opencode-manager 0.2.0 → 0.3.1

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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-manager",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "Terminal UI for inspecting OpenCode metadata stores.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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
+ }
@@ -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
- <text fg="#a5b4fc">OpenCode Metadata Manager</text>
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 Switch between projects and sessions
1296
- R Reload the active view
1297
- Q Quit the application
1298
- Projects view: Space select, A select all, M toggle missing filter, D delete, Enter jump to sessions
1299
- Sessions view: Space select, D delete, C clear project filter, Enter show details
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