opencode-manager 0.3.0 → 0.4.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.
@@ -1,6 +1,5 @@
1
1
  import type { KeyEvent, SelectOption } from "@opentui/core"
2
- import { createRoot, useKeyboard, useRenderer } from "@opentui/react"
3
- import { createCliRenderer } from "@opentui/core"
2
+ import { useKeyboard, useRenderer } from "@opentui/react"
4
3
  import React, {
5
4
  forwardRef,
6
5
  useCallback,
@@ -10,10 +9,8 @@ import React, {
10
9
  useRef,
11
10
  useState,
12
11
  } from "react"
13
- import { resolve } from "node:path"
14
- import { exec } from "node:child_process"
12
+ import { copyToClipboardSync } from "../lib/clipboard"
15
13
  import {
16
- DEFAULT_ROOT,
17
14
  ProjectRecord,
18
15
  SessionRecord,
19
16
  deleteProjectMetadata,
@@ -37,7 +34,14 @@ import {
37
34
  computeProjectTokenSummary,
38
35
  computeGlobalTokenSummary,
39
36
  clearTokenCache,
40
- } from "./lib/opencode-data"
37
+ ChatMessage,
38
+ ChatPart,
39
+ loadSessionChatIndex,
40
+ hydrateChatMessageParts,
41
+ ChatSearchResult,
42
+ searchSessionsChat,
43
+ } from "../lib/opencode-data"
44
+ import { createSearcher, type SearchCandidate } from "../lib/search"
41
45
 
42
46
  type TabKey = "projects" | "sessions"
43
47
 
@@ -75,6 +79,7 @@ type SessionsPanelProps = {
75
79
  onNotify: (message: string, level?: NotificationLevel) => void
76
80
  requestConfirm: (state: ConfirmState) => void
77
81
  onClearFilter: () => void
82
+ onOpenChatViewer: (session: SessionRecord) => void
78
83
  }
79
84
 
80
85
  const MAX_CONFIRM_PREVIEW = 5
@@ -130,17 +135,8 @@ function formatAggregateSummaryShort(summary: AggregateTokenSummary): string {
130
135
  return base
131
136
  }
132
137
 
133
- function copyToClipboard(text: string): void {
134
- const cmd = process.platform === "darwin" ? "pbcopy" : "xclip -selection clipboard"
135
- const proc = exec(cmd, (error) => {
136
- if (error) {
137
- // We can't easily notify from here without context, but it's a best effort
138
- console.error("Failed to copy to clipboard:", error)
139
- }
140
- })
141
- proc.stdin?.write(text)
142
- proc.stdin?.end()
143
- }
138
+ // Clipboard functionality moved to ../lib/clipboard.ts
139
+ // Use copyToClipboardSyncSync for fire-and-forget clipboard operations
144
140
 
145
141
  type ChildrenProps = { children: React.ReactNode }
146
142
 
@@ -434,12 +430,17 @@ const ProjectsPanel = forwardRef<PanelHandle, ProjectsPanelProps>(function Proje
434
430
  }
435
431
  if (letter === "a") {
436
432
  setSelectedIndexes((prev) => {
437
- const next = new Set(prev)
438
- if (next.size >= visibleRecords.length) {
439
- return new Set<number>()
433
+ if (visibleRecords.length === 0) {
434
+ return prev
440
435
  }
436
+ const next = new Set(prev)
437
+ const allVisibleSelected = visibleRecords.every((record) => next.has(record.index))
441
438
  for (const record of visibleRecords) {
442
- next.add(record.index)
439
+ if (allVisibleSelected) {
440
+ next.delete(record.index)
441
+ } else {
442
+ next.add(record.index)
443
+ }
443
444
  }
444
445
  return next
445
446
  })
@@ -548,7 +549,7 @@ const ProjectsPanel = forwardRef<PanelHandle, ProjectsPanelProps>(function Proje
548
549
  })
549
550
 
550
551
  const SessionsPanel = forwardRef<PanelHandle, SessionsPanelProps>(function SessionsPanel(
551
- { root, active, locked, projectFilter, searchQuery, globalTokenSummary, onNotify, requestConfirm, onClearFilter },
552
+ { root, active, locked, projectFilter, searchQuery, globalTokenSummary, onNotify, requestConfirm, onClearFilter, onOpenChatViewer },
552
553
  ref,
553
554
  ) {
554
555
  const [records, setRecords] = useState<SessionRecord[]>([])
@@ -567,6 +568,24 @@ const SessionsPanel = forwardRef<PanelHandle, SessionsPanelProps>(function Sessi
567
568
  const [currentTokenSummary, setCurrentTokenSummary] = useState<TokenSummary | null>(null)
568
569
  const [filteredTokenSummary, setFilteredTokenSummary] = useState<AggregateTokenSummary | null>(null)
569
570
 
571
+ // Build fuzzy search candidates using the shared search library
572
+ const searchCandidates = useMemo((): SearchCandidate<SessionRecord>[] => {
573
+ return records.map((session) => ({
574
+ item: session,
575
+ searchText: [
576
+ session.title || "",
577
+ session.sessionId,
578
+ session.directory || "",
579
+ session.projectId,
580
+ ].join(" ").replace(/\s+/g, " ").trim(),
581
+ }))
582
+ }, [records])
583
+
584
+ // Build fuzzy searcher using the shared search library
585
+ const searcher = useMemo(() => {
586
+ return createSearcher(searchCandidates)
587
+ }, [searchCandidates])
588
+
570
589
  const visibleRecords = useMemo(() => {
571
590
  const sorted = [...records].sort((a, b) => {
572
591
  const aDate = sortMode === "created" ? (a.createdAt ?? a.updatedAt) : (a.updatedAt ?? a.createdAt)
@@ -576,18 +595,41 @@ const SessionsPanel = forwardRef<PanelHandle, SessionsPanelProps>(function Sessi
576
595
  if (bTime !== aTime) return bTime - aTime
577
596
  return a.sessionId.localeCompare(b.sessionId)
578
597
  })
579
- const q = searchQuery.trim().toLowerCase()
598
+ const q = searchQuery.trim()
580
599
  if (!q) return sorted
581
- const tokens = q.split(/\s+/).filter(Boolean)
582
- if (tokens.length === 0) return sorted
583
- return sorted.filter((s) => {
584
- const title = (s.title || "").toLowerCase()
585
- const id = (s.sessionId || "").toLowerCase()
586
- const dir = (s.directory || "").toLowerCase()
587
- const proj = (s.projectId || "").toLowerCase()
588
- return tokens.every((tok) => title.includes(tok) || id.includes(tok) || dir.includes(tok) || proj.includes(tok))
589
- })
590
- }, [records, sortMode, searchQuery])
600
+
601
+ // Use fuzzy search
602
+ const results = searcher.search(q, { returnMatchData: true })
603
+
604
+ // Sort by score (descending), then by timestamp (based on sortMode), then by sessionId
605
+ const matched = results
606
+ .map((match) => {
607
+ const session = match.item.item
608
+ const createdMs = session.createdAt?.getTime() ?? 0
609
+ const updatedMs = (session.updatedAt ?? session.createdAt)?.getTime() ?? 0
610
+ return {
611
+ session,
612
+ score: match.score,
613
+ timeMs: sortMode === "created" ? createdMs : updatedMs,
614
+ }
615
+ })
616
+ .sort((a, b) => {
617
+ // Primary: score descending
618
+ if (b.score !== a.score) return b.score - a.score
619
+ // Secondary: time descending
620
+ if (b.timeMs !== a.timeMs) return b.timeMs - a.timeMs
621
+ // Tertiary: sessionId for stability
622
+ return a.session.sessionId.localeCompare(b.session.sessionId)
623
+ })
624
+ .map((m) => m.session)
625
+
626
+ // Cap results for very broad queries
627
+ const MAX_RESULTS = 200
628
+ if (matched.length > MAX_RESULTS) {
629
+ return matched.slice(0, MAX_RESULTS)
630
+ }
631
+ return matched
632
+ }, [records, sortMode, searchQuery, searcher])
591
633
  const currentSession = visibleRecords[cursor]
592
634
 
593
635
  const refreshRecords = useCallback(
@@ -846,6 +888,24 @@ const SessionsPanel = forwardRef<PanelHandle, SessionsPanelProps>(function Sessi
846
888
  toggleSelection(currentSession)
847
889
  return
848
890
  }
891
+ if (letter === "a") {
892
+ setSelectedIndexes((prev) => {
893
+ if (visibleRecords.length === 0) {
894
+ return prev
895
+ }
896
+ const next = new Set(prev)
897
+ const allVisibleSelected = visibleRecords.every((session) => next.has(session.index))
898
+ for (const session of visibleRecords) {
899
+ if (allVisibleSelected) {
900
+ next.delete(session.index)
901
+ } else {
902
+ next.add(session.index)
903
+ }
904
+ }
905
+ return next
906
+ })
907
+ return
908
+ }
849
909
  if (letter === "s") {
850
910
  setSortMode((prev) => (prev === "updated" ? "created" : "updated"))
851
911
  return
@@ -864,7 +924,7 @@ const SessionsPanel = forwardRef<PanelHandle, SessionsPanelProps>(function Sessi
864
924
  }
865
925
  if (letter === "y") {
866
926
  if (currentSession) {
867
- copyToClipboard(currentSession.sessionId)
927
+ copyToClipboardSync(currentSession.sessionId)
868
928
  onNotify(`Copied ID ${currentSession.sessionId} to clipboard`)
869
929
  }
870
930
  return
@@ -914,6 +974,13 @@ const SessionsPanel = forwardRef<PanelHandle, SessionsPanelProps>(function Sessi
914
974
  })
915
975
  return
916
976
  }
977
+ // View chat history with V key
978
+ if (letter === 'v') {
979
+ if (currentSession) {
980
+ onOpenChatViewer(currentSession)
981
+ }
982
+ return
983
+ }
917
984
  if (key.name === "return" || key.name === "enter") {
918
985
  if (currentSession) {
919
986
  const title = currentSession.title && currentSession.title.trim().length > 0 ? currentSession.title : currentSession.sessionId
@@ -922,7 +989,7 @@ const SessionsPanel = forwardRef<PanelHandle, SessionsPanelProps>(function Sessi
922
989
  return
923
990
  }
924
991
  },
925
- [active, locked, currentSession, projectFilter, onClearFilter, onNotify, requestDeletion, toggleSelection, isRenaming, executeRename, isSelectingProject, availableProjects, projectCursor, operationMode, executeTransfer, selectedSessions, root],
992
+ [active, locked, currentSession, projectFilter, onClearFilter, onNotify, requestDeletion, toggleSelection, isRenaming, executeRename, isSelectingProject, availableProjects, projectCursor, operationMode, executeTransfer, selectedSessions, root, onOpenChatViewer],
926
993
  )
927
994
 
928
995
  useImperativeHandle(
@@ -948,8 +1015,8 @@ const SessionsPanel = forwardRef<PanelHandle, SessionsPanelProps>(function Sessi
948
1015
  }}
949
1016
  >
950
1017
  <box flexDirection="column" marginBottom={1}>
951
- <text>Filter: {projectFilter ? `project ${projectFilter}` : "none"} | Sort: {sortMode} | Search: {searchQuery || "(none)"} | Selected: {selectedIndexes.size}</text>
952
- <text>Keys: Space select, S sort, D delete, Y copy ID, Shift+R rename, M move, P copy, C clear filter</text>
1018
+ <text>Filter: {projectFilter ? `project ${projectFilter}` : "none"} | Sort: {sortMode} | Search: {searchQuery ? `${searchQuery} (fuzzy)` : "(none)"} | Selected: {selectedIndexes.size}</text>
1019
+ <text>Keys: Space select, A select all, S sort, D delete, Y copy ID, V view chat, F search chats, Shift+R rename, M move, P copy, C clear filter, Esc clear</text>
953
1020
  </box>
954
1021
 
955
1022
  {isRenaming ? (
@@ -1152,6 +1219,10 @@ const HelpScreen = ({ onDismiss }: { onDismiss: () => void }) => {
1152
1219
  <text>Select: </text>
1153
1220
  <KeyChip k="Space" /> <text> — Toggle highlighted</text>
1154
1221
  </Bullet>
1222
+ <Bullet>
1223
+ <text>Select all: </text>
1224
+ <KeyChip k="A" />
1225
+ </Bullet>
1155
1226
  <Bullet>
1156
1227
  <text>Toggle sort (updated/created): </text>
1157
1228
  <KeyChip k="S" />
@@ -1169,6 +1240,16 @@ const HelpScreen = ({ onDismiss }: { onDismiss: () => void }) => {
1169
1240
  <text>Copy ID: </text>
1170
1241
  <KeyChip k="Y" />
1171
1242
  </Bullet>
1243
+ <Bullet>
1244
+ <text fg={PALETTE.primary}>View chat: </text>
1245
+ <KeyChip k="V" />
1246
+ <text> — Open chat history</text>
1247
+ </Bullet>
1248
+ <Bullet>
1249
+ <text fg={PALETTE.info}>Search chats: </text>
1250
+ <KeyChip k="F" />
1251
+ <text> — Search all chat content</text>
1252
+ </Bullet>
1172
1253
  <Bullet>
1173
1254
  <text>Rename: </text>
1174
1255
  <KeyChip k="Shift+R" />
@@ -1191,6 +1272,45 @@ const HelpScreen = ({ onDismiss }: { onDismiss: () => void }) => {
1191
1272
  </Bullet>
1192
1273
  </Section>
1193
1274
 
1275
+ <Section title="Chat Search">
1276
+ <Bullet>
1277
+ <text>Type query, </text>
1278
+ <KeyChip k="Enter" /> <text> — search / open result</text>
1279
+ </Bullet>
1280
+ <Bullet>
1281
+ <text>Navigate: </text>
1282
+ <KeyChip k="Up" /> <text> / </text> <KeyChip k="Down" />
1283
+ </Bullet>
1284
+ <Bullet>
1285
+ <text>Close: </text>
1286
+ <KeyChip k="Esc" />
1287
+ </Bullet>
1288
+ </Section>
1289
+
1290
+ <Section title="Chat Viewer">
1291
+ <Bullet>
1292
+ <text>Navigate: </text>
1293
+ <KeyChip k="Up" /> <text> / </text> <KeyChip k="Down" />
1294
+ </Bullet>
1295
+ <Bullet>
1296
+ <text>Jump: </text>
1297
+ <KeyChip k="PgUp" /> <text> / </text> <KeyChip k="PgDn" />
1298
+ <text> (10 messages)</text>
1299
+ </Bullet>
1300
+ <Bullet>
1301
+ <text>First/Last: </text>
1302
+ <KeyChip k="Home" /> <text> / </text> <KeyChip k="End" />
1303
+ </Bullet>
1304
+ <Bullet>
1305
+ <text>Copy message: </text>
1306
+ <KeyChip k="Y" />
1307
+ </Bullet>
1308
+ <Bullet>
1309
+ <text>Close: </text>
1310
+ <KeyChip k="Esc" />
1311
+ </Bullet>
1312
+ </Section>
1313
+
1194
1314
  <Section title="Tips">
1195
1315
  <Bullet>
1196
1316
  <text>Use </text> <KeyChip k="M" /> <text> to quickly isolate missing projects.</text>
@@ -1209,7 +1329,204 @@ const HelpScreen = ({ onDismiss }: { onDismiss: () => void }) => {
1209
1329
  )
1210
1330
  }
1211
1331
 
1212
- const App = ({ root }: { root: string }) => {
1332
+ type ChatViewerProps = {
1333
+ session: SessionRecord
1334
+ messages: ChatMessage[]
1335
+ cursor: number
1336
+ onCursorChange: (index: number) => void
1337
+ loading: boolean
1338
+ error: string | null
1339
+ onClose: () => void
1340
+ onHydrateMessage: (message: ChatMessage) => void
1341
+ onCopyMessage: (message: ChatMessage) => void
1342
+ }
1343
+
1344
+ const ChatViewer = ({
1345
+ session,
1346
+ messages,
1347
+ cursor,
1348
+ onCursorChange,
1349
+ loading,
1350
+ error,
1351
+ onClose,
1352
+ onHydrateMessage,
1353
+ onCopyMessage,
1354
+ }: ChatViewerProps) => {
1355
+ const currentMessage = messages[cursor]
1356
+
1357
+ // Trigger hydration for current message if parts not loaded
1358
+ useEffect(() => {
1359
+ if (currentMessage && currentMessage.parts === null) {
1360
+ onHydrateMessage(currentMessage)
1361
+ }
1362
+ }, [currentMessage, onHydrateMessage])
1363
+
1364
+ const messageOptions: SelectOption[] = useMemo(() => {
1365
+ return messages.map((msg, idx) => {
1366
+ const roleLabel = msg.role === "user" ? "[user]" : msg.role === "assistant" ? "[asst]" : "[???]"
1367
+ const timestamp = msg.createdAt
1368
+ ? msg.createdAt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
1369
+ : "??:??"
1370
+ const preview = msg.previewText.slice(0, 60) + (msg.previewText.length > 60 ? "..." : "")
1371
+ return {
1372
+ name: `${roleLabel} ${timestamp} - ${preview}`,
1373
+ description: "",
1374
+ value: idx,
1375
+ }
1376
+ })
1377
+ }, [messages])
1378
+
1379
+ // Render parts for the current message
1380
+ const renderMessageContent = () => {
1381
+ if (!currentMessage) {
1382
+ return <text fg={PALETTE.muted}>No message selected</text>
1383
+ }
1384
+
1385
+ if (currentMessage.parts === null) {
1386
+ return <text fg={PALETTE.muted}>Loading message content...</text>
1387
+ }
1388
+
1389
+ if (currentMessage.parts.length === 0) {
1390
+ return <text fg={PALETTE.muted}>[no content]</text>
1391
+ }
1392
+
1393
+ return (
1394
+ <box style={{ flexDirection: "column", gap: 1 }}>
1395
+ {currentMessage.parts.map((part, idx) => (
1396
+ <box key={part.partId} style={{ flexDirection: "column" }}>
1397
+ {part.type === "tool" ? (
1398
+ <text fg={PALETTE.accent}>
1399
+ [tool: {part.toolName ?? "unknown"}] {part.toolStatus ?? ""}
1400
+ </text>
1401
+ ) : part.type === "subtask" ? (
1402
+ <text fg={PALETTE.info}>[subtask]</text>
1403
+ ) : null}
1404
+ <text>{part.text.slice(0, 2000)}{part.text.length > 2000 ? "\n[... truncated]" : ""}</text>
1405
+ </box>
1406
+ ))}
1407
+ {currentMessage.totalChars !== null && currentMessage.totalChars > 2000 ? (
1408
+ <text fg={PALETTE.muted}>
1409
+ Showing first 2000 chars of {currentMessage.totalChars} total
1410
+ </text>
1411
+ ) : null}
1412
+ </box>
1413
+ )
1414
+ }
1415
+
1416
+ const title = session.title && session.title.trim() ? session.title : session.sessionId
1417
+
1418
+ return (
1419
+ <box
1420
+ title={`Chat: ${title} (READ-ONLY)`}
1421
+ style={{
1422
+ position: 'absolute',
1423
+ top: 2,
1424
+ left: 2,
1425
+ right: 2,
1426
+ bottom: 2,
1427
+ border: true,
1428
+ borderColor: PALETTE.primary,
1429
+ flexDirection: 'column',
1430
+ padding: 1,
1431
+ zIndex: 200,
1432
+ }}
1433
+ backgroundColor="#1a1a2e"
1434
+ >
1435
+ {/* Header */}
1436
+ <box style={{ flexDirection: "row", marginBottom: 1 }}>
1437
+ <text fg={PALETTE.accent}>Session: </text>
1438
+ <text>{session.sessionId}</text>
1439
+ <text fg={PALETTE.muted}> | </text>
1440
+ <text fg={PALETTE.accent}>Project: </text>
1441
+ <text>{session.projectId}</text>
1442
+ <text fg={PALETTE.muted}> | </text>
1443
+ <text fg={PALETTE.accent}>Messages: </text>
1444
+ <text>{messages.length}</text>
1445
+ {loading ? <text fg={PALETTE.key}> (loading...)</text> : null}
1446
+ </box>
1447
+
1448
+ {error ? (
1449
+ <text fg={PALETTE.danger}>Error: {error}</text>
1450
+ ) : messages.length === 0 && !loading ? (
1451
+ <text fg={PALETTE.muted}>No messages found in this session.</text>
1452
+ ) : (
1453
+ <box style={{ flexDirection: "row", gap: 1, flexGrow: 1 }}>
1454
+ {/* Left pane: message list */}
1455
+ <box
1456
+ style={{
1457
+ border: true,
1458
+ borderColor: PALETTE.muted,
1459
+ flexGrow: 4,
1460
+ flexDirection: "column",
1461
+ padding: 1,
1462
+ }}
1463
+ title="Messages"
1464
+ >
1465
+ <select
1466
+ options={messageOptions}
1467
+ selectedIndex={cursor}
1468
+ onChange={onCursorChange}
1469
+ focused={true}
1470
+ showScrollIndicator
1471
+ wrapSelection={false}
1472
+ />
1473
+ </box>
1474
+
1475
+ {/* Right pane: message detail */}
1476
+ <box
1477
+ style={{
1478
+ border: true,
1479
+ borderColor: currentMessage?.role === "user" ? PALETTE.accent : PALETTE.primary,
1480
+ flexGrow: 6,
1481
+ flexDirection: "column",
1482
+ padding: 1,
1483
+ overflow: "hidden",
1484
+ }}
1485
+ title={currentMessage ? `${currentMessage.role} message` : "Details"}
1486
+ >
1487
+ {currentMessage ? (
1488
+ <box style={{ flexDirection: "column" }}>
1489
+ <box style={{ flexDirection: "row", marginBottom: 1 }}>
1490
+ <text fg={PALETTE.accent}>Role: </text>
1491
+ <text fg={currentMessage.role === "user" ? PALETTE.accent : PALETTE.primary}>
1492
+ {currentMessage.role}
1493
+ </text>
1494
+ <text fg={PALETTE.muted}> | </text>
1495
+ <text fg={PALETTE.accent}>Time: </text>
1496
+ <text>{formatDate(currentMessage.createdAt)}</text>
1497
+ </box>
1498
+ {currentMessage.tokens ? (
1499
+ <box style={{ flexDirection: "row", marginBottom: 1 }}>
1500
+ <text fg={PALETTE.info}>Tokens: </text>
1501
+ <text>
1502
+ In: {formatTokenCount(currentMessage.tokens.input)} |
1503
+ Out: {formatTokenCount(currentMessage.tokens.output)} |
1504
+ Total: {formatTokenCount(currentMessage.tokens.total)}
1505
+ </text>
1506
+ </box>
1507
+ ) : null}
1508
+ <box style={{ flexGrow: 1, overflow: "hidden" }}>
1509
+ {renderMessageContent()}
1510
+ </box>
1511
+ </box>
1512
+ ) : (
1513
+ <text fg={PALETTE.muted}>Select a message to view details</text>
1514
+ )}
1515
+ </box>
1516
+ </box>
1517
+ )}
1518
+
1519
+ {/* Footer */}
1520
+ <box style={{ marginTop: 1 }}>
1521
+ <text fg={PALETTE.muted}>
1522
+ Esc close | Up/Down navigate | PgUp/PgDn jump | Y copy message
1523
+ </text>
1524
+ </box>
1525
+ </box>
1526
+ )
1527
+ }
1528
+
1529
+ export const App = ({ root }: { root: string }) => {
1213
1530
  const renderer = useRenderer()
1214
1531
  const projectsRef = useRef<PanelHandle>(null)
1215
1532
  const sessionsRef = useRef<PanelHandle>(null)
@@ -1227,6 +1544,23 @@ const App = ({ root }: { root: string }) => {
1227
1544
  const [globalTokens, setGlobalTokens] = useState<AggregateTokenSummary | null>(null)
1228
1545
  const [tokenRefreshKey, setTokenRefreshKey] = useState(0)
1229
1546
 
1547
+ // Chat viewer state
1548
+ const [chatViewerOpen, setChatViewerOpen] = useState(false)
1549
+ const [chatSession, setChatSession] = useState<SessionRecord | null>(null)
1550
+ const [chatMessages, setChatMessages] = useState<ChatMessage[]>([])
1551
+ const [chatCursor, setChatCursor] = useState(0)
1552
+ const [chatLoading, setChatLoading] = useState(false)
1553
+ const [chatError, setChatError] = useState<string | null>(null)
1554
+ const [chatPartsCache, setChatPartsCache] = useState<Map<string, ChatMessage>>(new Map())
1555
+
1556
+ // Chat search overlay state
1557
+ const [chatSearchOpen, setChatSearchOpen] = useState(false)
1558
+ const [chatSearchQuery, setChatSearchQuery] = useState("")
1559
+ const [chatSearchResults, setChatSearchResults] = useState<ChatSearchResult[]>([])
1560
+ const [chatSearchCursor, setChatSearchCursor] = useState(0)
1561
+ const [chatSearching, setChatSearching] = useState(false)
1562
+ const [allSessions, setAllSessions] = useState<SessionRecord[]>([])
1563
+
1230
1564
  // Load global tokens
1231
1565
  useEffect(() => {
1232
1566
  let cancelled = false
@@ -1241,6 +1575,17 @@ const App = ({ root }: { root: string }) => {
1241
1575
  return () => { cancelled = true }
1242
1576
  }, [root, tokenRefreshKey])
1243
1577
 
1578
+ // Load all sessions for chat search
1579
+ useEffect(() => {
1580
+ let cancelled = false
1581
+ loadSessionRecords({ root }).then((sessions) => {
1582
+ if (!cancelled) {
1583
+ setAllSessions(sessions)
1584
+ }
1585
+ })
1586
+ return () => { cancelled = true }
1587
+ }, [root, tokenRefreshKey])
1588
+
1244
1589
  const notify = useCallback((message: string, level: NotificationLevel = "info") => {
1245
1590
  setStatus(message)
1246
1591
  setStatusLevel(level)
@@ -1281,6 +1626,147 @@ const App = ({ root }: { root: string }) => {
1281
1626
  })
1282
1627
  }, [])
1283
1628
 
1629
+ // Chat viewer controls
1630
+ const openChatViewer = useCallback(async (session: SessionRecord) => {
1631
+ setChatViewerOpen(true)
1632
+ setChatSession(session)
1633
+ setChatMessages([])
1634
+ setChatCursor(0)
1635
+ setChatLoading(true)
1636
+ setChatError(null)
1637
+ setChatPartsCache(new Map())
1638
+
1639
+ try {
1640
+ const messages = await loadSessionChatIndex(session.sessionId, root)
1641
+ setChatMessages(messages)
1642
+ if (messages.length > 0) {
1643
+ setChatCursor(0)
1644
+ }
1645
+ } catch (err) {
1646
+ const msg = err instanceof Error ? err.message : String(err)
1647
+ setChatError(msg)
1648
+ } finally {
1649
+ setChatLoading(false)
1650
+ }
1651
+ }, [root])
1652
+
1653
+ const closeChatViewer = useCallback(() => {
1654
+ setChatViewerOpen(false)
1655
+ setChatSession(null)
1656
+ setChatMessages([])
1657
+ setChatCursor(0)
1658
+ setChatLoading(false)
1659
+ setChatError(null)
1660
+ setChatPartsCache(new Map())
1661
+ }, [])
1662
+
1663
+ const hydrateMessage = useCallback(async (message: ChatMessage) => {
1664
+ // Check cache first
1665
+ const cached = chatPartsCache.get(message.messageId)
1666
+ if (cached) {
1667
+ setChatMessages(prev => prev.map(m =>
1668
+ m.messageId === message.messageId ? cached : m
1669
+ ))
1670
+ return
1671
+ }
1672
+
1673
+ try {
1674
+ const hydrated = await hydrateChatMessageParts(message, root)
1675
+ setChatPartsCache(prev => new Map(prev).set(message.messageId, hydrated))
1676
+ setChatMessages(prev => prev.map(m =>
1677
+ m.messageId === message.messageId ? hydrated : m
1678
+ ))
1679
+ } catch (err) {
1680
+ // On error, set a placeholder
1681
+ const errorMsg: ChatMessage = {
1682
+ ...message,
1683
+ parts: [],
1684
+ previewText: "[failed to load]",
1685
+ totalChars: 0,
1686
+ }
1687
+ setChatMessages(prev => prev.map(m =>
1688
+ m.messageId === message.messageId ? errorMsg : m
1689
+ ))
1690
+ }
1691
+ }, [root, chatPartsCache])
1692
+
1693
+ const copyChatMessage = useCallback((message: ChatMessage) => {
1694
+ if (!message.parts || message.parts.length === 0) {
1695
+ notify("No content to copy", "error")
1696
+ return
1697
+ }
1698
+ const text = message.parts.map(p => p.text).join('\n\n')
1699
+ copyToClipboardSync(text)
1700
+ notify(`Copied ${text.length} chars to clipboard`)
1701
+ }, [notify])
1702
+
1703
+ // Chat search controls
1704
+ const openChatSearch = useCallback(() => {
1705
+ setChatSearchOpen(true)
1706
+ setChatSearchQuery("")
1707
+ setChatSearchResults([])
1708
+ setChatSearchCursor(0)
1709
+ setChatSearching(false)
1710
+ }, [])
1711
+
1712
+ const closeChatSearch = useCallback(() => {
1713
+ setChatSearchOpen(false)
1714
+ setChatSearchQuery("")
1715
+ setChatSearchResults([])
1716
+ setChatSearchCursor(0)
1717
+ setChatSearching(false)
1718
+ }, [])
1719
+
1720
+ const executeChatSearch = useCallback(async () => {
1721
+ if (!chatSearchQuery.trim()) {
1722
+ setChatSearchResults([])
1723
+ return
1724
+ }
1725
+
1726
+ setChatSearching(true)
1727
+
1728
+ try {
1729
+ // Filter to project if filter is active
1730
+ const sessionsToSearch = sessionFilter
1731
+ ? allSessions.filter(s => s.projectId === sessionFilter)
1732
+ : allSessions
1733
+
1734
+ const results = await searchSessionsChat(sessionsToSearch, chatSearchQuery, root, { maxResults: 100 })
1735
+ setChatSearchResults(results)
1736
+ setChatSearchCursor(0)
1737
+ } catch (err) {
1738
+ const msg = err instanceof Error ? err.message : String(err)
1739
+ notify(`Search failed: ${msg}`, "error")
1740
+ setChatSearchResults([])
1741
+ } finally {
1742
+ setChatSearching(false)
1743
+ }
1744
+ }, [chatSearchQuery, sessionFilter, allSessions, root, notify])
1745
+
1746
+ const handleChatSearchResult = useCallback(async (result: ChatSearchResult) => {
1747
+ // Find the session and open chat viewer at the matching message
1748
+ const session = allSessions.find(s => s.sessionId === result.sessionId)
1749
+ if (!session) {
1750
+ notify("Session not found", "error")
1751
+ return
1752
+ }
1753
+
1754
+ closeChatSearch()
1755
+ await openChatViewer(session)
1756
+
1757
+ // Find the message index in the chat viewer
1758
+ // Wait a bit for the chat viewer to load
1759
+ setTimeout(() => {
1760
+ setChatMessages(prev => {
1761
+ const idx = prev.findIndex(m => m.messageId === result.messageId)
1762
+ if (idx !== -1) {
1763
+ setChatCursor(idx)
1764
+ }
1765
+ return prev
1766
+ })
1767
+ }, 100)
1768
+ }, [allSessions, closeChatSearch, openChatViewer, notify])
1769
+
1284
1770
  const handleGlobalKey = useCallback(
1285
1771
  (key: KeyEvent) => {
1286
1772
  // Search input mode takes precedence
@@ -1318,6 +1804,89 @@ const App = ({ root }: { root: string }) => {
1318
1804
  return
1319
1805
  }
1320
1806
 
1807
+ // Chat viewer takes precedence when open
1808
+ if (chatViewerOpen) {
1809
+ const letter = key.sequence?.toLowerCase()
1810
+ if (key.name === "escape") {
1811
+ closeChatViewer()
1812
+ return
1813
+ }
1814
+ if (key.name === "up") {
1815
+ setChatCursor(prev => Math.max(0, prev - 1))
1816
+ return
1817
+ }
1818
+ if (key.name === "down") {
1819
+ setChatCursor(prev => Math.min(chatMessages.length - 1, prev + 1))
1820
+ return
1821
+ }
1822
+ if (key.name === "pageup" || (key.ctrl && letter === "u")) {
1823
+ setChatCursor(prev => Math.max(0, prev - 10))
1824
+ return
1825
+ }
1826
+ if (key.name === "pagedown" || (key.ctrl && letter === "d")) {
1827
+ setChatCursor(prev => Math.min(chatMessages.length - 1, prev + 10))
1828
+ return
1829
+ }
1830
+ if (key.name === "home") {
1831
+ setChatCursor(0)
1832
+ return
1833
+ }
1834
+ if (key.name === "end") {
1835
+ setChatCursor(chatMessages.length - 1)
1836
+ return
1837
+ }
1838
+ if (letter === "y") {
1839
+ const msg = chatMessages[chatCursor]
1840
+ if (msg) {
1841
+ copyChatMessage(msg)
1842
+ }
1843
+ return
1844
+ }
1845
+ // Block other keys while viewer is open
1846
+ return
1847
+ }
1848
+
1849
+ // Chat search overlay takes precedence when open
1850
+ if (chatSearchOpen) {
1851
+ const letter = key.sequence?.toLowerCase()
1852
+ if (key.name === "escape") {
1853
+ closeChatSearch()
1854
+ return
1855
+ }
1856
+ if (key.name === "return" || key.name === "enter") {
1857
+ if (chatSearchResults.length > 0) {
1858
+ // Select current result
1859
+ const result = chatSearchResults[chatSearchCursor]
1860
+ if (result) {
1861
+ void handleChatSearchResult(result)
1862
+ }
1863
+ } else {
1864
+ // Execute search
1865
+ void executeChatSearch()
1866
+ }
1867
+ return
1868
+ }
1869
+ if (key.name === "backspace") {
1870
+ setChatSearchQuery(prev => prev.slice(0, -1))
1871
+ return
1872
+ }
1873
+ if (key.name === "up") {
1874
+ setChatSearchCursor(prev => Math.max(0, prev - 1))
1875
+ return
1876
+ }
1877
+ if (key.name === "down") {
1878
+ setChatSearchCursor(prev => Math.min(chatSearchResults.length - 1, prev + 1))
1879
+ return
1880
+ }
1881
+ // Type characters
1882
+ const ch = key.sequence
1883
+ if (ch && ch.length === 1 && !key.ctrl && !key.meta) {
1884
+ setChatSearchQuery(prev => prev + ch)
1885
+ return
1886
+ }
1887
+ return
1888
+ }
1889
+
1321
1890
  if (showHelp) {
1322
1891
  const letter = key.sequence?.toLowerCase()
1323
1892
  if (key.name === "escape" || key.name === "return" || key.name === "enter" || letter === "?" || letter === "h") {
@@ -1376,10 +1945,16 @@ const App = ({ root }: { root: string }) => {
1376
1945
  return
1377
1946
  }
1378
1947
 
1948
+ // Open chat search with F key (Sessions tab only)
1949
+ if (letter === "f" && activeTab === "sessions") {
1950
+ openChatSearch()
1951
+ return
1952
+ }
1953
+
1379
1954
  const handler = activeTab === "projects" ? projectsRef.current : sessionsRef.current
1380
1955
  handler?.handleKey(key)
1381
1956
  },
1382
- [activeTab, cancelConfirm, confirmState, executeConfirm, notify, renderer, searchActive, searchQuery, showHelp, switchTab],
1957
+ [activeTab, cancelConfirm, confirmState, executeConfirm, notify, renderer, searchActive, searchQuery, showHelp, switchTab, chatViewerOpen, chatMessages, chatCursor, closeChatViewer, copyChatMessage, chatSearchOpen, chatSearchResults, chatSearchCursor, closeChatSearch, executeChatSearch, handleChatSearchResult, openChatSearch],
1383
1958
  )
1384
1959
 
1385
1960
  useKeyboard(handleGlobalKey)
@@ -1447,84 +2022,143 @@ const App = ({ root }: { root: string }) => {
1447
2022
  ref={sessionsRef}
1448
2023
  root={root}
1449
2024
  active={activeTab === "sessions"}
1450
- locked={Boolean(confirmState) || showHelp}
2025
+ locked={Boolean(confirmState) || showHelp || chatViewerOpen || chatSearchOpen}
1451
2026
  projectFilter={sessionFilter}
1452
2027
  searchQuery={activeTab === "sessions" ? searchQuery : ""}
1453
2028
  globalTokenSummary={globalTokens}
1454
2029
  onNotify={notify}
1455
2030
  requestConfirm={requestConfirm}
1456
2031
  onClearFilter={clearSessionFilter}
2032
+ onOpenChatViewer={openChatViewer}
1457
2033
  />
1458
2034
  </box>
1459
2035
  )}
1460
2036
 
1461
- <StatusBar status={status} level={statusLevel} />
1462
- {confirmState ? <ConfirmBar state={confirmState} busy={confirmBusy} /> : null}
1463
- </box>
1464
- )
1465
- }
1466
-
1467
- function parseArgs(): { root: string } {
1468
- const args = process.argv.slice(2)
1469
- let root = DEFAULT_ROOT
2037
+ {/* Chat Viewer Overlay */}
2038
+ {chatViewerOpen && chatSession ? (
2039
+ <ChatViewer
2040
+ session={chatSession}
2041
+ messages={chatMessages}
2042
+ cursor={chatCursor}
2043
+ onCursorChange={setChatCursor}
2044
+ loading={chatLoading}
2045
+ error={chatError}
2046
+ onClose={closeChatViewer}
2047
+ onHydrateMessage={hydrateMessage}
2048
+ onCopyMessage={copyChatMessage}
2049
+ />
2050
+ ) : null}
1470
2051
 
1471
- for (let idx = 0; idx < args.length; idx += 1) {
1472
- const token = args[idx]
1473
- if (token === "--root" && args[idx + 1]) {
1474
- root = resolve(args[idx + 1])
1475
- idx += 1
1476
- continue
1477
- }
1478
- if (token === "--help" || token === "-h") {
1479
- printUsage()
1480
- process.exit(0)
1481
- }
1482
- }
2052
+ {/* Chat Search Overlay */}
2053
+ {chatSearchOpen ? (
2054
+ <box
2055
+ title={`Search Chat Content ${sessionFilter ? `(project: ${sessionFilter})` : "(all sessions)"}`}
2056
+ style={{
2057
+ position: 'absolute',
2058
+ top: 2,
2059
+ left: 2,
2060
+ right: 2,
2061
+ bottom: 2,
2062
+ border: true,
2063
+ borderColor: PALETTE.info,
2064
+ flexDirection: 'column',
2065
+ padding: 1,
2066
+ zIndex: 200,
2067
+ }}
2068
+ backgroundColor="#1a1a2e"
2069
+ >
2070
+ {/* Search input */}
2071
+ <box style={{ flexDirection: "row", marginBottom: 1 }}>
2072
+ <text fg={PALETTE.accent}>Search: </text>
2073
+ <text fg={PALETTE.key}>{chatSearchQuery}</text>
2074
+ <text fg={PALETTE.muted}>_</text>
2075
+ {chatSearching ? <text fg={PALETTE.info}> (searching...)</text> : null}
2076
+ </box>
2077
+
2078
+ <box style={{ marginBottom: 1 }}>
2079
+ <text fg={PALETTE.muted}>
2080
+ Searching {sessionFilter ? allSessions.filter(s => s.projectId === sessionFilter).length : allSessions.length} sessions | Found: {chatSearchResults.length} matches
2081
+ </text>
2082
+ </box>
2083
+
2084
+ {chatSearchResults.length === 0 && chatSearchQuery && !chatSearching ? (
2085
+ <text fg={PALETTE.muted}>No results found. Try a different search term.</text>
2086
+ ) : chatSearchResults.length > 0 ? (
2087
+ <box style={{ flexDirection: "row", gap: 1, flexGrow: 1 }}>
2088
+ {/* Results list */}
2089
+ <box
2090
+ style={{
2091
+ border: true,
2092
+ borderColor: PALETTE.muted,
2093
+ flexGrow: 4,
2094
+ flexDirection: "column",
2095
+ padding: 1,
2096
+ }}
2097
+ title="Results"
2098
+ >
2099
+ <select
2100
+ options={chatSearchResults.map((r, idx) => ({
2101
+ name: `${r.sessionTitle.slice(0, 25)} | ${r.role === "user" ? "[user]" : "[asst]"} ${r.matchedText.slice(0, 40)}...`,
2102
+ description: "",
2103
+ value: idx,
2104
+ }))}
2105
+ selectedIndex={chatSearchCursor}
2106
+ onChange={setChatSearchCursor}
2107
+ focused={true}
2108
+ showScrollIndicator
2109
+ wrapSelection={false}
2110
+ />
2111
+ </box>
1483
2112
 
1484
- return { root }
1485
- }
2113
+ {/* Preview pane */}
2114
+ <box
2115
+ style={{
2116
+ border: true,
2117
+ borderColor: chatSearchResults[chatSearchCursor]?.role === "user" ? PALETTE.accent : PALETTE.primary,
2118
+ flexGrow: 6,
2119
+ flexDirection: "column",
2120
+ padding: 1,
2121
+ overflow: "hidden",
2122
+ }}
2123
+ title={chatSearchResults[chatSearchCursor] ? `${chatSearchResults[chatSearchCursor].role} message` : "Preview"}
2124
+ >
2125
+ {chatSearchResults[chatSearchCursor] ? (
2126
+ <box style={{ flexDirection: "column" }}>
2127
+ <box style={{ flexDirection: "row", marginBottom: 1 }}>
2128
+ <text fg={PALETTE.accent}>Session: </text>
2129
+ <text>{chatSearchResults[chatSearchCursor].sessionTitle}</text>
2130
+ </box>
2131
+ <box style={{ flexDirection: "row", marginBottom: 1 }}>
2132
+ <text fg={PALETTE.accent}>Time: </text>
2133
+ <text>{formatDate(chatSearchResults[chatSearchCursor].createdAt)}</text>
2134
+ <text fg={PALETTE.muted}> | </text>
2135
+ <text fg={PALETTE.accent}>Type: </text>
2136
+ <text>{chatSearchResults[chatSearchCursor].partType}</text>
2137
+ </box>
2138
+ <box style={{ flexGrow: 1 }}>
2139
+ <text>{chatSearchResults[chatSearchCursor].fullText.slice(0, 1500)}{chatSearchResults[chatSearchCursor].fullText.length > 1500 ? "\n[... truncated]" : ""}</text>
2140
+ </box>
2141
+ </box>
2142
+ ) : (
2143
+ <text fg={PALETTE.muted}>Select a result to preview</text>
2144
+ )}
2145
+ </box>
2146
+ </box>
2147
+ ) : (
2148
+ <text fg={PALETTE.muted}>Type a search query and press Enter to search chat content.</text>
2149
+ )}
1486
2150
 
1487
- function printUsage(): void {
1488
- console.log(`OpenCode Metadata TUI
1489
- Usage: bun run tui [-- --root /path/to/storage]
1490
-
1491
- Key bindings:
1492
- Tab / 1 / 2 Switch between projects and sessions
1493
- / Start search (active tab)
1494
- X Clear search
1495
- ? / H Toggle help
1496
- R Reload (and refresh token cache)
1497
- Q Quit the application
1498
-
1499
- Projects view:
1500
- Space Toggle selection
1501
- A Select all (visible)
1502
- M Toggle missing-only filter
1503
- D Delete selected (with confirmation)
1504
- Enter Jump to Sessions for project
1505
- Esc Clear selection
1506
-
1507
- Sessions view:
1508
- Space Toggle selection
1509
- S Toggle sort (updated/created)
1510
- Shift+R Rename session
1511
- M Move selected sessions to project
1512
- P Copy selected sessions to project
1513
- Y Copy session ID to clipboard
1514
- C Clear project filter
1515
- D Delete selected (with confirmation)
1516
- Enter Show details
1517
- Esc Clear selection
1518
- `)
1519
- }
2151
+ {/* Footer */}
2152
+ <box style={{ marginTop: 1 }}>
2153
+ <text fg={PALETTE.muted}>
2154
+ Type query, Enter to search | Esc close | Up/Down navigate | Enter on result opens chat
2155
+ </text>
2156
+ </box>
2157
+ </box>
2158
+ ) : null}
1520
2159
 
1521
- async function bootstrap() {
1522
- const { root } = parseArgs()
1523
- const renderer = await createCliRenderer()
1524
- createRoot(renderer).render(<App root={root} />)
2160
+ <StatusBar status={status} level={statusLevel} />
2161
+ {confirmState ? <ConfirmBar state={confirmState} busy={confirmBusy} /> : null}
2162
+ </box>
2163
+ )
1525
2164
  }
1526
-
1527
- bootstrap().catch((error) => {
1528
- console.error(error)
1529
- process.exit(1)
1530
- })