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.
- package/PROJECT-SUMMARY.md +10 -4
- package/README.md +33 -2
- package/package.json +1 -1
- package/src/lib/opencode-data.ts +423 -0
- package/src/opencode-tui.tsx +492 -10
package/PROJECT-SUMMARY.md
CHANGED
|
@@ -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
|
|
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
|
-
-
|
|
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
|
-
-
|
|
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, `
|
|
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
|
|
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
|
|
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
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
|
}
|
|
@@ -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
|
+
}
|
package/src/opencode-tui.tsx
CHANGED
|
@@ -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,
|
|
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
|
-
<
|
|
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
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
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
|
|