diff --git a/Cargo.lock b/Cargo.lock index 370d1a4..779b24b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1020,6 +1020,18 @@ dependencies = [ "num-traits", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fast-srgb8" version = "1.0.0" @@ -1469,6 +1481,15 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + [[package]] name = "hassle-rs" version = "0.11.0" @@ -2209,6 +2230,17 @@ dependencies = [ "redox_syscall 0.7.2", ] +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -3534,6 +3566,20 @@ version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags 2.11.0", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "rustc-hash" version = "1.1.0" @@ -4284,6 +4330,7 @@ dependencies = [ "rayon", "reqwest", "resvg", + "rusqlite", "serde", "serde_json", "terminal_size", @@ -4612,6 +4659,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" diff --git a/Cargo.toml b/Cargo.toml index c3f10da..1051360 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -85,3 +85,4 @@ serde_json = "1.0" tokio = { version = "1.48", features = ["fs", "macros", "rt-multi-thread"] } terminal_size = { version = "0.4", optional = true } cfg-if = "1.0.4" +rusqlite = { version = "0.32", features = ["bundled"] } diff --git a/README.md b/README.md index d2db84e..4120a7e 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,7 @@ tu --tui # same report in terminal UI # Source-specific tu codex tu claude +tu opencode tu antigravity # Date filter @@ -161,10 +162,11 @@ tu heartbeat watch . tu heartbeat stats tu heartbeat ping src/main.rs --write -# Live monitor (tabs: Codex / Claude / Antigravity) +# Live monitor (tabs: Codex / Claude / opencode / Antigravity) tu live tu live codex tu live claude +tu live opencode tu live antigravity # Real-time per-session viewer (htop for tokens) @@ -214,9 +216,14 @@ For a detailed feature comparison, see [tokenusage vs ccusage](docs/compare/toke From local log directories and IDE probes: - Claude: `~/.config/claude/projects`, `~/.claude/projects` - Codex: `~/.codex/sessions`, `~/.config/codex/sessions` +- opencode: `~/.local/share/opencode/opencode.db` (SQLite; honours `$XDG_DATA_HOME` and `$OPENCODE_DB`) - Antigravity: probed from running IDE language server (no log files needed) -You can override with `--claude-projects-dir` and `--codex-sessions-dir`. +You can override with `--claude-projects-dir`, `--codex-sessions-dir`, and `--opencode-db`. + +opencode records its own per-message cost. By default `tu` estimates cost from its +pricing table like the other sources; pass `--opencode-cost logged` to use opencode's +recorded cost instead (useful for custom or local models the pricing table doesn't cover). ### How is cost estimated? @@ -244,7 +251,7 @@ Yes for usage logs: parsing is local. `tu` only requests pricing metadata unless ## Command Overview ```text -tu [daily|today|activity|heartbeat|codex|claude|antigravity|monthly|weekly|img|session|blocks|live|top|statusline|gui] +tu [daily|today|activity|heartbeat|codex|claude|opencode|antigravity|monthly|weekly|img|session|blocks|live|top|statusline|gui] ``` Useful commands: diff --git a/src/activity.rs b/src/activity.rs index db86be7..9e9fd18 100644 --- a/src/activity.rs +++ b/src/activity.rs @@ -464,6 +464,7 @@ fn display_source_label(source: SourceKind) -> &'static str { match source { SourceKind::Claude => "Claude", SourceKind::Codex => "Codex", + SourceKind::Opencode => "opencode", } } diff --git a/src/api.rs b/src/api.rs index 07d5620..cbae585 100644 --- a/src/api.rs +++ b/src/api.rs @@ -139,6 +139,15 @@ pub struct Config { /// Each string should be an absolute path. Empty = use defaults. pub codex_sessions_dir: Vec, + /// Disable the opencode log source. + pub no_opencode: bool, + + /// Custom opencode database file paths. + /// + /// Overrides the default `~/.local/share/opencode/opencode.db` discovery. + /// Each string should be an absolute path. Empty = use defaults. + pub opencode_db: Vec, + /// Path substring ignore rules. /// /// Any discovered file whose absolute path contains one of these @@ -384,8 +393,11 @@ fn config_to_common_args(config: &Config) -> cli::CommonArgs { no_claude: config.no_claude, no_codex: config.no_codex, no_antigravity: true, + no_opencode: config.no_opencode, claude_projects_dir: config.claude_projects_dir.clone(), codex_sessions_dir: config.codex_sessions_dir.clone(), + opencode_db: config.opencode_db.clone(), + opencode_cost: cli::OpencodeCostModeArg::Estimated, ignore_path: config.ignore_path.clone(), no_default_ignores: config.no_default_ignores, no_incremental_cache: config.no_incremental_cache, diff --git a/src/cli.rs b/src/cli.rs index 3762c6b..e61da9f 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -20,6 +20,18 @@ pub enum SortOrder { Desc, } +/// Where the USD cost for opencode usage comes from. +#[cfg_attr(feature = "cli", derive(ValueEnum))] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum OpencodeCostModeArg { + /// Recompute cost from the pricing table (default; matches Claude/Codex). + #[default] + Estimated, + /// Use opencode's per-message logged cost. + Logged, +} + #[cfg_attr(feature = "cli", derive(ValueEnum))] #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)] #[serde(rename_all = "lowercase")] @@ -139,6 +151,8 @@ pub struct CommonArgs { pub no_codex: bool, #[cfg_attr(feature = "cli", arg(long, help = "Disable Antigravity quota probe"))] pub no_antigravity: bool, + #[cfg_attr(feature = "cli", arg(long, help = "Disable opencode source"))] + pub no_opencode: bool, #[cfg_attr( feature = "cli", arg(long = "claude-projects-dir", help = "Claude projects dir, repeatable") @@ -149,6 +163,21 @@ pub struct CommonArgs { arg(long = "codex-sessions-dir", help = "Codex sessions dir, repeatable") )] pub codex_sessions_dir: Vec, + #[cfg_attr( + feature = "cli", + arg(long = "opencode-db", help = "opencode DB file, repeatable") + )] + pub opencode_db: Vec, + #[cfg_attr( + feature = "cli", + arg( + long, + value_enum, + default_value_t = OpencodeCostModeArg::Estimated, + help = "opencode cost source (estimated|logged)" + ) + )] + pub opencode_cost: OpencodeCostModeArg, #[cfg_attr( feature = "cli", arg( @@ -215,6 +244,7 @@ pub(crate) enum Commands { Heartbeat(HeartbeatArgs), Codex(DailyArgs), Claude(DailyArgs), + Opencode(DailyArgs), Antigravity(AntigravityArgs), Monthly(MonthlyArgs), #[command(alias = "week")] @@ -627,7 +657,7 @@ pub(crate) fn normalize_cli_args(mut argv: Vec) -> Vec { Some( "-h" | "--help" | "-V" | "--version" | "help" | "daily" | "today" | "activity" | "heartbeat" | "monthly" | "weekly" | "week" | "img" | "session" | "blocks" | "live" - | "top" | "statusline" | "gui" | "codex" | "claude" | "antigravity", + | "top" | "statusline" | "gui" | "codex" | "claude" | "opencode" | "antigravity", ) => false, Some(arg) if arg.starts_with('-') => true, Some(_) => false, @@ -665,6 +695,16 @@ fn normalize_live_shortcuts(argv: &mut Vec) { argv.insert(2, "--no-codex".to_string()); } } + "opencode" => { + argv.remove(2); + argv.retain(|arg| arg != "--no-opencode"); + if !argv.iter().any(|arg| arg == "--no-claude") { + argv.insert(2, "--no-claude".to_string()); + } + if !argv.iter().any(|arg| arg == "--no-codex") { + argv.insert(2, "--no-codex".to_string()); + } + } "antigravity" => { argv.remove(2); argv.retain(|arg| arg != "--no-antigravity"); diff --git a/src/config.rs b/src/config.rs index c13af99..eb2935c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -5,8 +5,8 @@ use serde::Deserialize; use crate::cli::{ ActivityArgs, BlocksArgs, Commands, CommonArgs, CostSource, DailyArgs, GuiArgs, GuiPeriod, - ImgArgs, ImgPeriod, LiveArgs, MonthlyArgs, SessionArgs, SortOrder, StatuslineArgs, TodayArgs, - VisualBurnRate, WeekStart, WeeklyArgs, + ImgArgs, ImgPeriod, LiveArgs, MonthlyArgs, OpencodeCostModeArg, SessionArgs, SortOrder, + StatuslineArgs, TodayArgs, VisualBurnRate, WeekStart, WeeklyArgs, }; #[derive(Debug, Default, Deserialize)] @@ -54,10 +54,16 @@ struct CommonConfig { no_codex: Option, #[serde(alias = "noAntigravity")] no_antigravity: Option, + #[serde(alias = "noOpencode")] + no_opencode: Option, #[serde(alias = "claudeProjectsDir")] claude_projects_dir: Option>, #[serde(alias = "codexSessionsDir")] codex_sessions_dir: Option>, + #[serde(alias = "opencodeDb")] + opencode_db: Option>, + #[serde(alias = "opencodeCost")] + opencode_cost: Option, #[serde(alias = "ignorePath")] ignore_path: Option>, #[serde(alias = "noDefaultIgnores")] @@ -250,6 +256,14 @@ pub(crate) fn apply_config(command: Commands) -> Result { } Commands::Claude(args) } + Commands::Opencode(mut args) => { + apply_common_config(&mut args.common, config.defaults.as_ref()); + if let Some(daily_cfg) = config.commands.as_ref().and_then(|c| c.daily.as_ref()) { + apply_common_config(&mut args.common, Some(&daily_cfg.common)); + apply_daily_config(&mut args, daily_cfg); + } + Commands::Opencode(args) + } Commands::Antigravity(args) => Commands::Antigravity(args), Commands::Monthly(mut args) => { apply_common_config(&mut args.common, config.defaults.as_ref()); @@ -338,6 +352,7 @@ fn resolve_config_path(command: &Commands) -> Result> { Commands::Heartbeat(_) => None, Commands::Codex(args) => args.common.config.as_deref(), Commands::Claude(args) => args.common.config.as_deref(), + Commands::Opencode(args) => args.common.config.as_deref(), Commands::Antigravity(args) => args.config.as_deref(), Commands::Monthly(args) => args.common.config.as_deref(), Commands::Weekly(args) => args.common.config.as_deref(), @@ -396,6 +411,7 @@ fn apply_common_config(common: &mut CommonArgs, cfg: Option<&CommonConfig>) { merge_if_false(&mut common.no_claude, cfg.no_claude); merge_if_false(&mut common.no_codex, cfg.no_codex); merge_if_false(&mut common.no_antigravity, cfg.no_antigravity); + merge_if_false(&mut common.no_opencode, cfg.no_opencode); if common.claude_projects_dir.is_empty() && let Some(values) = cfg.claude_projects_dir.as_ref() { @@ -406,6 +422,16 @@ fn apply_common_config(common: &mut CommonArgs, cfg: Option<&CommonConfig>) { { common.codex_sessions_dir = values.clone(); } + if common.opencode_db.is_empty() + && let Some(values) = cfg.opencode_db.as_ref() + { + common.opencode_db = values.clone(); + } + merge_if_default( + &mut common.opencode_cost, + cfg.opencode_cost, + OpencodeCostModeArg::default(), + ); if common.ignore_path.is_empty() && let Some(values) = cfg.ignore_path.as_ref() { diff --git a/src/lib.rs b/src/lib.rs index 93a28ff..9170481 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -173,7 +173,9 @@ pub async fn run() -> Result<()> { #[cfg(feature = "cli")] fn extract_throttle(cmd: &Commands) -> u64 { match cmd { - Commands::Daily(a) | Commands::Codex(a) | Commands::Claude(a) => a.common.slow, + Commands::Daily(a) | Commands::Codex(a) | Commands::Claude(a) | Commands::Opencode(a) => { + a.common.slow + } Commands::Today(a) => a.common.slow, Commands::Activity(a) => a.common.slow, Commands::Monthly(a) => a.common.slow, @@ -260,11 +262,19 @@ async fn dispatch(command: Commands) -> Result<()> { Commands::Codex(mut args) => { args.common.no_claude = true; args.common.no_codex = false; + args.common.no_opencode = true; pipeline::run_daily(args).await } Commands::Claude(mut args) => { args.common.no_codex = true; args.common.no_claude = false; + args.common.no_opencode = true; + pipeline::run_daily(args).await + } + Commands::Opencode(mut args) => { + args.common.no_claude = true; + args.common.no_codex = true; + args.common.no_opencode = false; pipeline::run_daily(args).await } Commands::Antigravity(args) => pipeline::run_antigravity(args).await, diff --git a/src/pipeline/activity_report.rs b/src/pipeline/activity_report.rs index f0fd409..a0294c1 100644 --- a/src/pipeline/activity_report.rs +++ b/src/pipeline/activity_report.rs @@ -435,6 +435,7 @@ pub(super) fn activity_source_label(source: SourceKind) -> &'static str { match source { SourceKind::Claude => "Claude", SourceKind::Codex => "Codex", + SourceKind::Opencode => "opencode", } } diff --git a/src/pipeline/commands.rs b/src/pipeline/commands.rs index eaf2b75..6684a23 100644 --- a/src/pipeline/commands.rs +++ b/src/pipeline/commands.rs @@ -467,7 +467,7 @@ pub(crate) async fn run_antigravity(args: AntigravityArgs) -> Result<()> { .filter(|m| !shown_labels.contains(m.label.as_str())) .collect(); - for model in ordered.iter().chain(rest.into_iter()) { + for model in ordered.iter().chain(rest) { if let Some(frac) = model.remaining_fraction { let remaining_pct = frac * 100.0; let used_pct = 100.0 - remaining_pct; diff --git a/src/pipeline/live.rs b/src/pipeline/live.rs index 4611852..e6e3db6 100644 --- a/src/pipeline/live.rs +++ b/src/pipeline/live.rs @@ -576,6 +576,10 @@ pub(super) fn select_live_source( official_codex: Option<&OfficialCodexSnapshot>, official_claude: Option<&OfficialClaudeSnapshot>, ) -> Option { + // `tu live opencode` disables both other sources. + if common.no_codex && common.no_claude && !common.no_opencode { + return Some(SourceKind::Opencode); + } if common.no_codex && !common.no_claude { return Some(SourceKind::Claude); } @@ -631,7 +635,8 @@ pub(super) fn resolve_live_block_bounds( .unwrap_or(default_window_secs); (reset, window) } - None => return fallback, + // opencode has no official quota window. + Some(SourceKind::Opencode) | None => return fallback, }; let Some(mut end_unix) = reset_at else { @@ -787,6 +792,8 @@ pub(super) fn draw_blocks_live_tui(frame: &mut ratatui::Frame<'_>, context: &Liv 4 } } + // opencode has no official quota bars; only the usage-from-logs body. + LiveTab::Opencode => 0, LiveTab::Antigravity => 0, }; let tab_bar_height = 1u16; @@ -862,6 +869,9 @@ pub(super) fn draw_blocks_live_tui(frame: &mut ratatui::Frame<'_>, context: &Liv render_live_progress_bars_for(frame, progress_area, context, Some(SourceKind::Claude)); render_live_source_detail(frame, body_area, context, SourceKind::Claude); } + LiveTab::Opencode => { + render_live_source_detail(frame, body_area, context, SourceKind::Opencode); + } LiveTab::Antigravity => { render_live_antigravity_tab(frame, body_area, context); } @@ -949,7 +959,7 @@ pub(super) fn render_live_antigravity_tab( .collect(); // Draw each model with a gauge-style bar - for model in ordered.iter().chain(rest.into_iter()) { + for model in ordered.iter().chain(rest) { lines.push(Line::from(vec![Span::styled( format!(" {}", model.label), Style::default().add_modifier(Modifier::BOLD), @@ -1270,6 +1280,7 @@ pub(super) fn render_live_source_detail( let has_official = match source { SourceKind::Codex => context.official_codex.is_some(), SourceKind::Claude => context.official_claude.is_some(), + SourceKind::Opencode => false, }; if !has_official { @@ -1299,6 +1310,11 @@ pub(super) fn render_live_source_detail( "Run `tu live {lower}` after using {label} to fetch limits.", ))); } + SourceKind::Opencode => { + lines.push(Line::from( + "opencode reports usage from its local database; no official limits are available.", + )); + } } // Still show today's usage from logs below the warning @@ -1345,7 +1361,7 @@ pub(super) fn render_live_progress_bars_for( let preferred_official = match source_override { Some(SourceKind::Codex) => context.official_codex.map(LiveOfficialRef::Codex), Some(SourceKind::Claude) => context.official_claude.map(LiveOfficialRef::Claude), - None => preferred_official_for_live(context), + Some(SourceKind::Opencode) | None => preferred_official_for_live(context), }; let show_weekly = preferred_official .and_then(LiveOfficialRef::secondary_used_percent) @@ -1618,6 +1634,7 @@ pub(super) fn preferred_official_for_live<'a>( return match source { SourceKind::Codex => context.official_codex.map(LiveOfficialRef::Codex), SourceKind::Claude => context.official_claude.map(LiveOfficialRef::Claude), + SourceKind::Opencode => None, }; } @@ -1636,7 +1653,7 @@ pub(super) fn preferred_official_for_live<'a>( match context.active.and_then(|active| active.dominant_source) { Some(SourceKind::Claude) => Some(LiveOfficialRef::Claude(claude)), Some(SourceKind::Codex) => Some(LiveOfficialRef::Codex(codex)), - None => Some(LiveOfficialRef::Codex(codex)), + Some(SourceKind::Opencode) | None => Some(LiveOfficialRef::Codex(codex)), } } (None, None) => None, @@ -2294,7 +2311,7 @@ pub(super) fn live_limit_lines(context: &LiveFrameContext<'_>) -> Vec { + // opencode has no first-party plan tiers; treat it like a mixed source. + Some(SourceKind::Opencode) | None => { if estimated_window_tokens < 240_000_000 { "mixed_standard" } else if estimated_window_tokens < 1_200_000_000 { diff --git a/src/pipeline/mod.rs b/src/pipeline/mod.rs index 828ec05..254badd 100644 --- a/src/pipeline/mod.rs +++ b/src/pipeline/mod.rs @@ -9,6 +9,7 @@ mod live; #[cfg(feature = "cli")] mod membership; mod official; +mod opencode; mod parsing; mod pricing; #[cfg(feature = "cli")] @@ -168,6 +169,7 @@ struct LiveUsageRuntime { last_sources_refresh_at: Instant, sources_refresh_interval: Duration, last_cache_flush_at: Instant, + opencode_cost: crate::cli::OpencodeCostModeArg, } /// Complete snapshot of parsed token usage data. @@ -394,6 +396,7 @@ enum LiveTab { Overview, Codex, Claude, + Opencode, Antigravity, } @@ -401,6 +404,7 @@ const ALL_LIVE_TABS: &[LiveTab] = &[ LiveTab::Overview, LiveTab::Codex, LiveTab::Claude, + LiveTab::Opencode, LiveTab::Antigravity, ]; @@ -410,6 +414,7 @@ impl LiveTab { LiveTab::Overview => "Overview", LiveTab::Codex => "Codex", LiveTab::Claude => "Claude", + LiveTab::Opencode => "opencode", LiveTab::Antigravity => "Antigravity", } } diff --git a/src/pipeline/opencode.rs b/src/pipeline/opencode.rs new file mode 100644 index 0000000..74560de --- /dev/null +++ b/src/pipeline/opencode.rs @@ -0,0 +1,229 @@ +//! opencode usage source. +//! +//! Unlike Claude and Codex — which write newline-delimited JSON logs that the +//! streaming parser walks line by line — opencode stores usage in a SQLite +//! database (`~/.local/share/opencode/opencode.db`). Each assistant message is +//! a JSON blob in the `message` table's `data` column. This module reads that +//! database directly and produces [`UsageEvent`]s, bypassing the JSONL path. + +use std::path::Path; + +use chrono::{DateTime, TimeZone, Utc}; +use serde::Deserialize; + +use super::TimeZoneMode; +use crate::types::{DateFilter, PricingTable, SourceKind, UsageAccumulator, UsageEvent}; + +/// Where the USD cost for an opencode event comes from. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum OpencodeCostMode { + /// Recompute from tokens via the pricing table (default; matches Claude/Codex). + Estimated, + /// Use opencode's per-message logged `cost` verbatim. + Logged, +} + +#[derive(Deserialize, Default)] +struct RawCache { + #[serde(default)] + read: u64, + #[serde(default)] + write: u64, +} + +#[derive(Deserialize)] +struct RawTokens { + #[serde(default)] + input: u64, + #[serde(default)] + output: u64, + #[serde(default)] + reasoning: u64, + #[serde(default)] + cache: RawCache, +} + +#[derive(Deserialize)] +struct RawTime { + #[serde(default)] + created: i64, +} + +#[derive(Deserialize)] +struct RawPath { + #[serde(default)] + cwd: Option, +} + +#[derive(Deserialize)] +struct RawMessage { + #[serde(default)] + role: String, + #[serde(default)] + cost: f64, + tokens: Option, + #[serde(rename = "modelID", default)] + model_id: Option, + time: Option, + path: Option, +} + +/// Map one opencode `message.data` JSON blob to a [`UsageEvent`]. +/// +/// Returns `None` for non-assistant messages, malformed JSON, or messages that +/// carry no token counts. +pub(crate) fn map_opencode_message( + session_id: &str, + data: &str, + pricing: &PricingTable, + cost_mode: OpencodeCostMode, +) -> Option { + let raw: RawMessage = serde_json::from_str(data).ok()?; + if raw.role != "assistant" { + return None; + } + let tokens = raw.tokens?; + let model = raw.model_id.unwrap_or_default(); + + let mut usage = UsageAccumulator { + input_tokens: tokens.input, + cache_creation_input_tokens: tokens.cache.write, + cache_read_input_tokens: tokens.cache.read, + // opencode reports reasoning separately; fold it into output like its own stats. + output_tokens: tokens.output + tokens.reasoning, + reasoning_output_tokens: tokens.reasoning, + cost_usd: 0.0, + }; + + usage.cost_usd = match cost_mode { + OpencodeCostMode::Logged => raw.cost, + OpencodeCostMode::Estimated => pricing.estimate_cost(&model, usage).unwrap_or(0.0), + }; + + let created_ms = raw.time.map(|t| t.created).unwrap_or(0); + + Some(UsageEvent { + timestamp: ms_to_utc(created_ms), + source: SourceKind::Opencode, + model, + session: session_id.to_string(), + project: raw.path.and_then(|p| p.cwd).filter(|s| !s.is_empty()), + file_path: String::new(), + usage, + }) +} + +fn ms_to_utc(ms: i64) -> DateTime { + Utc.timestamp_millis_opt(ms) + .single() + .unwrap_or_else(|| Utc.timestamp_opt(0, 0).unwrap()) +} + +/// Read all assistant usage events from an opencode SQLite database. +/// +/// Opens the database read-only so a running opencode instance (holding a WAL +/// lock) does not block reads. On any open/query error, emits a warning to +/// stderr and returns an empty vec — the source is skipped, never fatal. +pub(crate) fn read_opencode_db( + db_path: &Path, + filter: DateFilter, + timezone: &TimeZoneMode, + pricing: &PricingTable, + cost_mode: OpencodeCostMode, +) -> Vec { + use rusqlite::OpenFlags; + + let conn = match rusqlite::Connection::open_with_flags( + db_path, + OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI, + ) { + Ok(c) => c, + Err(e) => { + eprintln!( + "tokenusage: skipping opencode DB {}: {e}", + db_path.display() + ); + return Vec::new(); + } + }; + + let mut stmt = match conn.prepare("SELECT session_id, data FROM message") { + Ok(s) => s, + Err(e) => { + eprintln!( + "tokenusage: opencode query failed on {}: {e}", + db_path.display() + ); + return Vec::new(); + } + }; + + let rows = match stmt.query_map([], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + }) { + Ok(r) => r, + Err(e) => { + eprintln!( + "tokenusage: opencode row read failed on {}: {e}", + db_path.display() + ); + return Vec::new(); + } + }; + + let mut events = Vec::new(); + for (session_id, data) in rows.flatten() { + if let Some(event) = map_opencode_message(&session_id, &data, pricing, cost_mode) { + if filter.allows(timezone.date_of(event.timestamp)) { + events.push(event); + } + } + } + events +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::PricingTable; + + const SAMPLE: &str = r#"{"role":"assistant","cost":0.25,"tokens":{"total":9132,"input":8855,"output":236,"reasoning":41,"cache":{"write":10,"read":20}},"modelID":"gpt-5","providerID":"opencode","time":{"created":1783207039387,"completed":1783207087073},"path":{"cwd":"/home/u/proj","root":"/home/u/proj"}}"#; + + #[test] + fn maps_tokens_into_buckets() { + let pricing = PricingTable::default(); + let ev = map_opencode_message("ses_1", SAMPLE, &pricing, OpencodeCostMode::Estimated) + .expect("assistant message maps to an event"); + assert_eq!(ev.source, SourceKind::Opencode); + assert_eq!(ev.usage.input_tokens, 8855); + assert_eq!(ev.usage.cache_creation_input_tokens, 10); + assert_eq!(ev.usage.cache_read_input_tokens, 20); + assert_eq!(ev.usage.output_tokens, 236 + 41); + assert_eq!(ev.usage.reasoning_output_tokens, 41); + assert_eq!(ev.session, "ses_1"); + assert_eq!(ev.project.as_deref(), Some("/home/u/proj")); + assert_eq!(ev.model, "gpt-5"); + } + + #[test] + fn logged_cost_mode_uses_message_cost() { + let pricing = PricingTable::default(); + let ev = map_opencode_message("s", SAMPLE, &pricing, OpencodeCostMode::Logged).unwrap(); + assert!((ev.usage.cost_usd - 0.25).abs() < 1e-9); + } + + #[test] + fn skips_non_assistant() { + let pricing = PricingTable::default(); + let user = r#"{"role":"user","tokens":{"input":1,"output":0,"reasoning":0,"cache":{"read":0,"write":0}},"modelID":"x","time":{"created":1}}"#; + assert!(map_opencode_message("s", user, &pricing, OpencodeCostMode::Estimated).is_none()); + } + + #[test] + fn skips_bad_json() { + let pricing = PricingTable::default(); + assert!( + map_opencode_message("s", "{not json", &pricing, OpencodeCostMode::Estimated).is_none() + ); + } +} diff --git a/src/pipeline/parsing.rs b/src/pipeline/parsing.rs index 9d59eb1..2d0dfe7 100644 --- a/src/pipeline/parsing.rs +++ b/src/pipeline/parsing.rs @@ -127,7 +127,7 @@ pub(super) fn parse_files_with_cache( } if sort_events { - events.sort_by(|a, b| a.timestamp.cmp(&b.timestamp)); + events.sort_by_key(|e| e.timestamp); } ParsedUsageOutput { @@ -222,6 +222,7 @@ impl LiveUsageRuntime { last_sources_refresh_at: now, sources_refresh_interval: Duration::from_secs(60), last_cache_flush_at: now, + opencode_cost: common.opencode_cost, }) } @@ -280,7 +281,7 @@ impl LiveUsageRuntime { pub(super) fn load(&mut self, timezone: &TimeZoneMode) -> LoadedUsage { self.maybe_refresh_discovery(); - let parsed = parse_files_with_cache( + let mut parsed = parse_files_with_cache( &self.files_cache, &mut self.cache_store, ParseFilesConfig { @@ -294,6 +295,13 @@ impl LiveUsageRuntime { ); self.cache_dirty |= parsed.cache_dirty; self.flush_cache(false); + parsed.loaded.events.extend(collect_opencode_events( + &self.sources, + self.filter, + timezone, + &self.pricing, + self.opencode_cost, + )); parsed.loaded } @@ -344,7 +352,10 @@ pub(super) async fn load_usage( cache_store = IncrementalCacheStore::new(pricing_key); } - let parsed = parse_files_with_cache( + let opencode_events = + collect_opencode_events(&sources, filter, timezone, &pricing, common.opencode_cost); + + let mut parsed = parse_files_with_cache( &files, &mut cache_store, ParseFilesConfig { @@ -363,9 +374,37 @@ pub(super) async fn load_usage( save_incremental_cache(path, &cache_store); } + parsed.loaded.events.extend(opencode_events); Ok(parsed.loaded) } +/// Read all configured opencode sources into events. Empty when opencode is +/// disabled or no DB is present. +pub(super) fn collect_opencode_events( + sources: &[SourceConfig], + filter: DateFilter, + timezone: &TimeZoneMode, + pricing: &PricingTable, + cost_mode: crate::cli::OpencodeCostModeArg, +) -> Vec { + use crate::pipeline::opencode::{OpencodeCostMode, read_opencode_db}; + + let cost_mode = match cost_mode { + crate::cli::OpencodeCostModeArg::Logged => OpencodeCostMode::Logged, + crate::cli::OpencodeCostModeArg::Estimated => OpencodeCostMode::Estimated, + }; + + let mut events = Vec::new(); + for source in sources.iter().filter(|s| s.kind == SourceKind::Opencode) { + for db_path in &source.roots { + events.extend(read_opencode_db( + db_path, filter, timezone, pricing, cost_mode, + )); + } + } + events +} + pub(super) async fn build_sources(common: &CommonArgs) -> Result> { let home = dirs::home_dir().context("Failed to resolve home directory")?; @@ -425,9 +464,58 @@ pub(super) async fn build_sources(common: &CommonArgs) -> Result) -> Vec { + let mut seen = HashSet::new(); + let mut out = Vec::new(); + + for path in input { + let normalized = normalize_path(&path); + if !seen.insert(normalized.clone()) { + continue; + } + + if let Ok(meta) = fs::metadata(&normalized).await + && meta.is_file() + { + out.push(normalized); + } + } + + out +} + pub(super) async fn filter_existing_dirs(input: Vec) -> Vec { let mut seen = HashSet::new(); let mut out = Vec::new(); @@ -455,6 +543,8 @@ pub(super) fn discover_files( ) -> Vec { let mut files: Vec = sources .par_iter() + // opencode is read directly from its SQLite DB, not walked as JSONL files. + .filter(|source| source.kind != SourceKind::Opencode) .flat_map_iter(|source| { source.roots.iter().flat_map(move |root| { discover_files_in_root(source.kind, root, ignore_rules, filter) @@ -634,6 +724,7 @@ pub(super) fn parse_single_file( SourceKind::Claude => { claude_state = ClaudeDedupeState::with_seed(base_cache.claude_recent_keys); } + SourceKind::Opencode => unreachable!("opencode uses the DB reader path"), } } else { let _ = reader.seek(SeekFrom::Start(0)); @@ -666,6 +757,7 @@ pub(super) fn parse_single_file( let parsed = match job.file.source { SourceKind::Claude => parse_claude_usage_line(&line, pricing, &mut claude_state), SourceKind::Codex => parse_codex_usage_line(&line, &mut codex_state, pricing), + SourceKind::Opencode => unreachable!("opencode uses the DB reader path"), }; let mut parsed = match parsed { @@ -753,6 +845,7 @@ pub(super) fn should_skip_parse_by_line_prefix(source: SourceKind, line: &str) - SourceKind::Claude => { !(line.contains("\"type\":\"assistant\"") || line.contains("\"type\": \"assistant\"")) } + SourceKind::Opencode => unreachable!("opencode uses the DB reader path"), } } @@ -780,6 +873,8 @@ pub(super) fn derive_session_meta(file: &DiscoveredFile) -> (String, Option raw_project, }; (session, project) @@ -889,6 +984,7 @@ pub(super) fn extract_session_title(source: SourceKind, path: &Path) -> String { } } } + SourceKind::Opencode => unreachable!("opencode uses the DB reader path"), } } String::new() @@ -1191,6 +1287,8 @@ pub(super) fn extract_model(value: &Value, source: SourceKind) -> Option "payload.model", "model", ][..], + // opencode extracts its model directly from the DB row, not JSONL. + SourceKind::Opencode => &[][..], }; extract_string(value, candidate_paths) @@ -1517,7 +1615,7 @@ pub(super) fn pricing_cache_key(pricing: &PricingTable) -> String { out.push('|'); let mut exact = pricing.exact.iter().collect::>(); - exact.sort_by(|(a, _), (b, _)| a.cmp(b)); + exact.sort_by_key(|(k, _)| (*k).clone()); for (model, rate) in exact { out.push_str(model); out.push(':'); diff --git a/src/pipeline/tests.rs b/src/pipeline/tests.rs index f6e82c3..5936482 100644 --- a/src/pipeline/tests.rs +++ b/src/pipeline/tests.rs @@ -286,3 +286,63 @@ fn parse_antigravity_error_code_rejects_nonzero() { }); assert!(parse_antigravity_user_status(&json).is_err()); } + +#[test] +fn opencode_db_events_flow_into_usage() { + use super::opencode::{OpencodeCostMode, read_opencode_db}; + use crate::types::{DateFilter, PricingTable}; + use std::fs; + + // Unique scratch path (no tempfile crate dependency). + let dir = std::env::temp_dir().join(format!("tu-opencode-test-{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + let db = dir.join("opencode.db"); + + { + let conn = rusqlite::Connection::open(&db).unwrap(); + conn.execute_batch( + "CREATE TABLE message ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + time_created INTEGER, + time_updated INTEGER, + data TEXT NOT NULL + );", + ) + .unwrap(); + conn.execute( + "INSERT INTO message VALUES ('m1','ses1',1,1, ?1)", + [r#"{"role":"assistant","cost":0.1,"tokens":{"input":100,"output":10,"reasoning":2,"cache":{"read":5,"write":3}},"modelID":"gpt-5","time":{"created":1783207039387}}"#], + ) + .unwrap(); + // A non-assistant row must be ignored. + conn.execute( + "INSERT INTO message VALUES ('m2','ses1',2,2, ?1)", + [r#"{"role":"user","time":{"created":1783207039400}}"#], + ) + .unwrap(); + } + + let pricing = PricingTable::default(); + let filter = DateFilter { + since: None, + until: None, + }; + let events = read_opencode_db( + &db, + filter, + &TimeZoneMode::Utc, + &pricing, + OpencodeCostMode::Logged, + ); + + let _ = fs::remove_dir_all(&dir); + + assert_eq!(events.len(), 1); + assert_eq!(events[0].source, SourceKind::Opencode); + assert_eq!(events[0].usage.input_tokens, 100); + assert_eq!(events[0].usage.output_tokens, 12); + assert_eq!(events[0].usage.reasoning_output_tokens, 2); + assert!((events[0].usage.cost_usd - 0.1).abs() < 1e-9); +} diff --git a/src/pipeline/top.rs b/src/pipeline/top.rs index 2fc3d7c..d7550f4 100644 --- a/src/pipeline/top.rs +++ b/src/pipeline/top.rs @@ -240,7 +240,7 @@ pub(crate) async fn run_top(args: TopArgs) -> Result<()> { .unwrap_or(std::cmp::Ordering::Equal) }), TopSortKey::OutputTokens => { - new_sessions.sort_by(|a, b| b.output_tokens_total.cmp(&a.output_tokens_total)) + new_sessions.sort_by_key(|s| std::cmp::Reverse(s.output_tokens_total)) } TopSortKey::Rate => new_sessions.sort_by(|a, b| { let ra = rates.get(&a.rate_key).copied().unwrap_or(0.0); @@ -248,7 +248,7 @@ pub(crate) async fn run_top(args: TopArgs) -> Result<()> { rb.partial_cmp(&ra).unwrap_or(std::cmp::Ordering::Equal) }), TopSortKey::Recent => { - new_sessions.sort_by(|a, b| b.last_activity.cmp(&a.last_activity)) + new_sessions.sort_by_key(|s| std::cmp::Reverse(s.last_activity)) } } @@ -412,6 +412,7 @@ pub(super) fn render_top_frame( let source_color = match s.source { SourceKind::Claude => TuiColor::Cyan, SourceKind::Codex => TuiColor::Blue, + SourceKind::Opencode => TuiColor::Magenta, }; let project = s.project.as_deref().unwrap_or("-"); let project_short = if project.len() > 20 { diff --git a/src/types.rs b/src/types.rs index 78c1562..c8569f1 100644 --- a/src/types.rs +++ b/src/types.rs @@ -12,21 +12,25 @@ use serde::{Deserialize, Serialize}; /// /// # Serialisation /// -/// Serialises to / deserialises from `"Claude"` or `"Codex"` (PascalCase). +/// Serialises to / deserialises from `"Claude"`, `"Codex"`, or `"Opencode"` +/// (PascalCase). #[derive(Debug, Clone, Copy, Eq, PartialEq, Hash, Ord, PartialOrd, Serialize, Deserialize)] pub enum SourceKind { /// Anthropic Claude Code (`~/.claude/projects/*/`). Claude, /// OpenAI Codex CLI (`~/.codex/sessions/`). Codex, + /// opencode (`~/.local/share/opencode/opencode.db`). + Opencode, } impl SourceKind { - /// Lowercase string label — `"claude"` or `"codex"`. + /// Lowercase string label — `"claude"`, `"codex"`, or `"opencode"`. pub fn as_str(self) -> &'static str { match self { SourceKind::Claude => "claude", SourceKind::Codex => "codex", + SourceKind::Opencode => "opencode", } } } @@ -437,7 +441,7 @@ impl PricingTable { table .prefixes - .sort_by(|(a, _), (b, _)| b.len().cmp(&a.len())); + .sort_by_key(|(p, _)| std::cmp::Reverse(p.len())); table } @@ -675,3 +679,17 @@ impl TableLayout { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn opencode_source_kind_labels() { + assert_eq!(SourceKind::Opencode.as_str(), "opencode"); + assert_eq!( + serde_json::to_string(&SourceKind::Opencode).unwrap(), + "\"Opencode\"" + ); + } +}