Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions Fixtures/OpenCodeMessages/expected-output.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
assistant_message_count=6
session_count=2

# Verification SQL (for reference):
# sqlite3 sample-opencode.db "SELECT COUNT(*) FROM message WHERE json_extract(data, '$.role') = 'assistant';"
# sqlite3 sample-opencode.db "SELECT COUNT(*) FROM session;"
Binary file added Fixtures/OpenCodeMessages/sample-opencode.db
Binary file not shown.
8 changes: 4 additions & 4 deletions README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@

평소처럼 AI 코딩하면, 메뉴 막대에서 Tokenmon이 조용히 모입니다.

Tokenmon은 Claude Code와 Codex 사용을 가볍고 귀여운 수집 경험으로
바꿔주는 macOS 메뉴 막대 앱입니다. 코딩하는 동안 탐험이 쌓이고, 가끔
새로운 Tokenmon을 만나며, 포획 결과와 Dex가 계정 없이 Mac 안에
차곡차곡 쌓입니다.
Tokenmon은 Claude Code, Codex, OpenCode, Gemini, Cursor 사용을 가볍고
귀여운 수집 경험으로 바꿔주는 macOS 메뉴 막대 앱입니다. 코딩하는 동안
탐험이 쌓이고, 가끔 새로운 Tokenmon을 만나며, 포획 결과와 Dex가 계정
없이 Mac 안에 차곡차곡 쌓입니다.

![Tokenmon animated menu hero](assets/screenshots/variants/korean/dark/menu-hero.gif)

Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@
Turn everyday AI coding into a quiet creature-collection loop.

Tokenmon is a macOS menu bar companion for people who spend their day in Claude
Code and Codex. Keep coding like normal, let exploration build in the
background, and watch new creatures appear, resolve, and fill your Dex without
asking for an account or your prompt history.
Code, Codex, OpenCode, Gemini, and Cursor. Keep coding like normal, let
exploration build in the background, and watch new creatures appear, resolve,
and fill your Dex without asking for an account or your prompt history.

![Tokenmon animated menu hero](assets/screenshots/variants/english/dark/menu-hero.gif)

Expand Down
24 changes: 18 additions & 6 deletions Sources/TokenmonApp/Popover/TokenmonNowTab.swift
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ struct TokenmonNowTab: View {
}
.padding(.horizontal, 14)
.padding(.top, 12)
.padding(.bottom, 10)
.padding(.bottom, 20)
.frame(width: 300, alignment: .topLeading)
}

Expand Down Expand Up @@ -360,23 +360,33 @@ private struct TokenmonProviderStatusChip: View {
}

private var presentationModel: Presentation {
let providerTint: Color = {
switch provider {
case .claude: return .orange
case .codex: return .teal
case .gemini: return .indigo
case .cursor: return .green
case .opencode: return .purple
}
}()

if provider != .cursor, let installed = cliInstalled, !installed {
return Presentation(tint: .secondary, accessibilityState: TokenmonL10n.string("provider.status.not_installed"))
}

guard let healthSummary else {
return Presentation(tint: .secondary, accessibilityState: TokenmonL10n.string("provider.status.unavailable"))
return Presentation(tint: providerTint.opacity(0.5), accessibilityState: TokenmonL10n.string("provider.status.unavailable"))
}

switch healthSummary.healthState {
case "active", "connected":
return Presentation(tint: .green, accessibilityState: TokenmonL10n.string("provider.status.connected"))
return Presentation(tint: providerTint, accessibilityState: TokenmonL10n.string("provider.status.connected"))
case "missing_configuration":
return Presentation(tint: .orange, accessibilityState: TokenmonL10n.string("provider.status.needs_setup"))
return Presentation(tint: providerTint.opacity(0.6), accessibilityState: TokenmonL10n.string("provider.status.needs_setup"))
case "degraded", "unsupported":
return Presentation(tint: .red, accessibilityState: TokenmonL10n.string("provider.status.needs_attention"))
return Presentation(tint: providerTint.opacity(0.4), accessibilityState: TokenmonL10n.string("provider.status.needs_attention"))
default:
return Presentation(tint: .secondary, accessibilityState: healthSummary.healthState)
return Presentation(tint: providerTint.opacity(0.5), accessibilityState: healthSummary.healthState)
}
}

Expand Down Expand Up @@ -446,6 +456,8 @@ private extension ProviderCode {
return "Gemini"
case .cursor:
return "Cursor"
case .opencode:
return "OpenCode"
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion Sources/TokenmonApp/Popover/TokenmonPopoverContainer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ struct TokenmonPopoverContainerActions {

struct TokenmonPopoverContainer: View {
static let width: CGFloat = 360
static let height: CGFloat = 520
static let height: CGFloat = 560
static let contentWidth: CGFloat = 300

@ObservedObject var model: TokenmonMenuModel
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ struct TokenmonProviderIndicator: View {
case .codex: return "Codex"
case .gemini: return "Gemini"
case .cursor: return "Cursor"
case .opencode: return "OpenCode"
}
}

Expand Down
4 changes: 3 additions & 1 deletion Sources/TokenmonApp/Popover/TokenmonTokensTab.swift
Original file line number Diff line number Diff line change
Expand Up @@ -72,14 +72,15 @@ struct TokenmonTokensTab: View {
}
}

private var providerSplitOrder: [ProviderCode] { [.claude, .codex, .gemini, .cursor] }
private var providerSplitOrder: [ProviderCode] { [.claude, .codex, .gemini, .cursor, .opencode] }

private func providerColor(_ provider: ProviderCode) -> Color {
switch provider {
case .claude: return .orange
case .codex: return .teal
case .gemini: return .indigo
case .cursor: return .green
case .opencode: return .purple
}
}

Expand All @@ -89,6 +90,7 @@ struct TokenmonTokensTab: View {
case .codex: return "Codex"
case .gemini: return "Gemini"
case .cursor: return "Cursor"
case .opencode: return "OpenCode"
}
}

Expand Down
16 changes: 16 additions & 0 deletions Sources/TokenmonApp/Resources/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -312,3 +312,19 @@
"settings.providers.source_mode.cursor_usage_export_api" = "Usage export sync";
"settings.providers.next_step.cursor_missing_configuration" = "Sync Cursor usage from Tokenmon to import the latest managed-only accounting data.";
"settings.feedback.cursor_sync_started" = "Syncing Cursor usage into Tokenmon…";

"provider.opencode.missing.title" = "OpenCode Not Found";
"provider.opencode.missing.detail" = "Install OpenCode to start tracking token usage";
"provider.opencode.missing.custom_path_detail" = "Custom path does not point to a valid OpenCode binary";
"provider.opencode.ready.title" = "OpenCode Ready";
"provider.opencode.ready.detail" = "OpenCode is installed and session data is available";
"provider.opencode.connected.title" = "OpenCode Connected";
"provider.opencode.connected.detail" = "Tracking OpenCode sessions in the background";
"provider.opencode.repair.title" = "OpenCode Needs Setup";
"provider.opencode.repair.detail" = "OpenCode was found but needs configuration";
"provider.opencode.repair.action" = "Set Up OpenCode";
"provider.opencode.sync.title" = "OpenCode Sync";
"provider.opencode.sync.detail" = "OpenCode session data is read from the local database";
"provider.opencode.sync.action" = "Sync Now";
"provider.opencode.sync_again.action" = "Sync Again";
"provider.install.opencode.success" = "OpenCode has been configured for Tokenmon. Session data will be read from the local database.";
16 changes: 16 additions & 0 deletions Sources/TokenmonApp/Resources/ko.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -658,3 +658,19 @@
"settings.providers.source_mode.cursor_usage_export_api" = "Usage export 동기화";
"settings.providers.next_step.cursor_missing_configuration" = "Tokenmon에서 Cursor usage sync를 실행해 최신 managed-only accounting 데이터를 가져오세요.";
"settings.feedback.cursor_sync_started" = "Cursor 사용량을 Tokenmon으로 동기화하는 중…";

"provider.opencode.missing.title" = "OpenCode 감지 안 됨";
"provider.opencode.missing.detail" = "OpenCode를 설치하거나 경로를 지정하세요.";
"provider.opencode.missing.custom_path_detail" = "사용자 지정 경로에서 OpenCode를 찾지 못했습니다. 경로를 수정하거나 자동 감지로 되돌리세요.";
"provider.opencode.ready.title" = "OpenCode 준비됨";
"provider.opencode.ready.detail" = "OpenCode가 설치되어 있으며 세션 데이터를 사용할 수 있습니다.";
"provider.opencode.connected.title" = "OpenCode 연결됨";
"provider.opencode.connected.detail" = "백그라운드에서 OpenCode 세션을 추적하고 있습니다.";
"provider.opencode.repair.title" = "OpenCode 설정 필요";
"provider.opencode.repair.detail" = "OpenCode가 감지되었지만 추가 설정이 필요합니다.";
"provider.opencode.repair.action" = "OpenCode 설정";
"provider.opencode.sync.title" = "OpenCode 동기화";
"provider.opencode.sync.detail" = "로컬 데이터베이스에서 OpenCode 세션 데이터를 읽어옵니다.";
"provider.opencode.sync.action" = "지금 동기화";
"provider.opencode.sync_again.action" = "OpenCode 다시 동기화";
"provider.install.opencode.success" = "OpenCode가 Tokenmon용으로 설정되었습니다. 로컬 데이터베이스에서 세션 데이터를 읽어옵니다.";
2 changes: 2 additions & 0 deletions Sources/TokenmonApp/TokenmonAutomationCommands.swift
Original file line number Diff line number Diff line change
Expand Up @@ -601,6 +601,8 @@ enum TokenmonAutomationCommand {
throw AutomationError.invalidUsage("gemini transcript backfill is not yet supported")
case .cursor:
throw AutomationError.invalidUsage("cursor transcript backfill is not yet supported")
case .opencode:
throw AutomationError.invalidUsage("opencode transcript backfill is not yet supported")
}
}

Expand Down
100 changes: 100 additions & 0 deletions Sources/TokenmonApp/TokenmonMenuModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -939,6 +939,10 @@ final class TokenmonMenuModel: ObservableObject {
await performCursorUsageSync(.background)
}

func syncOpenCodeUsageInBackground() async {
await performOpenCodeUsageSync(.background)
}

func runTranscriptBackfill(
provider: ProviderCode,
transcriptPath: String,
Expand Down Expand Up @@ -1001,6 +1005,34 @@ final class TokenmonMenuModel: ObservableObject {
settingsMessage = TokenmonL10n.string("settings.feedback.gemini_backfill_unsupported")
case .cursor:
settingsMessage = "Cursor transcript backfill is not supported"
case .opencode:
let dbPath = TokenmonProviderDiscovery.opencodeDBPath(preferences: providerInstallationPreferences)
let result = try OpenCodeBackfillService.run(
databasePath: databasePath,
dbPath: dbPath
)
if result.backfillRunID > 0 {
analyticsTracker.captureBackfillRunCompleted(
BackfillRunSummary(
backfillRunID: result.backfillRunID,
provider: .opencode,
mode: "opencode_sqlite_backfill",
status: result.status,
startedAt: completedAt,
completedAt: completedAt,
samplesExamined: result.samplesExamined,
samplesCreated: result.samplesCreated,
duplicatesSkipped: result.duplicatesSkipped,
errorsCount: result.errorsCount,
summaryJSON: result.summaryJSON
)
)
}
if result.status == "noop" {
settingsMessage = TokenmonL10n.string("settings.feedback.opencode_backfill_noop")
} else {
settingsMessage = TokenmonL10n.format("settings.feedback.opencode_backfill_complete", result.samplesCreated, result.duplicatesSkipped)
}
}
settingsError = nil
refresh(reason: .manual)
Expand Down Expand Up @@ -1754,6 +1786,71 @@ private extension TokenmonMenuModel {
}
}

private enum TokenmonOpenCodeSyncPresentation {
case manual
case background
}

private extension TokenmonMenuModel {
@MainActor
func performOpenCodeUsageSync(_ presentation: TokenmonOpenCodeSyncPresentation) async {
let dbPath = TokenmonProviderDiscovery.opencodeDBPath(preferences: providerInstallationPreferences)

guard FileManager.default.fileExists(atPath: dbPath) else {
if presentation == .manual {
settingsError = "OpenCode database not found"
settingsMessage = nil
refresh(reason: .manual)
}
return
}

let databasePath = self.databasePath

do {
let result = try await Task.detached(priority: .utility) {
try OpenCodeBackfillService.run(
databasePath: databasePath,
dbPath: dbPath
)
}.value

if result.samplesCreated > 0 {
recordLiveActivityPulse()
}

switch presentation {
case .manual:
if result.status == "noop" {
settingsMessage = TokenmonL10n.string("settings.feedback.opencode_backfill_noop")
} else {
settingsMessage = TokenmonL10n.format("settings.feedback.opencode_backfill_complete", result.samplesCreated, result.duplicatesSkipped)
}
settingsError = nil
refresh(reason: .manual)
case .background:
if result.samplesCreated > 0 {
refresh(reason: .manual)
}
}
} catch {
switch presentation {
case .manual:
settingsError = error.localizedDescription
settingsMessage = nil
refresh(reason: .manual)
case .background:
TokenmonAppBehaviorLogger.debug(
category: "providers",
event: "opencode_background_sync_failed",
metadata: ["error": error.localizedDescription],
supportDirectoryPath: supportDirectoryPath
)
}
}
}
}

enum TokenmonSceneContextResolver {
static func popoverContext(
displayedSceneContext: TokenmonSceneContext?,
Expand Down Expand Up @@ -2470,6 +2567,9 @@ final class TokenmonInboxMonitor: @unchecked Sendable {
case .cursor:
ProviderBackfillRequestQueue.removeRequest(at: pendingRequest.filePath)
continue
case .opencode:
ProviderBackfillRequestQueue.removeRequest(at: pendingRequest.filePath)
continue
}

ProviderBackfillRequestQueue.removeRequest(at: pendingRequest.filePath)
Expand Down
2 changes: 2 additions & 0 deletions Sources/TokenmonApp/TokenmonMenuViews.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1910,6 +1910,8 @@ private struct TokenmonProviderSettingsCard: View {
return "antenna.radiowaves.left.and.right"
case .cursor:
return "arrow.triangle.branch"
case .opencode:
return "terminal"
}
}

Expand Down
16 changes: 16 additions & 0 deletions Sources/TokenmonApp/TokenmonProviderDiscovery.swift
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,18 @@ enum TokenmonProviderDiscovery {
.path
}

static func opencodeDBPath(
preferences: ProviderInstallationPreferences
) -> String {
opencodeDBPath(configurationRootPath: discover(provider: .opencode, preferences: preferences).configurationRootPath)
}

static func opencodeDBPath(configurationRootPath: String) -> String {
URL(fileURLWithPath: configurationRootPath, isDirectory: true)
.appendingPathComponent("opencode.db")
.path
}

static func resolvedHomeDirectory() -> URL {
if let override = ProcessInfo.processInfo.environment["TOKENMON_HOME_OVERRIDE"],
override.isEmpty == false {
Expand Down Expand Up @@ -175,6 +187,8 @@ enum TokenmonProviderDiscovery {
return "gemini"
case .cursor:
return "cursor"
case .opencode:
return "opencode"
}
}

Expand All @@ -190,6 +204,8 @@ enum TokenmonProviderDiscovery {
return resolvedHomeDirectory()
.appendingPathComponent("Library/Application Support/Cursor/User", isDirectory: true)
.path
case .opencode:
return resolvedHomeDirectory().appendingPathComponent(".local/share/opencode", isDirectory: true).path
}
}

Expand Down
Loading