opencode-manager 0.1.0

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.
@@ -0,0 +1,1046 @@
1
+ import type { KeyEvent, SelectOption } from "@opentui/core"
2
+ import { render, useKeyboard, useRenderer } from "@opentui/react"
3
+ import React, {
4
+ forwardRef,
5
+ useCallback,
6
+ useEffect,
7
+ useImperativeHandle,
8
+ useMemo,
9
+ useRef,
10
+ useState,
11
+ } from "react"
12
+ import { resolve } from "node:path"
13
+ import {
14
+ DEFAULT_ROOT,
15
+ ProjectRecord,
16
+ SessionRecord,
17
+ deleteProjectMetadata,
18
+ deleteSessionMetadata,
19
+ describeProject,
20
+ describeSession,
21
+ formatDate,
22
+ formatDisplayPath,
23
+ loadProjectRecords,
24
+ loadSessionRecords,
25
+ } from "./lib/opencode-data"
26
+
27
+ type TabKey = "projects" | "sessions"
28
+
29
+ type PanelHandle = {
30
+ handleKey: (key: KeyEvent) => void
31
+ refresh: () => void
32
+ }
33
+
34
+ type NotificationLevel = "info" | "error"
35
+
36
+ type ConfirmState = {
37
+ title: string
38
+ details?: string[]
39
+ actionLabel?: string
40
+ onConfirm: () => Promise<void> | void
41
+ }
42
+
43
+ type ProjectsPanelProps = {
44
+ root: string
45
+ active: boolean
46
+ locked: boolean
47
+ searchQuery: string
48
+ onNotify: (message: string, level?: NotificationLevel) => void
49
+ requestConfirm: (state: ConfirmState) => void
50
+ onNavigateToSessions: (projectId: string) => void
51
+ }
52
+
53
+ type SessionsPanelProps = {
54
+ root: string
55
+ active: boolean
56
+ locked: boolean
57
+ projectFilter: string | null
58
+ searchQuery: string
59
+ onNotify: (message: string, level?: NotificationLevel) => void
60
+ requestConfirm: (state: ConfirmState) => void
61
+ onClearFilter: () => void
62
+ }
63
+
64
+ const MAX_CONFIRM_PREVIEW = 5
65
+
66
+ // Palette used for subtle color accents
67
+ const PALETTE = {
68
+ primary: "#a5b4fc", // lavender
69
+ accent: "#93c5fd", // sky
70
+ success: "#86efac", // green
71
+ danger: "#fca5a5", // red
72
+ info: "#38bdf8", // cyan
73
+ key: "#fbbf24", // amber
74
+ muted: "#9ca3af", // gray
75
+ } as const
76
+
77
+ type ChildrenProps = { children: React.ReactNode }
78
+
79
+ const Section = ({ title, children }: { title: string } & ChildrenProps) => (
80
+ <box title={title} style={{ border: true, padding: 1, marginBottom: 1, flexDirection: "column" }}>
81
+ {children}
82
+ </box>
83
+ )
84
+
85
+ const SearchBar = ({
86
+ active,
87
+ context,
88
+ query,
89
+ }: {
90
+ active: boolean
91
+ context: string
92
+ query: string
93
+ }) => (
94
+ <box style={{ border: true, padding: 1, marginBottom: 1, flexDirection: "row", gap: 1 }}>
95
+ <text fg={PALETTE.accent}>Search</text>
96
+ <text>({context}):</text>
97
+ <text fg={active ? PALETTE.key : PALETTE.muted}>{active ? "/" + query : query || "(none)"}</text>
98
+ <text>—</text>
99
+ <text>Enter apply</text>
100
+ <text>•</text>
101
+ <text>Esc clear</text>
102
+ </box>
103
+ )
104
+
105
+ const Row = ({ children }: ChildrenProps) => {
106
+ const kids = React.Children.toArray(children).filter((c) => !(typeof c === "string" && c.trim() === ""))
107
+ return <box style={{ flexDirection: "row", alignItems: "baseline" }}>{kids as any}</box>
108
+ }
109
+
110
+ const Bullet = ({ children }: ChildrenProps) => {
111
+ const kids = React.Children.toArray(children).filter((c) => !(typeof c === "string" && c.trim() === ""))
112
+ return (
113
+ <Row>
114
+ <text fg={PALETTE.muted}>• </text>
115
+ <box style={{ flexDirection: "row", flexWrap: "wrap" }}>{kids as any}</box>
116
+ </Row>
117
+ )
118
+ }
119
+
120
+ const Columns = ({ children }: ChildrenProps) => {
121
+ const kids = React.Children.toArray(children).filter((c) => !(typeof c === "string" && c.trim() === ""))
122
+ return <box style={{ flexDirection: "row", gap: 2, marginTop: 1, flexGrow: 1 }}>{kids as any}</box>
123
+ }
124
+
125
+ const KeyChip = ({ k }: { k: string }) => <text fg={PALETTE.key}>[{k}]</text>
126
+
127
+ const ProjectsPanel = forwardRef<PanelHandle, ProjectsPanelProps>(function ProjectsPanel(
128
+ { root, active, locked, searchQuery, onNotify, requestConfirm, onNavigateToSessions },
129
+ ref,
130
+ ) {
131
+ const [records, setRecords] = useState<ProjectRecord[]>([])
132
+ const [loading, setLoading] = useState(true)
133
+ const [error, setError] = useState<string | null>(null)
134
+ const [missingOnly, setMissingOnly] = useState(false)
135
+ const [cursor, setCursor] = useState(0)
136
+ const [selectedIndexes, setSelectedIndexes] = useState<Set<number>>(new Set())
137
+
138
+ const missingCount = useMemo(() => records.filter((record) => record.state === "missing").length, [records])
139
+
140
+ const visibleRecords = useMemo(() => {
141
+ const base = missingOnly ? records.filter((record) => record.state === "missing") : records
142
+ const q = searchQuery?.trim().toLowerCase() ?? ""
143
+ if (!q) return base
144
+ const tokens = q.split(/\s+/).filter(Boolean)
145
+ return base.filter((record) => {
146
+ const id = (record.projectId || "").toLowerCase()
147
+ const path = (record.worktree || "").toLowerCase()
148
+ return tokens.every((tok) => id.includes(tok) || path.includes(tok))
149
+ })
150
+ }, [records, missingOnly, searchQuery])
151
+
152
+ const currentRecord = visibleRecords[cursor]
153
+
154
+ const refreshRecords = useCallback(
155
+ async (silent = false) => {
156
+ setLoading(true)
157
+ setError(null)
158
+ try {
159
+ const data = await loadProjectRecords({ root })
160
+ setRecords(data)
161
+ if (!silent) {
162
+ onNotify(`Loaded ${data.length} project(s).`)
163
+ }
164
+ } catch (error) {
165
+ const message = error instanceof Error ? error.message : String(error)
166
+ setError(message)
167
+ onNotify(`Failed to load projects: ${message}`, "error")
168
+ } finally {
169
+ setLoading(false)
170
+ }
171
+ },
172
+ [root, onNotify],
173
+ )
174
+
175
+ useEffect(() => {
176
+ void refreshRecords(true)
177
+ }, [refreshRecords])
178
+
179
+ useEffect(() => {
180
+ setSelectedIndexes((prev) => {
181
+ if (prev.size === 0) {
182
+ return prev
183
+ }
184
+ const validIndexes = new Set(records.map((record) => record.index))
185
+ let changed = false
186
+ const next = new Set<number>()
187
+ for (const index of prev) {
188
+ if (validIndexes.has(index)) {
189
+ next.add(index)
190
+ } else {
191
+ changed = true
192
+ }
193
+ }
194
+ return changed ? next : prev
195
+ })
196
+ }, [records])
197
+
198
+ useEffect(() => {
199
+ setCursor((prev) => {
200
+ if (visibleRecords.length === 0) {
201
+ return 0
202
+ }
203
+ return Math.min(prev, visibleRecords.length - 1)
204
+ })
205
+ }, [visibleRecords.length])
206
+
207
+ const toggleSelection = useCallback((record: ProjectRecord | undefined) => {
208
+ if (!record) {
209
+ return
210
+ }
211
+ setSelectedIndexes((prev) => {
212
+ const next = new Set(prev)
213
+ if (next.has(record.index)) {
214
+ next.delete(record.index)
215
+ } else {
216
+ next.add(record.index)
217
+ }
218
+ return next
219
+ })
220
+ }, [])
221
+
222
+ const selectedRecords = useMemo(() => {
223
+ if (selectedIndexes.size === 0) {
224
+ return currentRecord ? [currentRecord] : []
225
+ }
226
+ return records.filter((record) => selectedIndexes.has(record.index))
227
+ }, [records, selectedIndexes, currentRecord])
228
+
229
+ const selectOptions: SelectOption[] = useMemo(() => {
230
+ return visibleRecords.map((record) => {
231
+ const selected = selectedIndexes.has(record.index)
232
+ const prefix = selected ? "[*]" : "[ ]"
233
+ const label = `${prefix} #${record.index} ${formatDisplayPath(record.worktree)} (${record.state})`
234
+ return {
235
+ name: label,
236
+ description: "",
237
+ value: record.index,
238
+ }
239
+ })
240
+ }, [visibleRecords, selectedIndexes])
241
+
242
+ const requestDeletion = useCallback(() => {
243
+ if (selectedRecords.length === 0) {
244
+ onNotify("No projects selected for deletion.", "error")
245
+ return
246
+ }
247
+ requestConfirm({
248
+ title: `Delete ${selectedRecords.length} project metadata entr${selectedRecords.length === 1 ? "y" : "ies"}?`,
249
+ details: selectedRecords
250
+ .slice(0, MAX_CONFIRM_PREVIEW)
251
+ .map((record) => describeProject(record, { fullPath: true })),
252
+ onConfirm: async () => {
253
+ const { removed, failed } = await deleteProjectMetadata(selectedRecords)
254
+ setSelectedIndexes(new Set())
255
+ const msg = failed.length
256
+ ? `Removed ${removed.length} project file(s). Failed: ${failed.length}`
257
+ : `Removed ${removed.length} project file(s).`
258
+ onNotify(msg, failed.length ? "error" : "info")
259
+ await refreshRecords(true)
260
+ },
261
+ })
262
+ }, [selectedRecords, onNotify, requestConfirm, refreshRecords])
263
+
264
+ const handleKey = useCallback(
265
+ (key: KeyEvent) => {
266
+ if (!active || locked) {
267
+ return
268
+ }
269
+ const letter = key.sequence?.toLowerCase()
270
+ if (key.name === "space") {
271
+ key.preventDefault()
272
+ toggleSelection(currentRecord)
273
+ return
274
+ }
275
+ if (letter === "m") {
276
+ setMissingOnly((prev) => !prev)
277
+ setCursor(0)
278
+ return
279
+ }
280
+ if (letter === "a") {
281
+ setSelectedIndexes((prev) => {
282
+ const next = new Set(prev)
283
+ if (next.size >= visibleRecords.length) {
284
+ return new Set<number>()
285
+ }
286
+ for (const record of visibleRecords) {
287
+ next.add(record.index)
288
+ }
289
+ return next
290
+ })
291
+ return
292
+ }
293
+ if (key.name === "escape") {
294
+ setSelectedIndexes(new Set())
295
+ return
296
+ }
297
+ if (letter === "d") {
298
+ requestDeletion()
299
+ return
300
+ }
301
+ if (key.name === "return" || key.name === "enter") {
302
+ if (currentRecord) {
303
+ onNavigateToSessions(currentRecord.projectId)
304
+ }
305
+ return
306
+ }
307
+ },
308
+ [active, locked, currentRecord, visibleRecords, onNavigateToSessions, requestDeletion, toggleSelection],
309
+ )
310
+
311
+ useImperativeHandle(
312
+ ref,
313
+ () => ({
314
+ handleKey,
315
+ refresh: () => {
316
+ void refreshRecords(true)
317
+ },
318
+ }),
319
+ [handleKey, refreshRecords],
320
+ )
321
+
322
+ return (
323
+ <box
324
+ title="Projects"
325
+ style={{
326
+ border: true,
327
+ borderColor: active ? "#22d3ee" : "#374151",
328
+ flexDirection: "column",
329
+ flexGrow: active ? 6 : 4,
330
+ padding: 1,
331
+ }}
332
+ >
333
+ <box flexDirection="column" marginBottom={1}>
334
+ <text>Filter: {missingOnly ? "missing only" : "all"}</text>
335
+ <text>
336
+ Total: {records.length} | Missing: {missingCount} | Selected: {selectedIndexes.size}
337
+ </text>
338
+ <text>
339
+ Keys: Space select, A select all, M toggle missing, D delete, Enter view sessions, Esc clear
340
+ </text>
341
+ </box>
342
+
343
+ {error ? (
344
+ <text fg="red">{error}</text>
345
+ ) : loading ? (
346
+ <text>Loading projects...</text>
347
+ ) : visibleRecords.length === 0 ? (
348
+ <text>No projects found.</text>
349
+ ) : (
350
+ <box style={{ flexGrow: 1, flexDirection: "column" }}>
351
+ <select
352
+ style={{ flexGrow: 1 }}
353
+ options={selectOptions}
354
+ selectedIndex={Math.min(cursor, selectOptions.length - 1)}
355
+ onChange={(index) => setCursor(index)}
356
+ onSelect={(index) => {
357
+ const record = visibleRecords[index]
358
+ if (record) {
359
+ onNavigateToSessions(record.projectId)
360
+ }
361
+ }}
362
+ focused={active && !locked}
363
+ showScrollIndicator
364
+ showDescription
365
+ wrapSelection={false}
366
+ />
367
+ {currentRecord ? (
368
+ <box title="Details" style={{ border: true, marginTop: 1, padding: 1 }}>
369
+ <text>Project: {currentRecord.projectId} State: {currentRecord.state}</text>
370
+ <text>Bucket: {currentRecord.bucket} VCS: {currentRecord.vcs || "-"}</text>
371
+ <text>Created: {formatDate(currentRecord.createdAt)}</text>
372
+ <text>Path:</text>
373
+ <text>{formatDisplayPath(currentRecord.worktree, { fullPath: true })}</text>
374
+ </box>
375
+ ) : null}
376
+ </box>
377
+ )}
378
+ </box>
379
+ )
380
+ })
381
+
382
+ const SessionsPanel = forwardRef<PanelHandle, SessionsPanelProps>(function SessionsPanel(
383
+ { root, active, locked, projectFilter, searchQuery, onNotify, requestConfirm, onClearFilter },
384
+ ref,
385
+ ) {
386
+ const [records, setRecords] = useState<SessionRecord[]>([])
387
+ const [loading, setLoading] = useState(true)
388
+ const [error, setError] = useState<string | null>(null)
389
+ const [cursor, setCursor] = useState(0)
390
+ const [selectedIndexes, setSelectedIndexes] = useState<Set<number>>(new Set())
391
+ const [sortMode, setSortMode] = useState<"updated" | "created">("updated")
392
+
393
+ const visibleRecords = useMemo(() => {
394
+ const sorted = [...records].sort((a, b) => {
395
+ const aDate = sortMode === "created" ? (a.createdAt ?? a.updatedAt) : (a.updatedAt ?? a.createdAt)
396
+ const bDate = sortMode === "created" ? (b.createdAt ?? b.updatedAt) : (b.updatedAt ?? b.createdAt)
397
+ const aTime = aDate?.getTime() ?? 0
398
+ const bTime = bDate?.getTime() ?? 0
399
+ if (bTime !== aTime) return bTime - aTime
400
+ return a.sessionId.localeCompare(b.sessionId)
401
+ })
402
+ const q = searchQuery.trim().toLowerCase()
403
+ if (!q) return sorted
404
+ const tokens = q.split(/\s+/).filter(Boolean)
405
+ if (tokens.length === 0) return sorted
406
+ return sorted.filter((s) => {
407
+ const title = (s.title || "").toLowerCase()
408
+ const id = (s.sessionId || "").toLowerCase()
409
+ const dir = (s.directory || "").toLowerCase()
410
+ const proj = (s.projectId || "").toLowerCase()
411
+ return tokens.every((tok) => title.includes(tok) || id.includes(tok) || dir.includes(tok) || proj.includes(tok))
412
+ })
413
+ }, [records, sortMode, searchQuery])
414
+ const currentSession = visibleRecords[cursor]
415
+
416
+ const refreshRecords = useCallback(
417
+ async (silent = false) => {
418
+ setLoading(true)
419
+ setError(null)
420
+ try {
421
+ const data = await loadSessionRecords({ root, projectId: projectFilter || undefined })
422
+ setRecords(data)
423
+ if (!silent) {
424
+ onNotify(`Loaded ${data.length} session(s).`)
425
+ }
426
+ } catch (error) {
427
+ const message = error instanceof Error ? error.message : String(error)
428
+ setError(message)
429
+ onNotify(`Failed to load sessions: ${message}`, "error")
430
+ } finally {
431
+ setLoading(false)
432
+ }
433
+ },
434
+ [root, projectFilter, onNotify],
435
+ )
436
+
437
+ useEffect(() => {
438
+ void refreshRecords(true)
439
+ }, [refreshRecords])
440
+
441
+ useEffect(() => {
442
+ setSelectedIndexes((prev) => {
443
+ if (prev.size === 0) {
444
+ return prev
445
+ }
446
+ const validIndexes = new Set(records.map((record) => record.index))
447
+ let changed = false
448
+ const next = new Set<number>()
449
+ for (const index of prev) {
450
+ if (validIndexes.has(index)) {
451
+ next.add(index)
452
+ } else {
453
+ changed = true
454
+ }
455
+ }
456
+ return changed ? next : prev
457
+ })
458
+ }, [records])
459
+
460
+ useEffect(() => {
461
+ setCursor((prev) => {
462
+ if (visibleRecords.length === 0) {
463
+ return 0
464
+ }
465
+ return Math.min(prev, visibleRecords.length - 1)
466
+ })
467
+ }, [visibleRecords.length])
468
+
469
+ const toggleSelection = useCallback((session: SessionRecord | undefined) => {
470
+ if (!session) {
471
+ return
472
+ }
473
+ setSelectedIndexes((prev) => {
474
+ const next = new Set(prev)
475
+ if (next.has(session.index)) {
476
+ next.delete(session.index)
477
+ } else {
478
+ next.add(session.index)
479
+ }
480
+ return next
481
+ })
482
+ }, [])
483
+
484
+ const selectedSessions = useMemo(() => {
485
+ if (selectedIndexes.size === 0) {
486
+ return currentSession ? [currentSession] : []
487
+ }
488
+ return records.filter((record) => selectedIndexes.has(record.index))
489
+ }, [records, selectedIndexes, currentSession])
490
+
491
+ const selectOptions: SelectOption[] = useMemo(() => {
492
+ return visibleRecords.map((session, idx) => {
493
+ const selected = selectedIndexes.has(session.index)
494
+ const prefix = selected ? "[*]" : "[ ]"
495
+ const primary = session.title && session.title.trim().length > 0 ? session.title : session.sessionId
496
+ const label = `${prefix} #${idx + 1} ${primary} (${session.version || "unknown"})`
497
+ const stampBase = sortMode === "created" ? (session.createdAt ?? session.updatedAt) : (session.updatedAt ?? session.createdAt)
498
+ const stamp = stampBase ? `${sortMode}: ${formatDate(stampBase)}` : `${sortMode}: ?`
499
+ return {
500
+ name: label,
501
+ description: stamp,
502
+ value: session.index,
503
+ }
504
+ })
505
+ }, [visibleRecords, selectedIndexes, sortMode])
506
+
507
+ const requestDeletion = useCallback(() => {
508
+ if (selectedSessions.length === 0) {
509
+ onNotify("No sessions selected for deletion.", "error")
510
+ return
511
+ }
512
+ requestConfirm({
513
+ title: `Delete ${selectedSessions.length} session entr${selectedSessions.length === 1 ? "y" : "ies"}?`,
514
+ details: selectedSessions
515
+ .slice(0, MAX_CONFIRM_PREVIEW)
516
+ .map((session) => describeSession(session, { fullPath: true })),
517
+ onConfirm: async () => {
518
+ const { removed, failed } = await deleteSessionMetadata(selectedSessions)
519
+ setSelectedIndexes(new Set())
520
+ const msg = failed.length
521
+ ? `Removed ${removed.length} session file(s). Failed: ${failed.length}`
522
+ : `Removed ${removed.length} session file(s).`
523
+ onNotify(msg, failed.length ? "error" : "info")
524
+ await refreshRecords(true)
525
+ },
526
+ })
527
+ }, [selectedSessions, onNotify, requestConfirm, refreshRecords])
528
+
529
+ const handleKey = useCallback(
530
+ (key: KeyEvent) => {
531
+ if (!active || locked) {
532
+ return
533
+ }
534
+
535
+ const letter = key.sequence?.toLowerCase()
536
+ if (key.name === "space") {
537
+ key.preventDefault()
538
+ toggleSelection(currentSession)
539
+ return
540
+ }
541
+ if (letter === "s") {
542
+ setSortMode((prev) => (prev === "updated" ? "created" : "updated"))
543
+ return
544
+ }
545
+ if (letter === "c" && projectFilter) {
546
+ onClearFilter()
547
+ return
548
+ }
549
+ if (key.name === "escape") {
550
+ setSelectedIndexes(new Set())
551
+ return
552
+ }
553
+ if (letter === "d") {
554
+ requestDeletion()
555
+ return
556
+ }
557
+ if (key.name === "return" || key.name === "enter") {
558
+ if (currentSession) {
559
+ const title = currentSession.title && currentSession.title.trim().length > 0 ? currentSession.title : currentSession.sessionId
560
+ onNotify(`Session ${title} [${currentSession.sessionId}] → ${formatDisplayPath(currentSession.directory)}`)
561
+ }
562
+ return
563
+ }
564
+ },
565
+ [active, locked, currentSession, projectFilter, onClearFilter, onNotify, requestDeletion, toggleSelection],
566
+ )
567
+
568
+ useImperativeHandle(
569
+ ref,
570
+ () => ({
571
+ handleKey,
572
+ refresh: () => {
573
+ void refreshRecords(true)
574
+ },
575
+ }),
576
+ [handleKey, refreshRecords],
577
+ )
578
+
579
+ return (
580
+ <box
581
+ title="Sessions"
582
+ style={{
583
+ border: true,
584
+ borderColor: active ? "#22c55e" : "#374151",
585
+ flexDirection: "column",
586
+ flexGrow: active ? 6 : 4,
587
+ padding: 1,
588
+ }}
589
+ >
590
+ <box flexDirection="column" marginBottom={1}>
591
+ <text>Filter: {projectFilter ? `project ${projectFilter}` : "none"} | Sort: {sortMode} | Search: {searchQuery || "(none)"} | Selected: {selectedIndexes.size}</text>
592
+ <text>Keys: Space select, S sort, D delete, C clear filter, Enter details, Esc clear</text>
593
+ </box>
594
+
595
+ {error ? (
596
+ <text fg="red">{error}</text>
597
+ ) : loading ? (
598
+ <text>Loading sessions...</text>
599
+ ) : visibleRecords.length === 0 ? (
600
+ <text>No sessions found.</text>
601
+ ) : (
602
+ <box style={{ flexGrow: 1, flexDirection: "column" }}>
603
+ <select
604
+ style={{ flexGrow: 1 }}
605
+ options={selectOptions}
606
+ selectedIndex={Math.min(cursor, selectOptions.length - 1)}
607
+ onChange={(index) => setCursor(index)}
608
+ onSelect={(index) => {
609
+ const session = visibleRecords[index]
610
+ if (session) {
611
+ const title = session.title && session.title.trim().length > 0 ? session.title : session.sessionId
612
+ onNotify(`Session ${title} [${session.sessionId}] → ${formatDisplayPath(session.directory)}`)
613
+ }
614
+ }}
615
+ focused={active && !locked}
616
+ showScrollIndicator
617
+ showDescription={false}
618
+ wrapSelection={false}
619
+ />
620
+ {currentSession ? (
621
+ <box title="Details" style={{ border: true, marginTop: 1, padding: 1 }}>
622
+ <text>
623
+ Session: {currentSession.sessionId} Version: {currentSession.version || "unknown"}
624
+ </text>
625
+ <text>Title: {currentSession.title && currentSession.title.trim().length > 0 ? currentSession.title : "(no title)"}</text>
626
+ <text>Project: {currentSession.projectId}</text>
627
+ <text>Updated: {formatDate(currentSession.updatedAt || currentSession.createdAt)}</text>
628
+ <text>Directory:</text>
629
+ <text>{formatDisplayPath(currentSession.directory, { fullPath: true })}</text>
630
+ </box>
631
+ ) : null}
632
+ </box>
633
+ )}
634
+ </box>
635
+ )
636
+ })
637
+
638
+ const StatusBar = ({ status, level }: { status: string; level: NotificationLevel }) => (
639
+ <box
640
+ style={{
641
+ border: true,
642
+ borderColor: level === "error" ? "#ef4444" : "#3b82f6",
643
+ paddingLeft: 1,
644
+ paddingRight: 1,
645
+ height: 3,
646
+ marginTop: 1,
647
+ }}
648
+ >
649
+ <text fg={level === "error" ? "#ef4444" : "#38bdf8"}>{status}</text>
650
+ </box>
651
+ )
652
+
653
+ const ConfirmBar = ({ state, busy }: { state: ConfirmState; busy: boolean }) => (
654
+ <box
655
+ style={{
656
+ border: true,
657
+ borderColor: "#f97316",
658
+ flexDirection: "column",
659
+ marginTop: 1,
660
+ padding: 1,
661
+ }}
662
+ >
663
+ <text fg="#f97316">{state.title}</text>
664
+ {state.details?.map((detail, idx) => (
665
+ <text key={idx}>{detail}</text>
666
+ ))}
667
+ <text>
668
+ {busy ? "Working..." : "Press Y/Enter to confirm, N/Esc to cancel"}
669
+ </text>
670
+ </box>
671
+ )
672
+
673
+ const HelpScreen = ({ onDismiss }: { onDismiss: () => void }) => {
674
+ return (
675
+ <box style={{ flexDirection: "column", flexGrow: 1, padding: 2, border: true }}>
676
+ <text fg={PALETTE.primary}>OpenCode Metadata Manager — Help</text>
677
+ <text fg={PALETTE.muted}>Quick reference for keys and actions</text>
678
+ <Columns>
679
+ <box style={{ flexDirection: "column", flexGrow: 1 }}>
680
+ <Section title="Global">
681
+ <Bullet>
682
+ <KeyChip k="Tab" /> <text> / </text> <KeyChip k="1" /> <text> / </text> <KeyChip k="2" />
683
+ <text> — Switch tabs</text>
684
+ </Bullet>
685
+ <Bullet>
686
+ <KeyChip k="R" /> <text> — Reload active view</text>
687
+ </Bullet>
688
+ <Bullet>
689
+ <text>Search current tab: </text>
690
+ <KeyChip k="/" /> <text> — start, </text> <KeyChip k="X" /> <text> — clear</text>
691
+ </Bullet>
692
+ <Bullet>
693
+ <KeyChip k="?" /> <text> / </text> <KeyChip k="H" /> <text> — Toggle help</text>
694
+ </Bullet>
695
+ <Bullet>
696
+ <text>Quit: </text>
697
+ <KeyChip k="Q" />
698
+ </Bullet>
699
+ </Section>
700
+
701
+ <Section title="Projects">
702
+ <Bullet>
703
+ <text>Move: </text>
704
+ <KeyChip k="Up" /> <text> / </text> <KeyChip k="Down" />
705
+ </Bullet>
706
+ <Bullet>
707
+ <text>Select: </text>
708
+ <KeyChip k="Space" /> <text> — Toggle highlighted</text>
709
+ </Bullet>
710
+ <Bullet>
711
+ <text>Select all: </text>
712
+ <KeyChip k="A" />
713
+ </Bullet>
714
+ <Bullet>
715
+ <text>Filter: </text>
716
+ <KeyChip k="M" /> <text> — Missing-only</text>
717
+ </Bullet>
718
+ <Bullet>
719
+ <text fg={PALETTE.danger}>Delete: </text>
720
+ <KeyChip k="D" />
721
+ <text> — With confirmation</text>
722
+ </Bullet>
723
+ <Bullet>
724
+ <text>Open sessions: </text>
725
+ <KeyChip k="Enter" />
726
+ </Bullet>
727
+ <Bullet>
728
+ <text>Clear selection: </text>
729
+ <KeyChip k="Esc" />
730
+ </Bullet>
731
+ </Section>
732
+ </box>
733
+
734
+ <box style={{ flexDirection: "column", flexGrow: 1 }}>
735
+ <Section title="Sessions">
736
+ <Bullet>
737
+ <text>Move: </text>
738
+ <KeyChip k="Up" /> <text> / </text> <KeyChip k="Down" />
739
+ </Bullet>
740
+ <Bullet>
741
+ <text>Select: </text>
742
+ <KeyChip k="Space" /> <text> — Toggle highlighted</text>
743
+ </Bullet>
744
+ <Bullet>
745
+ <text>Toggle sort (updated/created): </text>
746
+ <KeyChip k="S" />
747
+ </Bullet>
748
+ <Bullet>
749
+ <text>Clear project filter: </text>
750
+ <KeyChip k="C" />
751
+ </Bullet>
752
+ <Bullet>
753
+ <text fg={PALETTE.danger}>Delete: </text>
754
+ <KeyChip k="D" />
755
+ <text> — With confirmation</text>
756
+ </Bullet>
757
+ <Bullet>
758
+ <text>Show details: </text>
759
+ <KeyChip k="Enter" />
760
+ </Bullet>
761
+ <Bullet>
762
+ <text>Clear selection: </text>
763
+ <KeyChip k="Esc" />
764
+ </Bullet>
765
+ </Section>
766
+
767
+ <Section title="Tips">
768
+ <Bullet>
769
+ <text>Use </text> <KeyChip k="M" /> <text> to quickly isolate missing projects.</text>
770
+ </Bullet>
771
+ <Bullet>
772
+ <text>Press </text> <KeyChip k="R" /> <text> to refresh after cleanup.</text>
773
+ </Bullet>
774
+ <Bullet>
775
+ <text>Dismiss help with </text> <KeyChip k="Enter" /> <text> or </text> <KeyChip k="Esc" />
776
+ </Bullet>
777
+ </Section>
778
+ </box>
779
+ </Columns>
780
+ <text fg={PALETTE.info}>Press Enter or Esc to dismiss this screen.</text>
781
+ </box>
782
+ )
783
+ }
784
+
785
+ const App = ({ root }: { root: string }) => {
786
+ const renderer = useRenderer()
787
+ const projectsRef = useRef<PanelHandle>(null)
788
+ const sessionsRef = useRef<PanelHandle>(null)
789
+
790
+ const [activeTab, setActiveTab] = useState<TabKey>("projects")
791
+ const [sessionFilter, setSessionFilter] = useState<string | null>(null)
792
+ const [searchActive, setSearchActive] = useState(false)
793
+ const [searchQuery, setSearchQuery] = useState("")
794
+ const [status, setStatus] = useState("Ready")
795
+ const [statusLevel, setStatusLevel] = useState<NotificationLevel>("info")
796
+ const [confirmState, setConfirmState] = useState<ConfirmState | null>(null)
797
+ const [showHelp, setShowHelp] = useState(true)
798
+ const [confirmBusy, setConfirmBusy] = useState(false)
799
+
800
+ const notify = useCallback((message: string, level: NotificationLevel = "info") => {
801
+ setStatus(message)
802
+ setStatusLevel(level)
803
+ }, [])
804
+
805
+ const requestConfirm = useCallback((state: ConfirmState) => {
806
+ setConfirmState(state)
807
+ setConfirmBusy(false)
808
+ }, [])
809
+
810
+ const cancelConfirm = useCallback(() => {
811
+ setConfirmState(null)
812
+ setConfirmBusy(false)
813
+ }, [])
814
+
815
+ const executeConfirm = useCallback(async () => {
816
+ if (!confirmState || confirmBusy) {
817
+ return
818
+ }
819
+ try {
820
+ setConfirmBusy(true)
821
+ await confirmState.onConfirm()
822
+ } catch (error) {
823
+ const message = error instanceof Error ? error.message : String(error)
824
+ notify(`Action failed: ${message}`, "error")
825
+ } finally {
826
+ setConfirmBusy(false)
827
+ setConfirmState(null)
828
+ }
829
+ }, [confirmState, confirmBusy, notify])
830
+
831
+ const switchTab = useCallback((direction: "next" | "prev" | TabKey) => {
832
+ setActiveTab((prev) => {
833
+ if (direction === "next" || direction === "prev") {
834
+ return prev === "projects" ? "sessions" : "projects"
835
+ }
836
+ return direction
837
+ })
838
+ }, [])
839
+
840
+ const handleGlobalKey = useCallback(
841
+ (key: KeyEvent) => {
842
+ // Search input mode takes precedence
843
+ if (searchActive) {
844
+ if (key.name === "escape") {
845
+ setSearchActive(false)
846
+ setSearchQuery("")
847
+ return
848
+ }
849
+ if (key.name === "return" || key.name === "enter") {
850
+ setSearchActive(false)
851
+ return
852
+ }
853
+ if (key.name === "backspace") {
854
+ setSearchQuery((prev) => prev.slice(0, -1))
855
+ return
856
+ }
857
+ const ch = key.sequence
858
+ if (ch && ch.length === 1 && !key.ctrl && !key.meta) {
859
+ setSearchQuery((prev) => prev + ch)
860
+ return
861
+ }
862
+ return
863
+ }
864
+ if (confirmState) {
865
+ const letter = key.sequence?.toLowerCase()
866
+ if (key.name === "escape" || letter === "n") {
867
+ cancelConfirm()
868
+ return
869
+ }
870
+ if (key.name === "return" || key.name === "enter" || letter === "y") {
871
+ void executeConfirm()
872
+ return
873
+ }
874
+ return
875
+ }
876
+
877
+ if (showHelp) {
878
+ const letter = key.sequence?.toLowerCase()
879
+ if (key.name === "escape" || key.name === "return" || key.name === "enter" || letter === "?" || letter === "h") {
880
+ setShowHelp(false)
881
+ return
882
+ }
883
+ return
884
+ }
885
+
886
+ const letter = key.sequence?.toLowerCase()
887
+
888
+ if (letter === "q" || (key.ctrl && key.name === "c")) {
889
+ renderer.destroy()
890
+ return
891
+ }
892
+
893
+ if (letter === "?" || letter === "h") {
894
+ setShowHelp((v) => !v)
895
+ return
896
+ }
897
+
898
+ if (letter === "/") {
899
+ setSearchActive(true)
900
+ setSearchQuery("")
901
+ return
902
+ }
903
+ if (letter === "x" && searchQuery) {
904
+ setSearchQuery("")
905
+ return
906
+ }
907
+
908
+ if (key.name === "tab") {
909
+ switchTab("next")
910
+ return
911
+ }
912
+
913
+ if (letter === "1") {
914
+ switchTab("projects")
915
+ return
916
+ }
917
+ if (letter === "2") {
918
+ switchTab("sessions")
919
+ return
920
+ }
921
+
922
+ if (letter === "r") {
923
+ if (activeTab === "projects") {
924
+ projectsRef.current?.refresh()
925
+ } else {
926
+ sessionsRef.current?.refresh()
927
+ }
928
+ notify("Reload requested...")
929
+ return
930
+ }
931
+
932
+ const handler = activeTab === "projects" ? projectsRef.current : sessionsRef.current
933
+ handler?.handleKey(key)
934
+ },
935
+ [activeTab, cancelConfirm, confirmState, executeConfirm, notify, renderer, searchActive, searchQuery, showHelp, switchTab],
936
+ )
937
+
938
+ useKeyboard(handleGlobalKey)
939
+
940
+ const handleNavigateToSessions = useCallback(
941
+ (projectId: string) => {
942
+ setSessionFilter(projectId)
943
+ setActiveTab("sessions")
944
+ notify(`Filtering sessions by ${projectId}`)
945
+ },
946
+ [notify],
947
+ )
948
+
949
+ const clearSessionFilter = useCallback(() => {
950
+ setSessionFilter(null)
951
+ notify("Cleared session filter")
952
+ }, [notify])
953
+
954
+ return (
955
+ <box style={{ flexDirection: "column", padding: 1, flexGrow: 1 }}>
956
+ <box flexDirection="column" marginBottom={1}>
957
+ <text fg="#a5b4fc">OpenCode Metadata Manager</text>
958
+ <text>Root: {root}</text>
959
+ <text>
960
+ Tabs: [1] Projects [2] Sessions | Active: {activeTab} | Global: Tab switch, / search, X clear, R reload, Q quit, ? help
961
+ </text>
962
+ {sessionFilter ? <text fg="#a3e635">Session filter: {sessionFilter}</text> : null}
963
+ </box>
964
+
965
+ {showHelp
966
+ ? null
967
+ : searchActive || searchQuery
968
+ ? <SearchBar active={searchActive} context={activeTab} query={searchQuery} />
969
+ : null}
970
+
971
+ {showHelp ? (
972
+ <HelpScreen onDismiss={() => setShowHelp(false)} />
973
+ ) : (
974
+ <box style={{ flexDirection: "row", gap: 1, flexGrow: 1 }}>
975
+ <ProjectsPanel
976
+ ref={projectsRef}
977
+ root={root}
978
+ active={activeTab === "projects"}
979
+ locked={Boolean(confirmState) || showHelp}
980
+ searchQuery={activeTab === "projects" ? searchQuery : ""}
981
+ onNotify={notify}
982
+ requestConfirm={requestConfirm}
983
+ onNavigateToSessions={handleNavigateToSessions}
984
+ />
985
+ <SessionsPanel
986
+ ref={sessionsRef}
987
+ root={root}
988
+ active={activeTab === "sessions"}
989
+ locked={Boolean(confirmState) || showHelp}
990
+ projectFilter={sessionFilter}
991
+ searchQuery={activeTab === "sessions" ? searchQuery : ""}
992
+ onNotify={notify}
993
+ requestConfirm={requestConfirm}
994
+ onClearFilter={clearSessionFilter}
995
+ />
996
+ </box>
997
+ )}
998
+
999
+ <StatusBar status={status} level={statusLevel} />
1000
+ {confirmState ? <ConfirmBar state={confirmState} busy={confirmBusy} /> : null}
1001
+ </box>
1002
+ )
1003
+ }
1004
+
1005
+ function parseArgs(): { root: string } {
1006
+ const args = process.argv.slice(2)
1007
+ let root = DEFAULT_ROOT
1008
+
1009
+ for (let idx = 0; idx < args.length; idx += 1) {
1010
+ const token = args[idx]
1011
+ if (token === "--root" && args[idx + 1]) {
1012
+ root = resolve(args[idx + 1])
1013
+ idx += 1
1014
+ continue
1015
+ }
1016
+ if (token === "--help" || token === "-h") {
1017
+ printUsage()
1018
+ process.exit(0)
1019
+ }
1020
+ }
1021
+
1022
+ return { root }
1023
+ }
1024
+
1025
+ function printUsage(): void {
1026
+ console.log(`OpenCode Metadata TUI
1027
+ Usage: bun run tui [-- --root /path/to/storage]
1028
+
1029
+ Key bindings:
1030
+ Tab / 1 / 2 Switch between projects and sessions
1031
+ R Reload the active view
1032
+ Q Quit the application
1033
+ Projects view: Space select, A select all, M toggle missing filter, D delete, Enter jump to sessions
1034
+ Sessions view: Space select, D delete, C clear project filter, Enter show details
1035
+ `)
1036
+ }
1037
+
1038
+ async function bootstrap() {
1039
+ const { root } = parseArgs()
1040
+ await render(<App root={root} />)
1041
+ }
1042
+
1043
+ bootstrap().catch((error) => {
1044
+ console.error(error)
1045
+ process.exit(1)
1046
+ })