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.
- package/LICENSE +21 -0
- package/PROJECT-SUMMARY.md +188 -0
- package/bun.lock +217 -0
- package/manage_opencode_projects.py +94 -0
- package/package.json +55 -0
- package/src/bin/opencode-manager.ts +4 -0
- package/src/lib/opencode-data.ts +311 -0
- package/src/opencode-tui.tsx +1046 -0
- package/tsconfig.json +17 -0
|
@@ -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
|
+
})
|