opencode-manager 0.1.2 → 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.
@@ -31,9 +31,12 @@ Key Features
31
31
  - Missing-only toggle and bulk selection.
32
32
  - Sessions panel
33
33
  - Shows session title (or falls back to ID) in the list.
34
- - Sort toggle (S) between updated and created”.
34
+ - Sort toggle (S) between "updated" and "created".
35
35
  - Each row shows a timestamp snippet for the active sort (created/updated).
36
36
  - Details pane includes title, project ID, updated time, and directory.
37
+ - Rename sessions inline (Shift+R) with validation.
38
+ - Move sessions to another project (M) with project selector.
39
+ - Copy sessions to another project (P) with new session ID generation.
37
40
  - Projects ↔ Sessions workflow
38
41
  - Pressing Enter on a project jumps to the Sessions tab with the project filter set; status text confirms the active filter.
39
42
  - `C` clears the filter (and notifies the user) so global searches go back to all sessions.
@@ -55,13 +58,16 @@ Work Completed
55
58
  - Redesigned Help screen into two columns with color-coded sections and key chips; removed wall-of-text effect.
56
59
  - Added a small color palette and helper components (Section, Row, Bullet, Columns, KeyChip) to simplify consistent styling.
57
60
  - Implemented a global Search bar and input mode:
58
- - “/” to start, Enter to apply, Esc or X to clear.
61
+ - "/" to start, Enter to apply, Esc or "X" to clear.
59
62
  - Projects: search `projectId` and `worktree`; Sessions: search `title`, `sessionId`, `directory`, `projectId`.
60
63
  - Sessions sorting & context:
61
- - S toggles sort by `updated`/`created`.
64
+ - "S" toggles sort by `updated`/`created`.
62
65
  - Show per-row description with the relevant timestamp.
63
66
  - Fixed OpenTUI text rendering error by filtering whitespace-only raw text in layout helpers and by removing nested `<text>` nodes around key chips.
64
67
  - Verified via `bun run tui` (tmux socket creation is blocked in this environment, but direct run works).
68
+ - Added session rename feature (Shift+R): inline text input with validation, updates JSON file, refreshes list.
69
+ - Added session move feature (M): select target project, relocate session JSON, update projectID field.
70
+ - Added session copy feature (P): select target project, create new session with generated ID, preserve original.
65
71
 
66
72
  How To Run
67
73
  ----------
@@ -71,7 +77,7 @@ How To Run
71
77
  - Keys:
72
78
  - Global: `Tab`/`1`/`2` switch tabs, `/` search, `X` clear search, `R` reload, `Q` quit, `?` help
73
79
  - Projects: `Space` select, `A` select all, `M` toggle missing, `D` delete, `Enter` view sessions
74
- - Sessions: `Space` select, `S` sort, `D` delete, `C` clear project filter, `Enter` details
80
+ - Sessions: `Space` select, `S` sort, `D` delete, `Y` copy ID, `Shift+R` rename, `M` move, `P` copy, `C` clear filter
75
81
  - Optional tmux usage (when permitted): `tmux new -s opencode-tui 'bun run tui'`
76
82
  - CLI help: `bun run tui -- --help` (or `bunx opencode-manager -- --help`, or `manage_opencode_projects.py -- --help`) prints the built-in usage block with key bindings.
77
83
 
package/README.md CHANGED
@@ -1,3 +1,6 @@
1
+ > **Note:** This is an independent, community-maintained project created by fans of OpenCode. We are not affiliated with SST Corp. or the official OpenCode project. For the official OpenCode CLI, visit [opencode.ai](https://opencode.ai).
2
+
3
+
1
4
  # OpenCode Metadata Manager
2
5
 
3
6
  Terminal UI for inspecting, filtering, and pruning OpenCode metadata stored on disk. The app is written in TypeScript, runs on Bun, and renders with [`@opentui/react`](https://github.com/open-tui/opentui).
@@ -18,11 +21,39 @@ Terminal UI for inspecting, filtering, and pruning OpenCode metadata stored on d
18
21
 
19
22
  ## Features
20
23
  - List both OpenCode projects and sessions from a local metadata root.
21
- - Filter by missing only”, bulk-select, and delete metadata safely.
24
+ - Filter by "missing only", bulk-select, and delete metadata safely.
22
25
  - Jump from a project directly to its sessions and keep contextual filters.
23
26
  - Global search bar (`/` to focus, `Enter` to apply, `Esc` or `X` to clear).
27
+ - Rename sessions inline (`Shift+R`) with title validation.
28
+ - Move sessions between projects (`M`) preserving session ID.
29
+ - Copy sessions to other projects (`P`) with new session ID generation.
24
30
  - Rich help overlay with live key hints (`?` or `H`).
25
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.
26
57
 
27
58
  ## Requirements
28
59
  - [Bun](https://bun.sh) **1.1.0+** (developed/tested on 1.2.x).
@@ -56,7 +87,7 @@ bun run tui -- --root ~/.local/share/opencode
56
87
  Keyboard reference:
57
88
  - **Global**: `Tab`/`1`/`2` switch tabs, `/` search, `X` clear search, `R` reload, `Q` quit, `?` help.
58
89
  - **Projects**: `Space` toggle selection, `A` select all, `M` missing-only filter, `D` delete, `Enter` jump to Sessions.
59
- - **Sessions**: `Space` select, `S` toggle updated/created sort, `D` delete, `C` clear project filter, `Enter` details.
90
+ - **Sessions**: `Space` select, `S` toggle sort, `D` delete, `Y` copy ID, `Shift+R` rename, `M` move to project, `P` copy to project, `C` clear filter.
60
91
 
61
92
  ## Development Workflow
62
93
  1. Install dependencies with `bun install`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-manager",
3
- "version": "0.1.2",
3
+ "version": "0.3.0",
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
  }
@@ -309,3 +339,396 @@ export function describeSession(record: SessionRecord, options?: { fullPath?: bo
309
339
  export async function ensureDirectory(path: string): Promise<void> {
310
340
  await fs.mkdir(dirname(path), { recursive: true })
311
341
  }
342
+
343
+ export async function updateSessionTitle(filePath: string, newTitle: string): Promise<void> {
344
+ const payload = await readJsonFile<any>(filePath)
345
+ if (!payload) {
346
+ throw new Error(`Session file not found or unreadable: ${filePath}`)
347
+ }
348
+ payload.title = newTitle
349
+ payload.time = payload.time || {}
350
+ payload.time.updated = Date.now()
351
+ await fs.writeFile(filePath, JSON.stringify(payload, null, 2), 'utf8')
352
+ }
353
+
354
+ export async function copySession(
355
+ session: SessionRecord,
356
+ targetProjectId: string,
357
+ root: string = DEFAULT_ROOT
358
+ ): Promise<SessionRecord> {
359
+ const payload = await readJsonFile<any>(session.filePath)
360
+ if (!payload) {
361
+ throw new Error(`Session file not found: ${session.filePath}`)
362
+ }
363
+
364
+ // Generate new session ID
365
+ const newSessionId = `session_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
366
+
367
+ // Update payload for new session
368
+ payload.id = newSessionId
369
+ payload.projectID = targetProjectId
370
+ payload.time = payload.time || {}
371
+ payload.time.created = Date.now()
372
+ payload.time.updated = Date.now()
373
+
374
+ // Ensure target directory exists
375
+ const targetDir = join(root, 'storage', 'session', targetProjectId)
376
+ await ensureDirectory(join(targetDir, 'dummy'))
377
+
378
+ // Write new session file
379
+ const targetPath = join(targetDir, `${newSessionId}.json`)
380
+ await fs.writeFile(targetPath, JSON.stringify(payload, null, 2), 'utf8')
381
+
382
+ // Return new session record
383
+ return {
384
+ index: 0,
385
+ filePath: targetPath,
386
+ sessionId: newSessionId,
387
+ projectId: targetProjectId,
388
+ directory: session.directory,
389
+ title: session.title,
390
+ version: session.version,
391
+ createdAt: new Date(),
392
+ updatedAt: new Date()
393
+ }
394
+ }
395
+
396
+ export async function moveSession(
397
+ session: SessionRecord,
398
+ targetProjectId: string,
399
+ root: string = DEFAULT_ROOT
400
+ ): Promise<SessionRecord> {
401
+ const payload = await readJsonFile<any>(session.filePath)
402
+ if (!payload) {
403
+ throw new Error(`Session file not found: ${session.filePath}`)
404
+ }
405
+
406
+ payload.projectID = targetProjectId
407
+ payload.time = payload.time || {}
408
+ payload.time.updated = Date.now()
409
+
410
+ // Ensure target directory exists
411
+ const targetDir = join(root, 'storage', 'session', targetProjectId)
412
+ await ensureDirectory(join(targetDir, 'dummy'))
413
+
414
+ // Write to new location
415
+ const targetPath = join(targetDir, `${session.sessionId}.json`)
416
+ await fs.writeFile(targetPath, JSON.stringify(payload, null, 2), 'utf8')
417
+
418
+ // Remove old file
419
+ await fs.unlink(session.filePath)
420
+
421
+ return {
422
+ ...session,
423
+ filePath: targetPath,
424
+ projectId: targetProjectId,
425
+ updatedAt: new Date()
426
+ }
427
+ }
428
+
429
+ export interface BatchOperationResult {
430
+ succeeded: { session: SessionRecord; newRecord: SessionRecord }[]
431
+ failed: { session: SessionRecord; error: string }[]
432
+ }
433
+
434
+ export async function copySessions(
435
+ sessions: SessionRecord[],
436
+ targetProjectId: string,
437
+ root?: string
438
+ ): Promise<BatchOperationResult> {
439
+ const succeeded: BatchOperationResult['succeeded'] = []
440
+ const failed: BatchOperationResult['failed'] = []
441
+
442
+ for (const session of sessions) {
443
+ try {
444
+ const newRecord = await copySession(session, targetProjectId, root)
445
+ succeeded.push({ session, newRecord })
446
+ } catch (error) {
447
+ failed.push({
448
+ session,
449
+ error: error instanceof Error ? error.message : String(error)
450
+ })
451
+ }
452
+ }
453
+
454
+ return { succeeded, failed }
455
+ }
456
+
457
+ export async function moveSessions(
458
+ sessions: SessionRecord[],
459
+ targetProjectId: string,
460
+ root?: string
461
+ ): Promise<BatchOperationResult> {
462
+ const succeeded: BatchOperationResult['succeeded'] = []
463
+ const failed: BatchOperationResult['failed'] = []
464
+
465
+ for (const session of sessions) {
466
+ try {
467
+ const newRecord = await moveSession(session, targetProjectId, root)
468
+ succeeded.push({ session, newRecord })
469
+ } catch (error) {
470
+ failed.push({
471
+ session,
472
+ error: error instanceof Error ? error.message : String(error)
473
+ })
474
+ }
475
+ }
476
+
477
+ return { succeeded, failed }
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
+ }
@@ -11,6 +11,7 @@ import React, {
11
11
  useState,
12
12
  } from "react"
13
13
  import { resolve } from "node:path"
14
+ import { exec } from "node:child_process"
14
15
  import {
15
16
  DEFAULT_ROOT,
16
17
  ProjectRecord,
@@ -23,6 +24,19 @@ import {
23
24
  formatDisplayPath,
24
25
  loadProjectRecords,
25
26
  loadSessionRecords,
27
+ updateSessionTitle,
28
+ copySession,
29
+ moveSession,
30
+ copySessions,
31
+ moveSessions,
32
+ BatchOperationResult,
33
+ TokenSummary,
34
+ TokenBreakdown,
35
+ AggregateTokenSummary,
36
+ computeSessionTokenSummary,
37
+ computeProjectTokenSummary,
38
+ computeGlobalTokenSummary,
39
+ clearTokenCache,
26
40
  } from "./lib/opencode-data"
27
41
 
28
42
  type TabKey = "projects" | "sessions"
@@ -57,6 +71,7 @@ type SessionsPanelProps = {
57
71
  locked: boolean
58
72
  projectFilter: string | null
59
73
  searchQuery: string
74
+ globalTokenSummary: AggregateTokenSummary | null
60
75
  onNotify: (message: string, level?: NotificationLevel) => void
61
76
  requestConfirm: (state: ConfirmState) => void
62
77
  onClearFilter: () => void
@@ -75,6 +90,58 @@ const PALETTE = {
75
90
  muted: "#9ca3af", // gray
76
91
  } as const
77
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
+
133
+ function copyToClipboard(text: string): void {
134
+ const cmd = process.platform === "darwin" ? "pbcopy" : "xclip -selection clipboard"
135
+ const proc = exec(cmd, (error) => {
136
+ if (error) {
137
+ // We can't easily notify from here without context, but it's a best effort
138
+ console.error("Failed to copy to clipboard:", error)
139
+ }
140
+ })
141
+ proc.stdin?.write(text)
142
+ proc.stdin?.end()
143
+ }
144
+
78
145
  type ChildrenProps = { children: React.ReactNode }
79
146
 
80
147
  const Section = ({ title, children }: { title: string } & ChildrenProps) => (
@@ -125,6 +192,62 @@ const Columns = ({ children }: ChildrenProps) => {
125
192
 
126
193
  const KeyChip = ({ k }: { k: string }) => <text fg={PALETTE.key}>[{k}]</text>
127
194
 
195
+ type ProjectSelectorProps = {
196
+ projects: ProjectRecord[]
197
+ cursor: number
198
+ onCursorChange: (index: number) => void
199
+ onSelect: (project: ProjectRecord) => void
200
+ onCancel: () => void
201
+ operationMode: 'move' | 'copy'
202
+ sessionCount: number
203
+ }
204
+
205
+ const ProjectSelector = ({
206
+ projects,
207
+ cursor,
208
+ onCursorChange,
209
+ onSelect,
210
+ onCancel,
211
+ operationMode,
212
+ sessionCount
213
+ }: ProjectSelectorProps) => {
214
+ const options: SelectOption[] = projects.map((p, idx) => ({
215
+ name: `${formatDisplayPath(p.worktree)} (${p.projectId})`,
216
+ description: p.state,
217
+ value: idx
218
+ }))
219
+
220
+ return (
221
+ <box
222
+ title={`Select Target Project (${operationMode} ${sessionCount} session${sessionCount > 1 ? 's' : ''})`}
223
+ style={{
224
+ border: true,
225
+ borderColor: operationMode === 'move' ? PALETTE.key : PALETTE.accent,
226
+ padding: 1,
227
+ position: 'absolute',
228
+ top: 5,
229
+ left: 5,
230
+ right: 5,
231
+ bottom: 5,
232
+ zIndex: 100
233
+ }}
234
+ >
235
+ <select
236
+ options={options}
237
+ selectedIndex={cursor}
238
+ onChange={onCursorChange}
239
+ onSelect={(idx) => {
240
+ const project = projects[idx]
241
+ if (project) onSelect(project)
242
+ }}
243
+ focused={true}
244
+ showScrollIndicator
245
+ />
246
+ <text fg={PALETTE.muted}>Enter to select, Esc to cancel</text>
247
+ </box>
248
+ )
249
+ }
250
+
128
251
  const ProjectsPanel = forwardRef<PanelHandle, ProjectsPanelProps>(function ProjectsPanel(
129
252
  { root, active, locked, searchQuery, onNotify, requestConfirm, onNavigateToSessions },
130
253
  ref,
@@ -135,6 +258,9 @@ const ProjectsPanel = forwardRef<PanelHandle, ProjectsPanelProps>(function Proje
135
258
  const [missingOnly, setMissingOnly] = useState(false)
136
259
  const [cursor, setCursor] = useState(0)
137
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)
138
264
 
139
265
  const missingCount = useMemo(() => records.filter((record) => record.state === "missing").length, [records])
140
266
 
@@ -205,6 +331,34 @@ const ProjectsPanel = forwardRef<PanelHandle, ProjectsPanelProps>(function Proje
205
331
  })
206
332
  }, [visibleRecords.length])
207
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
+
208
362
  const toggleSelection = useCallback((record: ProjectRecord | undefined) => {
209
363
  if (!record) {
210
364
  return
@@ -372,6 +526,19 @@ const ProjectsPanel = forwardRef<PanelHandle, ProjectsPanelProps>(function Proje
372
526
  <text>Created: {formatDate(currentRecord.createdAt)}</text>
373
527
  <text>Path:</text>
374
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>
375
542
  </box>
376
543
  ) : null}
377
544
  </box>
@@ -381,7 +548,7 @@ const ProjectsPanel = forwardRef<PanelHandle, ProjectsPanelProps>(function Proje
381
548
  })
382
549
 
383
550
  const SessionsPanel = forwardRef<PanelHandle, SessionsPanelProps>(function SessionsPanel(
384
- { root, active, locked, projectFilter, searchQuery, onNotify, requestConfirm, onClearFilter },
551
+ { root, active, locked, projectFilter, searchQuery, globalTokenSummary, onNotify, requestConfirm, onClearFilter },
385
552
  ref,
386
553
  ) {
387
554
  const [records, setRecords] = useState<SessionRecord[]>([])
@@ -390,6 +557,15 @@ const SessionsPanel = forwardRef<PanelHandle, SessionsPanelProps>(function Sessi
390
557
  const [cursor, setCursor] = useState(0)
391
558
  const [selectedIndexes, setSelectedIndexes] = useState<Set<number>>(new Set())
392
559
  const [sortMode, setSortMode] = useState<"updated" | "created">("updated")
560
+ const [isRenaming, setIsRenaming] = useState(false)
561
+ const [renameValue, setRenameValue] = useState('')
562
+ const [isSelectingProject, setIsSelectingProject] = useState(false)
563
+ const [operationMode, setOperationMode] = useState<'move' | 'copy' | null>(null)
564
+ const [availableProjects, setAvailableProjects] = useState<ProjectRecord[]>([])
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)
393
569
 
394
570
  const visibleRecords = useMemo(() => {
395
571
  const sorted = [...records].sort((a, b) => {
@@ -467,6 +643,46 @@ const SessionsPanel = forwardRef<PanelHandle, SessionsPanelProps>(function Sessi
467
643
  })
468
644
  }, [visibleRecords.length])
469
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
+
470
686
  const toggleSelection = useCallback((session: SessionRecord | undefined) => {
471
687
  if (!session) {
472
688
  return
@@ -527,12 +743,103 @@ const SessionsPanel = forwardRef<PanelHandle, SessionsPanelProps>(function Sessi
527
743
  })
528
744
  }, [selectedSessions, onNotify, requestConfirm, refreshRecords])
529
745
 
746
+ const executeRename = useCallback(async () => {
747
+ if (!currentSession || !renameValue.trim()) {
748
+ onNotify('Title cannot be empty', 'error')
749
+ setIsRenaming(false)
750
+ return
751
+ }
752
+ if (renameValue.length > 200) {
753
+ onNotify('Title too long (max 200 characters)', 'error')
754
+ return
755
+ }
756
+ try {
757
+ await updateSessionTitle(currentSession.filePath, renameValue.trim())
758
+ onNotify(`Renamed to "${renameValue.trim()}"`)
759
+ setIsRenaming(false)
760
+ setRenameValue('')
761
+ await refreshRecords(true)
762
+ } catch (error) {
763
+ const msg = error instanceof Error ? error.message : String(error)
764
+ onNotify(`Rename failed: ${msg}`, 'error')
765
+ }
766
+ }, [currentSession, renameValue, onNotify, refreshRecords])
767
+
768
+ const executeTransfer = useCallback(async (
769
+ targetProject: ProjectRecord,
770
+ mode: 'move' | 'copy'
771
+ ) => {
772
+ setIsSelectingProject(false)
773
+ setOperationMode(null)
774
+
775
+ const operationFn = mode === 'move' ? moveSessions : copySessions
776
+ const result = await operationFn(selectedSessions, targetProject.projectId, root)
777
+
778
+ setSelectedIndexes(new Set())
779
+
780
+ const successCount = result.succeeded.length
781
+ const failCount = result.failed.length
782
+ const verb = mode === 'move' ? 'moved' : 'copied'
783
+
784
+ if (failCount === 0) {
785
+ onNotify(`Successfully ${verb} ${successCount} session(s) to ${targetProject.projectId}`)
786
+ } else {
787
+ onNotify(
788
+ `${verb} ${successCount} session(s), ${failCount} failed`,
789
+ 'error'
790
+ )
791
+ }
792
+
793
+ await refreshRecords(true)
794
+ }, [selectedSessions, root, onNotify, refreshRecords])
795
+
530
796
  const handleKey = useCallback(
531
797
  (key: KeyEvent) => {
532
798
  if (!active || locked) {
533
799
  return
534
800
  }
535
801
 
802
+ // Handle project selection mode
803
+ if (isSelectingProject) {
804
+ if (key.name === 'escape') {
805
+ setIsSelectingProject(false)
806
+ setOperationMode(null)
807
+ return
808
+ }
809
+ if (key.name === 'return' || key.name === 'enter') {
810
+ const targetProject = availableProjects[projectCursor]
811
+ if (targetProject && operationMode) {
812
+ void executeTransfer(targetProject, operationMode)
813
+ }
814
+ return
815
+ }
816
+ // Let select component handle up/down via onCursorChange
817
+ return
818
+ }
819
+
820
+ // Handle rename mode - takes precedence over other key handling
821
+ if (isRenaming) {
822
+ if (key.name === 'escape') {
823
+ setIsRenaming(false)
824
+ setRenameValue('')
825
+ return
826
+ }
827
+ if (key.name === 'return' || key.name === 'enter') {
828
+ void executeRename()
829
+ return
830
+ }
831
+ if (key.name === 'backspace') {
832
+ setRenameValue(prev => prev.slice(0, -1))
833
+ return
834
+ }
835
+ const ch = key.sequence
836
+ if (ch && ch.length === 1 && !key.ctrl && !key.meta) {
837
+ setRenameValue(prev => prev + ch)
838
+ return
839
+ }
840
+ return
841
+ }
842
+
536
843
  const letter = key.sequence?.toLowerCase()
537
844
  if (key.name === "space") {
538
845
  key.preventDefault()
@@ -555,6 +862,58 @@ const SessionsPanel = forwardRef<PanelHandle, SessionsPanelProps>(function Sessi
555
862
  requestDeletion()
556
863
  return
557
864
  }
865
+ if (letter === "y") {
866
+ if (currentSession) {
867
+ copyToClipboard(currentSession.sessionId)
868
+ onNotify(`Copied ID ${currentSession.sessionId} to clipboard`)
869
+ }
870
+ return
871
+ }
872
+ // Rename with Shift+R (uppercase R)
873
+ if (key.sequence === 'R') {
874
+ if (currentSession) {
875
+ setIsRenaming(true)
876
+ setRenameValue(currentSession.title || '')
877
+ }
878
+ return
879
+ }
880
+ // Move with M key
881
+ if (letter === 'm') {
882
+ if (selectedSessions.length === 0) {
883
+ onNotify('No sessions selected for move', 'error')
884
+ return
885
+ }
886
+ // Load projects for selection
887
+ loadProjectRecords({ root }).then(projects => {
888
+ // Filter out current project if filtering by project
889
+ const filtered = projectFilter
890
+ ? projects.filter(p => p.projectId !== projectFilter)
891
+ : projects
892
+ setAvailableProjects(filtered)
893
+ setProjectCursor(0)
894
+ setOperationMode('move')
895
+ setIsSelectingProject(true)
896
+ }).catch(err => {
897
+ onNotify(`Failed to load projects: ${err.message}`, 'error')
898
+ })
899
+ return
900
+ }
901
+ // Copy with P key
902
+ if (letter === 'p') {
903
+ if (selectedSessions.length === 0) {
904
+ onNotify('No sessions selected for copy', 'error')
905
+ return
906
+ }
907
+ loadProjectRecords({ root }).then(projects => {
908
+ setAvailableProjects(projects)
909
+ setProjectCursor(0)
910
+ setOperationMode('copy')
911
+ setIsSelectingProject(true)
912
+ }).catch(err => {
913
+ onNotify(`Failed to load projects: ${err.message}`, 'error')
914
+ })
915
+ return
916
+ }
558
917
  if (key.name === "return" || key.name === "enter") {
559
918
  if (currentSession) {
560
919
  const title = currentSession.title && currentSession.title.trim().length > 0 ? currentSession.title : currentSession.sessionId
@@ -563,7 +922,7 @@ const SessionsPanel = forwardRef<PanelHandle, SessionsPanelProps>(function Sessi
563
922
  return
564
923
  }
565
924
  },
566
- [active, locked, currentSession, projectFilter, onClearFilter, onNotify, requestDeletion, toggleSelection],
925
+ [active, locked, currentSession, projectFilter, onClearFilter, onNotify, requestDeletion, toggleSelection, isRenaming, executeRename, isSelectingProject, availableProjects, projectCursor, operationMode, executeTransfer, selectedSessions, root],
567
926
  )
568
927
 
569
928
  useImperativeHandle(
@@ -590,9 +949,32 @@ const SessionsPanel = forwardRef<PanelHandle, SessionsPanelProps>(function Sessi
590
949
  >
591
950
  <box flexDirection="column" marginBottom={1}>
592
951
  <text>Filter: {projectFilter ? `project ${projectFilter}` : "none"} | Sort: {sortMode} | Search: {searchQuery || "(none)"} | Selected: {selectedIndexes.size}</text>
593
- <text>Keys: Space select, S sort, D delete, C clear filter, Enter details, Esc clear</text>
952
+ <text>Keys: Space select, S sort, D delete, Y copy ID, Shift+R rename, M move, P copy, C clear filter</text>
594
953
  </box>
595
954
 
955
+ {isRenaming ? (
956
+ <box style={{ border: true, borderColor: PALETTE.key, padding: 1, marginBottom: 1 }}>
957
+ <text>Rename: </text>
958
+ <text fg={PALETTE.key}>{renameValue}</text>
959
+ <text fg={PALETTE.muted}> (Enter confirm, Esc cancel)</text>
960
+ </box>
961
+ ) : null}
962
+
963
+ {isSelectingProject && operationMode ? (
964
+ <ProjectSelector
965
+ projects={availableProjects}
966
+ cursor={projectCursor}
967
+ onCursorChange={setProjectCursor}
968
+ onSelect={(project) => executeTransfer(project, operationMode)}
969
+ onCancel={() => {
970
+ setIsSelectingProject(false)
971
+ setOperationMode(null)
972
+ }}
973
+ operationMode={operationMode}
974
+ sessionCount={selectedSessions.length}
975
+ />
976
+ ) : null}
977
+
596
978
  {error ? (
597
979
  <text fg="red">{error}</text>
598
980
  ) : loading ? (
@@ -613,7 +995,7 @@ const SessionsPanel = forwardRef<PanelHandle, SessionsPanelProps>(function Sessi
613
995
  onNotify(`Session ${title} [${session.sessionId}] → ${formatDisplayPath(session.directory)}`)
614
996
  }
615
997
  }}
616
- focused={active && !locked}
998
+ focused={active && !locked && !isSelectingProject && !isRenaming}
617
999
  showScrollIndicator
618
1000
  showDescription={false}
619
1001
  wrapSelection={false}
@@ -628,6 +1010,34 @@ const SessionsPanel = forwardRef<PanelHandle, SessionsPanelProps>(function Sessi
628
1010
  <text>Updated: {formatDate(currentSession.updatedAt || currentSession.createdAt)}</text>
629
1011
  <text>Directory:</text>
630
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}
1040
+ <text fg={PALETTE.muted} style={{ marginTop: 1 }}>Press Y to copy ID</text>
631
1041
  </box>
632
1042
  ) : null}
633
1043
  </box>
@@ -755,6 +1165,22 @@ const HelpScreen = ({ onDismiss }: { onDismiss: () => void }) => {
755
1165
  <KeyChip k="D" />
756
1166
  <text> — With confirmation</text>
757
1167
  </Bullet>
1168
+ <Bullet>
1169
+ <text>Copy ID: </text>
1170
+ <KeyChip k="Y" />
1171
+ </Bullet>
1172
+ <Bullet>
1173
+ <text>Rename: </text>
1174
+ <KeyChip k="Shift+R" />
1175
+ </Bullet>
1176
+ <Bullet>
1177
+ <text>Move to project: </text>
1178
+ <KeyChip k="M" />
1179
+ </Bullet>
1180
+ <Bullet>
1181
+ <text>Copy to project: </text>
1182
+ <KeyChip k="P" />
1183
+ </Bullet>
758
1184
  <Bullet>
759
1185
  <text>Show details: </text>
760
1186
  <KeyChip k="Enter" />
@@ -797,6 +1223,23 @@ const App = ({ root }: { root: string }) => {
797
1223
  const [confirmState, setConfirmState] = useState<ConfirmState | null>(null)
798
1224
  const [showHelp, setShowHelp] = useState(true)
799
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])
800
1243
 
801
1244
  const notify = useCallback((message: string, level: NotificationLevel = "info") => {
802
1245
  setStatus(message)
@@ -921,6 +1364,9 @@ const App = ({ root }: { root: string }) => {
921
1364
  }
922
1365
 
923
1366
  if (letter === "r") {
1367
+ // Clear token cache on reload
1368
+ clearTokenCache()
1369
+ setTokenRefreshKey((k) => k + 1)
924
1370
  if (activeTab === "projects") {
925
1371
  projectsRef.current?.refresh()
926
1372
  } else {
@@ -955,7 +1401,21 @@ const App = ({ root }: { root: string }) => {
955
1401
  return (
956
1402
  <box style={{ flexDirection: "column", padding: 1, flexGrow: 1 }}>
957
1403
  <box flexDirection="column" marginBottom={1}>
958
- <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>
959
1419
  <text>Root: {root}</text>
960
1420
  <text>
961
1421
  Tabs: [1] Projects [2] Sessions | Active: {activeTab} | Global: Tab switch, / search, X clear, R reload, Q quit, ? help
@@ -990,6 +1450,7 @@ const App = ({ root }: { root: string }) => {
990
1450
  locked={Boolean(confirmState) || showHelp}
991
1451
  projectFilter={sessionFilter}
992
1452
  searchQuery={activeTab === "sessions" ? searchQuery : ""}
1453
+ globalTokenSummary={globalTokens}
993
1454
  onNotify={notify}
994
1455
  requestConfirm={requestConfirm}
995
1456
  onClearFilter={clearSessionFilter}
@@ -1028,11 +1489,32 @@ function printUsage(): void {
1028
1489
  Usage: bun run tui [-- --root /path/to/storage]
1029
1490
 
1030
1491
  Key bindings:
1031
- Tab / 1 / 2 Switch between projects and sessions
1032
- R Reload the active view
1033
- Q Quit the application
1034
- Projects view: Space select, A select all, M toggle missing filter, D delete, Enter jump to sessions
1035
- 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
1036
1518
  `)
1037
1519
  }
1038
1520