opencode-manager 0.2.0 → 0.3.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/README.md +25 -0
- package/package.json +1 -1
- package/src/lib/opencode-data.ts +286 -0
- package/src/opencode-tui.tsx +225 -7
package/README.md
CHANGED
|
@@ -29,6 +29,31 @@ Terminal UI for inspecting, filtering, and pruning OpenCode metadata stored on d
|
|
|
29
29
|
- Copy sessions to other projects (`P`) with new session ID generation.
|
|
30
30
|
- Rich help overlay with live key hints (`?` or `H`).
|
|
31
31
|
- Zero-install via `bunx` so even CI shells can run it without cloning.
|
|
32
|
+
- **Token counting**: View token usage per session, per project, and globally.
|
|
33
|
+
|
|
34
|
+
## Token Counting
|
|
35
|
+
|
|
36
|
+
The TUI displays token telemetry from OpenCode's stored message data at three levels:
|
|
37
|
+
|
|
38
|
+
1. **Per-session**: Shows a breakdown in the session Details pane (Input, Output, Reasoning, Cache Read, Cache Write, Total).
|
|
39
|
+
2. **Per-project**: Shows total tokens for the highlighted project in the Projects panel.
|
|
40
|
+
3. **Global**: Shows total tokens across all sessions in the header bar.
|
|
41
|
+
|
|
42
|
+
### Token Definitions
|
|
43
|
+
| Field | Description |
|
|
44
|
+
|---|---|
|
|
45
|
+
| **Input** | Tokens in the prompt sent to the model |
|
|
46
|
+
| **Output** | Tokens generated by the model |
|
|
47
|
+
| **Reasoning** | Tokens used for chain-of-thought reasoning (some models) |
|
|
48
|
+
| **Cache Read** | Tokens read from provider cache |
|
|
49
|
+
| **Cache Write** | Tokens written to provider cache |
|
|
50
|
+
| **Total** | Sum of all token fields |
|
|
51
|
+
|
|
52
|
+
### Behavior Notes
|
|
53
|
+
- Token data is read from `storage/message/<sessionId>/*.json` files (assistant messages only).
|
|
54
|
+
- If token telemetry is missing or unreadable, the display shows `?` instead of `0`.
|
|
55
|
+
- Token summaries are cached in memory and refreshed when you press `R` to reload.
|
|
56
|
+
- Large datasets are handled with lazy computation to avoid UI freezes.
|
|
32
57
|
|
|
33
58
|
## Requirements
|
|
34
59
|
- [Bun](https://bun.sh) **1.1.0+** (developed/tested on 1.2.x).
|
package/package.json
CHANGED
package/src/lib/opencode-data.ts
CHANGED
|
@@ -4,6 +4,29 @@ import { homedir } from "node:os"
|
|
|
4
4
|
|
|
5
5
|
export type ProjectState = "present" | "missing" | "unknown"
|
|
6
6
|
|
|
7
|
+
// ========================
|
|
8
|
+
// Token Types
|
|
9
|
+
// ========================
|
|
10
|
+
|
|
11
|
+
export type TokenBreakdown = {
|
|
12
|
+
input: number
|
|
13
|
+
output: number
|
|
14
|
+
reasoning: number
|
|
15
|
+
cacheRead: number
|
|
16
|
+
cacheWrite: number
|
|
17
|
+
total: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type TokenSummary =
|
|
21
|
+
| { kind: "known"; tokens: TokenBreakdown }
|
|
22
|
+
| { kind: "unknown"; reason: "missing" | "parse_error" | "no_messages" }
|
|
23
|
+
|
|
24
|
+
export type AggregateTokenSummary = {
|
|
25
|
+
total: TokenSummary
|
|
26
|
+
knownOnly?: TokenBreakdown
|
|
27
|
+
unknownSessions?: number
|
|
28
|
+
}
|
|
29
|
+
|
|
7
30
|
export interface ProjectRecord {
|
|
8
31
|
index: number
|
|
9
32
|
bucket: ProjectBucket
|
|
@@ -186,11 +209,18 @@ export async function loadSessionRecords(options: SessionLoadOptions = {}): Prom
|
|
|
186
209
|
const projectDirs = await fs.readdir(sessionRoot, { withFileTypes: true })
|
|
187
210
|
const sessions: SessionRecord[] = []
|
|
188
211
|
|
|
212
|
+
// Some older OpenCode layouts may store message/part data under `storage/session/*`.
|
|
213
|
+
// Avoid treating those as project IDs when loading sessions.
|
|
214
|
+
const reservedSessionDirs = new Set(["message", "part"])
|
|
215
|
+
|
|
189
216
|
for (const dirent of projectDirs) {
|
|
190
217
|
if (!dirent.isDirectory()) {
|
|
191
218
|
continue
|
|
192
219
|
}
|
|
193
220
|
const currentProjectId = dirent.name
|
|
221
|
+
if (reservedSessionDirs.has(currentProjectId)) {
|
|
222
|
+
continue
|
|
223
|
+
}
|
|
194
224
|
if (options.projectId && options.projectId !== currentProjectId) {
|
|
195
225
|
continue
|
|
196
226
|
}
|
|
@@ -446,3 +476,259 @@ export async function moveSessions(
|
|
|
446
476
|
|
|
447
477
|
return { succeeded, failed }
|
|
448
478
|
}
|
|
479
|
+
|
|
480
|
+
// ========================
|
|
481
|
+
// Token Aggregation
|
|
482
|
+
// ========================
|
|
483
|
+
|
|
484
|
+
// Cache: key includes root+project+session+updatedAtMs to avoid collisions.
|
|
485
|
+
const tokenCache = new Map<string, TokenSummary>()
|
|
486
|
+
|
|
487
|
+
function getCacheKey(session: SessionRecord, root: string): string {
|
|
488
|
+
const updatedMs = session.updatedAt?.getTime() ?? session.createdAt?.getTime() ?? 0
|
|
489
|
+
return JSON.stringify([root, session.projectId, session.sessionId, updatedMs])
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
export function clearTokenCache(): void {
|
|
493
|
+
tokenCache.clear()
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function emptyBreakdown(): TokenBreakdown {
|
|
497
|
+
return {
|
|
498
|
+
input: 0,
|
|
499
|
+
output: 0,
|
|
500
|
+
reasoning: 0,
|
|
501
|
+
cacheRead: 0,
|
|
502
|
+
cacheWrite: 0,
|
|
503
|
+
total: 0,
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function addBreakdown(a: TokenBreakdown, b: TokenBreakdown): TokenBreakdown {
|
|
508
|
+
return {
|
|
509
|
+
input: a.input + b.input,
|
|
510
|
+
output: a.output + b.output,
|
|
511
|
+
reasoning: a.reasoning + b.reasoning,
|
|
512
|
+
cacheRead: a.cacheRead + b.cacheRead,
|
|
513
|
+
cacheWrite: a.cacheWrite + b.cacheWrite,
|
|
514
|
+
total: a.total + b.total,
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
interface MessageTokens {
|
|
519
|
+
input?: number
|
|
520
|
+
output?: number
|
|
521
|
+
reasoning?: number
|
|
522
|
+
cache?: {
|
|
523
|
+
read?: number
|
|
524
|
+
write?: number
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
interface MessagePayload {
|
|
529
|
+
role?: string
|
|
530
|
+
tokens?: MessageTokens | null
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function asTokenNumber(value: unknown): number | null {
|
|
534
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
535
|
+
return null
|
|
536
|
+
}
|
|
537
|
+
if (value < 0) {
|
|
538
|
+
return null
|
|
539
|
+
}
|
|
540
|
+
return value
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function parseMessageTokens(tokens: MessageTokens | null | undefined): TokenBreakdown | null {
|
|
544
|
+
if (!tokens || typeof tokens !== "object") {
|
|
545
|
+
return null
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
const input = asTokenNumber(tokens.input)
|
|
549
|
+
const output = asTokenNumber(tokens.output)
|
|
550
|
+
const reasoning = asTokenNumber(tokens.reasoning)
|
|
551
|
+
const cacheRead = asTokenNumber(tokens.cache?.read)
|
|
552
|
+
const cacheWrite = asTokenNumber(tokens.cache?.write)
|
|
553
|
+
|
|
554
|
+
const hasAny = input !== null || output !== null || reasoning !== null || cacheRead !== null || cacheWrite !== null
|
|
555
|
+
if (!hasAny) {
|
|
556
|
+
return null
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const breakdown = emptyBreakdown()
|
|
560
|
+
breakdown.input = input ?? 0
|
|
561
|
+
breakdown.output = output ?? 0
|
|
562
|
+
breakdown.reasoning = reasoning ?? 0
|
|
563
|
+
breakdown.cacheRead = cacheRead ?? 0
|
|
564
|
+
breakdown.cacheWrite = cacheWrite ?? 0
|
|
565
|
+
breakdown.total = breakdown.input + breakdown.output + breakdown.reasoning + breakdown.cacheRead + breakdown.cacheWrite
|
|
566
|
+
return breakdown
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
async function loadSessionMessagePaths(sessionId: string, root: string): Promise<string[] | null> {
|
|
570
|
+
// Primary path: storage/message/<sessionId>
|
|
571
|
+
const primaryPath = join(root, 'storage', 'message', sessionId)
|
|
572
|
+
if (await pathExists(primaryPath)) {
|
|
573
|
+
try {
|
|
574
|
+
const entries = await fs.readdir(primaryPath)
|
|
575
|
+
return entries
|
|
576
|
+
.filter((e) => e.endsWith('.json'))
|
|
577
|
+
.map((e) => join(primaryPath, e))
|
|
578
|
+
} catch {
|
|
579
|
+
return null
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// Legacy fallback: storage/session/message/<sessionId>
|
|
584
|
+
const legacyPath = join(root, 'storage', 'session', 'message', sessionId)
|
|
585
|
+
if (await pathExists(legacyPath)) {
|
|
586
|
+
try {
|
|
587
|
+
const entries = await fs.readdir(legacyPath)
|
|
588
|
+
return entries
|
|
589
|
+
.filter((e) => e.endsWith('.json'))
|
|
590
|
+
.map((e) => join(legacyPath, e))
|
|
591
|
+
} catch {
|
|
592
|
+
return null
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
return null
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
export async function computeSessionTokenSummary(
|
|
600
|
+
session: SessionRecord,
|
|
601
|
+
root: string = DEFAULT_ROOT
|
|
602
|
+
): Promise<TokenSummary> {
|
|
603
|
+
const normalizedRoot = resolve(root)
|
|
604
|
+
const cacheKey = getCacheKey(session, normalizedRoot)
|
|
605
|
+
const cached = tokenCache.get(cacheKey)
|
|
606
|
+
if (cached) {
|
|
607
|
+
return cached
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
const messagePaths = await loadSessionMessagePaths(session.sessionId, normalizedRoot)
|
|
611
|
+
if (messagePaths === null) {
|
|
612
|
+
const result: TokenSummary = { kind: 'unknown', reason: 'missing' }
|
|
613
|
+
tokenCache.set(cacheKey, result)
|
|
614
|
+
return result
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
if (messagePaths.length === 0) {
|
|
618
|
+
const result: TokenSummary = { kind: 'unknown', reason: 'no_messages' }
|
|
619
|
+
tokenCache.set(cacheKey, result)
|
|
620
|
+
return result
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
const breakdown = emptyBreakdown()
|
|
624
|
+
let foundAnyAssistant = false
|
|
625
|
+
|
|
626
|
+
for (const msgPath of messagePaths) {
|
|
627
|
+
const payload = await readJsonFile<MessagePayload>(msgPath)
|
|
628
|
+
if (!payload) {
|
|
629
|
+
const result: TokenSummary = { kind: "unknown", reason: "parse_error" }
|
|
630
|
+
tokenCache.set(cacheKey, result)
|
|
631
|
+
return result
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// Only sum assistant messages (they have token telemetry)
|
|
635
|
+
if (payload.role !== "assistant") {
|
|
636
|
+
continue
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
foundAnyAssistant = true
|
|
640
|
+
|
|
641
|
+
const msgTokens = parseMessageTokens(payload.tokens)
|
|
642
|
+
if (!msgTokens) {
|
|
643
|
+
const result: TokenSummary = { kind: "unknown", reason: "missing" }
|
|
644
|
+
tokenCache.set(cacheKey, result)
|
|
645
|
+
return result
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
breakdown.input += msgTokens.input
|
|
649
|
+
breakdown.output += msgTokens.output
|
|
650
|
+
breakdown.reasoning += msgTokens.reasoning
|
|
651
|
+
breakdown.cacheRead += msgTokens.cacheRead
|
|
652
|
+
breakdown.cacheWrite += msgTokens.cacheWrite
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
if (!foundAnyAssistant) {
|
|
656
|
+
const result: TokenSummary = { kind: "unknown", reason: "no_messages" }
|
|
657
|
+
tokenCache.set(cacheKey, result)
|
|
658
|
+
return result
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// Compute total
|
|
662
|
+
breakdown.total = breakdown.input + breakdown.output + breakdown.reasoning + breakdown.cacheRead + breakdown.cacheWrite
|
|
663
|
+
|
|
664
|
+
const result: TokenSummary = { kind: "known", tokens: breakdown }
|
|
665
|
+
tokenCache.set(cacheKey, result)
|
|
666
|
+
return result
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
export async function computeProjectTokenSummary(
|
|
670
|
+
projectId: string,
|
|
671
|
+
sessions: SessionRecord[],
|
|
672
|
+
root: string = DEFAULT_ROOT
|
|
673
|
+
): Promise<AggregateTokenSummary> {
|
|
674
|
+
const projectSessions = sessions.filter((s) => s.projectId === projectId)
|
|
675
|
+
return computeAggregateTokenSummary(projectSessions, root)
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
export async function computeGlobalTokenSummary(
|
|
679
|
+
sessions: SessionRecord[],
|
|
680
|
+
root: string = DEFAULT_ROOT
|
|
681
|
+
): Promise<AggregateTokenSummary> {
|
|
682
|
+
return computeAggregateTokenSummary(sessions, root)
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
async function computeAggregateTokenSummary(
|
|
686
|
+
sessions: SessionRecord[],
|
|
687
|
+
root: string
|
|
688
|
+
): Promise<AggregateTokenSummary> {
|
|
689
|
+
if (sessions.length === 0) {
|
|
690
|
+
return {
|
|
691
|
+
total: { kind: 'unknown', reason: 'no_messages' },
|
|
692
|
+
knownOnly: emptyBreakdown(),
|
|
693
|
+
unknownSessions: 0,
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
const knownOnly = emptyBreakdown()
|
|
698
|
+
let unknownSessions = 0
|
|
699
|
+
|
|
700
|
+
const normalizedRoot = resolve(root)
|
|
701
|
+
|
|
702
|
+
for (const session of sessions) {
|
|
703
|
+
const summary = await computeSessionTokenSummary(session, normalizedRoot)
|
|
704
|
+
if (summary.kind === "known") {
|
|
705
|
+
knownOnly.input += summary.tokens.input
|
|
706
|
+
knownOnly.output += summary.tokens.output
|
|
707
|
+
knownOnly.reasoning += summary.tokens.reasoning
|
|
708
|
+
knownOnly.cacheRead += summary.tokens.cacheRead
|
|
709
|
+
knownOnly.cacheWrite += summary.tokens.cacheWrite
|
|
710
|
+
knownOnly.total += summary.tokens.total
|
|
711
|
+
} else {
|
|
712
|
+
unknownSessions += 1
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// Recompute knownOnly.total (defensive)
|
|
717
|
+
knownOnly.total = knownOnly.input + knownOnly.output + knownOnly.reasoning + knownOnly.cacheRead + knownOnly.cacheWrite
|
|
718
|
+
|
|
719
|
+
// If all sessions are unknown, total is unknown
|
|
720
|
+
if (unknownSessions === sessions.length) {
|
|
721
|
+
return {
|
|
722
|
+
total: { kind: 'unknown', reason: 'missing' },
|
|
723
|
+
knownOnly: emptyBreakdown(),
|
|
724
|
+
unknownSessions,
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// Otherwise, total is the known aggregate (even if some sessions are unknown)
|
|
729
|
+
return {
|
|
730
|
+
total: { kind: 'known', tokens: { ...knownOnly } },
|
|
731
|
+
knownOnly,
|
|
732
|
+
unknownSessions,
|
|
733
|
+
}
|
|
734
|
+
}
|
package/src/opencode-tui.tsx
CHANGED
|
@@ -30,6 +30,13 @@ import {
|
|
|
30
30
|
copySessions,
|
|
31
31
|
moveSessions,
|
|
32
32
|
BatchOperationResult,
|
|
33
|
+
TokenSummary,
|
|
34
|
+
TokenBreakdown,
|
|
35
|
+
AggregateTokenSummary,
|
|
36
|
+
computeSessionTokenSummary,
|
|
37
|
+
computeProjectTokenSummary,
|
|
38
|
+
computeGlobalTokenSummary,
|
|
39
|
+
clearTokenCache,
|
|
33
40
|
} from "./lib/opencode-data"
|
|
34
41
|
|
|
35
42
|
type TabKey = "projects" | "sessions"
|
|
@@ -64,6 +71,7 @@ type SessionsPanelProps = {
|
|
|
64
71
|
locked: boolean
|
|
65
72
|
projectFilter: string | null
|
|
66
73
|
searchQuery: string
|
|
74
|
+
globalTokenSummary: AggregateTokenSummary | null
|
|
67
75
|
onNotify: (message: string, level?: NotificationLevel) => void
|
|
68
76
|
requestConfirm: (state: ConfirmState) => void
|
|
69
77
|
onClearFilter: () => void
|
|
@@ -82,6 +90,46 @@ const PALETTE = {
|
|
|
82
90
|
muted: "#9ca3af", // gray
|
|
83
91
|
} as const
|
|
84
92
|
|
|
93
|
+
// Token formatting helpers
|
|
94
|
+
function formatTokenCount(n: number): string {
|
|
95
|
+
if (n >= 1_000_000) {
|
|
96
|
+
return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`
|
|
97
|
+
}
|
|
98
|
+
if (n >= 1_000) {
|
|
99
|
+
return `${(n / 1_000).toFixed(1).replace(/\.0$/, "")}k`
|
|
100
|
+
}
|
|
101
|
+
return String(n)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function formatTokenBreakdown(tokens: TokenBreakdown): string[] {
|
|
105
|
+
return [
|
|
106
|
+
`Input: ${formatTokenCount(tokens.input)}`,
|
|
107
|
+
`Output: ${formatTokenCount(tokens.output)}`,
|
|
108
|
+
`Reasoning: ${formatTokenCount(tokens.reasoning)}`,
|
|
109
|
+
`Cache Read: ${formatTokenCount(tokens.cacheRead)}`,
|
|
110
|
+
`Cache Write: ${formatTokenCount(tokens.cacheWrite)}`,
|
|
111
|
+
`Total: ${formatTokenCount(tokens.total)}`,
|
|
112
|
+
]
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function formatTokenSummaryShort(summary: TokenSummary): string {
|
|
116
|
+
if (summary.kind === 'unknown') {
|
|
117
|
+
return '?'
|
|
118
|
+
}
|
|
119
|
+
return formatTokenCount(summary.tokens.total)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function formatAggregateSummaryShort(summary: AggregateTokenSummary): string {
|
|
123
|
+
if (summary.total.kind === 'unknown') {
|
|
124
|
+
return '?'
|
|
125
|
+
}
|
|
126
|
+
const base = formatTokenCount(summary.total.tokens.total)
|
|
127
|
+
if (summary.unknownSessions && summary.unknownSessions > 0) {
|
|
128
|
+
return `${base} (+${summary.unknownSessions} unknown)`
|
|
129
|
+
}
|
|
130
|
+
return base
|
|
131
|
+
}
|
|
132
|
+
|
|
85
133
|
function copyToClipboard(text: string): void {
|
|
86
134
|
const cmd = process.platform === "darwin" ? "pbcopy" : "xclip -selection clipboard"
|
|
87
135
|
const proc = exec(cmd, (error) => {
|
|
@@ -210,6 +258,9 @@ const ProjectsPanel = forwardRef<PanelHandle, ProjectsPanelProps>(function Proje
|
|
|
210
258
|
const [missingOnly, setMissingOnly] = useState(false)
|
|
211
259
|
const [cursor, setCursor] = useState(0)
|
|
212
260
|
const [selectedIndexes, setSelectedIndexes] = useState<Set<number>>(new Set())
|
|
261
|
+
// Token state for projects
|
|
262
|
+
const [allSessions, setAllSessions] = useState<SessionRecord[]>([])
|
|
263
|
+
const [currentProjectTokens, setCurrentProjectTokens] = useState<AggregateTokenSummary | null>(null)
|
|
213
264
|
|
|
214
265
|
const missingCount = useMemo(() => records.filter((record) => record.state === "missing").length, [records])
|
|
215
266
|
|
|
@@ -280,6 +331,34 @@ const ProjectsPanel = forwardRef<PanelHandle, ProjectsPanelProps>(function Proje
|
|
|
280
331
|
})
|
|
281
332
|
}, [visibleRecords.length])
|
|
282
333
|
|
|
334
|
+
// Load all sessions once for token computation
|
|
335
|
+
useEffect(() => {
|
|
336
|
+
let cancelled = false
|
|
337
|
+
loadSessionRecords({ root }).then((sessions) => {
|
|
338
|
+
if (!cancelled) {
|
|
339
|
+
setAllSessions(sessions)
|
|
340
|
+
}
|
|
341
|
+
})
|
|
342
|
+
return () => { cancelled = true }
|
|
343
|
+
}, [root, records]) // Re-fetch when projects change (implies sessions may have changed)
|
|
344
|
+
|
|
345
|
+
// Compute token summary for current project
|
|
346
|
+
useEffect(() => {
|
|
347
|
+
setCurrentProjectTokens(null)
|
|
348
|
+
if (!currentRecord || allSessions.length === 0) {
|
|
349
|
+
return
|
|
350
|
+
}
|
|
351
|
+
let cancelled = false
|
|
352
|
+
computeProjectTokenSummary(currentRecord.projectId, allSessions, root).then((summary) => {
|
|
353
|
+
if (!cancelled) {
|
|
354
|
+
setCurrentProjectTokens(summary)
|
|
355
|
+
}
|
|
356
|
+
})
|
|
357
|
+
return () => {
|
|
358
|
+
cancelled = true
|
|
359
|
+
}
|
|
360
|
+
}, [currentRecord, allSessions, root])
|
|
361
|
+
|
|
283
362
|
const toggleSelection = useCallback((record: ProjectRecord | undefined) => {
|
|
284
363
|
if (!record) {
|
|
285
364
|
return
|
|
@@ -447,6 +526,19 @@ const ProjectsPanel = forwardRef<PanelHandle, ProjectsPanelProps>(function Proje
|
|
|
447
526
|
<text>Created: {formatDate(currentRecord.createdAt)}</text>
|
|
448
527
|
<text>Path:</text>
|
|
449
528
|
<text>{formatDisplayPath(currentRecord.worktree, { fullPath: true })}</text>
|
|
529
|
+
<box style={{ marginTop: 1 }}>
|
|
530
|
+
<text fg={PALETTE.accent}>Tokens: </text>
|
|
531
|
+
{currentProjectTokens?.total.kind === 'known' ? (
|
|
532
|
+
<>
|
|
533
|
+
<text fg={PALETTE.success}>Total: {formatTokenCount(currentProjectTokens.total.tokens.total)}</text>
|
|
534
|
+
{currentProjectTokens.unknownSessions && currentProjectTokens.unknownSessions > 0 ? (
|
|
535
|
+
<text fg={PALETTE.muted}> (+{currentProjectTokens.unknownSessions} unknown sessions)</text>
|
|
536
|
+
) : null}
|
|
537
|
+
</>
|
|
538
|
+
) : (
|
|
539
|
+
<text fg={PALETTE.muted}>{currentProjectTokens ? '?' : 'loading...'}</text>
|
|
540
|
+
)}
|
|
541
|
+
</box>
|
|
450
542
|
</box>
|
|
451
543
|
) : null}
|
|
452
544
|
</box>
|
|
@@ -456,7 +548,7 @@ const ProjectsPanel = forwardRef<PanelHandle, ProjectsPanelProps>(function Proje
|
|
|
456
548
|
})
|
|
457
549
|
|
|
458
550
|
const SessionsPanel = forwardRef<PanelHandle, SessionsPanelProps>(function SessionsPanel(
|
|
459
|
-
{ root, active, locked, projectFilter, searchQuery, onNotify, requestConfirm, onClearFilter },
|
|
551
|
+
{ root, active, locked, projectFilter, searchQuery, globalTokenSummary, onNotify, requestConfirm, onClearFilter },
|
|
460
552
|
ref,
|
|
461
553
|
) {
|
|
462
554
|
const [records, setRecords] = useState<SessionRecord[]>([])
|
|
@@ -471,6 +563,9 @@ const SessionsPanel = forwardRef<PanelHandle, SessionsPanelProps>(function Sessi
|
|
|
471
563
|
const [operationMode, setOperationMode] = useState<'move' | 'copy' | null>(null)
|
|
472
564
|
const [availableProjects, setAvailableProjects] = useState<ProjectRecord[]>([])
|
|
473
565
|
const [projectCursor, setProjectCursor] = useState(0)
|
|
566
|
+
// Token state
|
|
567
|
+
const [currentTokenSummary, setCurrentTokenSummary] = useState<TokenSummary | null>(null)
|
|
568
|
+
const [filteredTokenSummary, setFilteredTokenSummary] = useState<AggregateTokenSummary | null>(null)
|
|
474
569
|
|
|
475
570
|
const visibleRecords = useMemo(() => {
|
|
476
571
|
const sorted = [...records].sort((a, b) => {
|
|
@@ -548,6 +643,46 @@ const SessionsPanel = forwardRef<PanelHandle, SessionsPanelProps>(function Sessi
|
|
|
548
643
|
})
|
|
549
644
|
}, [visibleRecords.length])
|
|
550
645
|
|
|
646
|
+
// Compute token summary for current session
|
|
647
|
+
useEffect(() => {
|
|
648
|
+
setCurrentTokenSummary(null)
|
|
649
|
+
if (!currentSession) {
|
|
650
|
+
return
|
|
651
|
+
}
|
|
652
|
+
let cancelled = false
|
|
653
|
+
computeSessionTokenSummary(currentSession, root).then((summary) => {
|
|
654
|
+
if (!cancelled) {
|
|
655
|
+
setCurrentTokenSummary(summary)
|
|
656
|
+
}
|
|
657
|
+
})
|
|
658
|
+
return () => {
|
|
659
|
+
cancelled = true
|
|
660
|
+
}
|
|
661
|
+
}, [currentSession, root])
|
|
662
|
+
|
|
663
|
+
// Compute filtered token summary (deferred to avoid UI freeze)
|
|
664
|
+
useEffect(() => {
|
|
665
|
+
setFilteredTokenSummary(null)
|
|
666
|
+
if (records.length === 0) {
|
|
667
|
+
return
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
let cancelled = false
|
|
671
|
+
|
|
672
|
+
// Compute filtered (project-only) if filter is active.
|
|
673
|
+
if (projectFilter) {
|
|
674
|
+
computeProjectTokenSummary(projectFilter, records, root).then((summary) => {
|
|
675
|
+
if (!cancelled) {
|
|
676
|
+
setFilteredTokenSummary(summary)
|
|
677
|
+
}
|
|
678
|
+
})
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
return () => {
|
|
682
|
+
cancelled = true
|
|
683
|
+
}
|
|
684
|
+
}, [records, projectFilter, root])
|
|
685
|
+
|
|
551
686
|
const toggleSelection = useCallback((session: SessionRecord | undefined) => {
|
|
552
687
|
if (!session) {
|
|
553
688
|
return
|
|
@@ -875,6 +1010,33 @@ const SessionsPanel = forwardRef<PanelHandle, SessionsPanelProps>(function Sessi
|
|
|
875
1010
|
<text>Updated: {formatDate(currentSession.updatedAt || currentSession.createdAt)}</text>
|
|
876
1011
|
<text>Directory:</text>
|
|
877
1012
|
<text>{formatDisplayPath(currentSession.directory, { fullPath: true })}</text>
|
|
1013
|
+
<box style={{ marginTop: 1 }}>
|
|
1014
|
+
<text fg={PALETTE.accent}>Tokens: </text>
|
|
1015
|
+
{currentTokenSummary?.kind === 'known' ? (
|
|
1016
|
+
<>
|
|
1017
|
+
<text>In: {formatTokenCount(currentTokenSummary.tokens.input)} </text>
|
|
1018
|
+
<text>Out: {formatTokenCount(currentTokenSummary.tokens.output)} </text>
|
|
1019
|
+
<text>Reason: {formatTokenCount(currentTokenSummary.tokens.reasoning)} </text>
|
|
1020
|
+
<text>Cache R: {formatTokenCount(currentTokenSummary.tokens.cacheRead)} </text>
|
|
1021
|
+
<text>Cache W: {formatTokenCount(currentTokenSummary.tokens.cacheWrite)} </text>
|
|
1022
|
+
<text fg={PALETTE.success}>Total: {formatTokenCount(currentTokenSummary.tokens.total)}</text>
|
|
1023
|
+
</>
|
|
1024
|
+
) : (
|
|
1025
|
+
<text fg={PALETTE.muted}>{currentTokenSummary ? '?' : 'loading...'}</text>
|
|
1026
|
+
)}
|
|
1027
|
+
</box>
|
|
1028
|
+
{projectFilter && filteredTokenSummary ? (
|
|
1029
|
+
<box style={{ marginTop: 1 }}>
|
|
1030
|
+
<text fg={PALETTE.info}>Filtered ({projectFilter}): </text>
|
|
1031
|
+
<text>{formatAggregateSummaryShort(filteredTokenSummary)}</text>
|
|
1032
|
+
</box>
|
|
1033
|
+
) : null}
|
|
1034
|
+
{globalTokenSummary ? (
|
|
1035
|
+
<box>
|
|
1036
|
+
<text fg={PALETTE.primary}>Global: </text>
|
|
1037
|
+
<text>{formatAggregateSummaryShort(globalTokenSummary)}</text>
|
|
1038
|
+
</box>
|
|
1039
|
+
) : null}
|
|
878
1040
|
<text fg={PALETTE.muted} style={{ marginTop: 1 }}>Press Y to copy ID</text>
|
|
879
1041
|
</box>
|
|
880
1042
|
) : null}
|
|
@@ -1061,6 +1223,23 @@ const App = ({ root }: { root: string }) => {
|
|
|
1061
1223
|
const [confirmState, setConfirmState] = useState<ConfirmState | null>(null)
|
|
1062
1224
|
const [showHelp, setShowHelp] = useState(true)
|
|
1063
1225
|
const [confirmBusy, setConfirmBusy] = useState(false)
|
|
1226
|
+
// Global token state
|
|
1227
|
+
const [globalTokens, setGlobalTokens] = useState<AggregateTokenSummary | null>(null)
|
|
1228
|
+
const [tokenRefreshKey, setTokenRefreshKey] = useState(0)
|
|
1229
|
+
|
|
1230
|
+
// Load global tokens
|
|
1231
|
+
useEffect(() => {
|
|
1232
|
+
let cancelled = false
|
|
1233
|
+
loadSessionRecords({ root }).then((sessions) => {
|
|
1234
|
+
if (cancelled) return
|
|
1235
|
+
return computeGlobalTokenSummary(sessions, root)
|
|
1236
|
+
}).then((summary) => {
|
|
1237
|
+
if (!cancelled && summary) {
|
|
1238
|
+
setGlobalTokens(summary)
|
|
1239
|
+
}
|
|
1240
|
+
})
|
|
1241
|
+
return () => { cancelled = true }
|
|
1242
|
+
}, [root, tokenRefreshKey])
|
|
1064
1243
|
|
|
1065
1244
|
const notify = useCallback((message: string, level: NotificationLevel = "info") => {
|
|
1066
1245
|
setStatus(message)
|
|
@@ -1185,6 +1364,9 @@ const App = ({ root }: { root: string }) => {
|
|
|
1185
1364
|
}
|
|
1186
1365
|
|
|
1187
1366
|
if (letter === "r") {
|
|
1367
|
+
// Clear token cache on reload
|
|
1368
|
+
clearTokenCache()
|
|
1369
|
+
setTokenRefreshKey((k) => k + 1)
|
|
1188
1370
|
if (activeTab === "projects") {
|
|
1189
1371
|
projectsRef.current?.refresh()
|
|
1190
1372
|
} else {
|
|
@@ -1219,7 +1401,21 @@ const App = ({ root }: { root: string }) => {
|
|
|
1219
1401
|
return (
|
|
1220
1402
|
<box style={{ flexDirection: "column", padding: 1, flexGrow: 1 }}>
|
|
1221
1403
|
<box flexDirection="column" marginBottom={1}>
|
|
1222
|
-
<
|
|
1404
|
+
<box style={{ flexDirection: "row", gap: 2 }}>
|
|
1405
|
+
<text fg="#a5b4fc">OpenCode Metadata Manager</text>
|
|
1406
|
+
<text fg={PALETTE.muted}>|</text>
|
|
1407
|
+
<text fg={PALETTE.accent}>Global Tokens: </text>
|
|
1408
|
+
{globalTokens?.total.kind === 'known' ? (
|
|
1409
|
+
<>
|
|
1410
|
+
<text fg={PALETTE.success}>{formatTokenCount(globalTokens.total.tokens.total)}</text>
|
|
1411
|
+
{globalTokens.unknownSessions && globalTokens.unknownSessions > 0 ? (
|
|
1412
|
+
<text fg={PALETTE.muted}> (+{globalTokens.unknownSessions} unknown)</text>
|
|
1413
|
+
) : null}
|
|
1414
|
+
</>
|
|
1415
|
+
) : (
|
|
1416
|
+
<text fg={PALETTE.muted}>{globalTokens ? '?' : 'loading...'}</text>
|
|
1417
|
+
)}
|
|
1418
|
+
</box>
|
|
1223
1419
|
<text>Root: {root}</text>
|
|
1224
1420
|
<text>
|
|
1225
1421
|
Tabs: [1] Projects [2] Sessions | Active: {activeTab} | Global: Tab switch, / search, X clear, R reload, Q quit, ? help
|
|
@@ -1254,6 +1450,7 @@ const App = ({ root }: { root: string }) => {
|
|
|
1254
1450
|
locked={Boolean(confirmState) || showHelp}
|
|
1255
1451
|
projectFilter={sessionFilter}
|
|
1256
1452
|
searchQuery={activeTab === "sessions" ? searchQuery : ""}
|
|
1453
|
+
globalTokenSummary={globalTokens}
|
|
1257
1454
|
onNotify={notify}
|
|
1258
1455
|
requestConfirm={requestConfirm}
|
|
1259
1456
|
onClearFilter={clearSessionFilter}
|
|
@@ -1292,11 +1489,32 @@ function printUsage(): void {
|
|
|
1292
1489
|
Usage: bun run tui [-- --root /path/to/storage]
|
|
1293
1490
|
|
|
1294
1491
|
Key bindings:
|
|
1295
|
-
Tab / 1 / 2
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
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
|
|
1300
1518
|
`)
|
|
1301
1519
|
}
|
|
1302
1520
|
|