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.
- package/PROJECT-SUMMARY.md +104 -24
- package/README.md +335 -7
- package/bun.lock +17 -1
- package/manage_opencode_projects.py +71 -66
- package/package.json +6 -3
- package/src/bin/opencode-manager.ts +133 -3
- package/src/cli/backup.ts +324 -0
- package/src/cli/commands/chat.ts +322 -0
- package/src/cli/commands/projects.ts +222 -0
- package/src/cli/commands/sessions.ts +495 -0
- package/src/cli/commands/tokens.ts +168 -0
- package/src/cli/commands/tui.ts +36 -0
- package/src/cli/errors.ts +259 -0
- package/src/cli/formatters/json.ts +184 -0
- package/src/cli/formatters/ndjson.ts +71 -0
- package/src/cli/formatters/table.ts +837 -0
- package/src/cli/index.ts +169 -0
- package/src/cli/output.ts +661 -0
- package/src/cli/resolvers.ts +249 -0
- package/src/lib/clipboard.ts +37 -0
- package/src/lib/opencode-data.ts +380 -1
- package/src/lib/search.ts +170 -0
- package/src/{opencode-tui.tsx → tui/app.tsx} +739 -105
- package/src/tui/args.ts +92 -0
- package/src/tui/index.tsx +46 -0
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import type { KeyEvent, SelectOption } from "@opentui/core"
|
|
2
|
-
import {
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
134
|
-
|
|
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
|
-
|
|
438
|
-
|
|
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
|
-
|
|
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()
|
|
598
|
+
const q = searchQuery.trim()
|
|
580
599
|
if (!q) return sorted
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1462
|
-
{
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
}
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
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
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
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
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
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
|
-
})
|