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
53 changes: 53 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
13 changes: 10 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ tu --tui # same report in terminal UI
# Source-specific
tu codex
tu claude
tu opencode
tu antigravity

# Date filter
Expand All @@ -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)
Expand Down Expand Up @@ -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?

Expand Down Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions src/activity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,7 @@ fn display_source_label(source: SourceKind) -> &'static str {
match source {
SourceKind::Claude => "Claude",
SourceKind::Codex => "Codex",
SourceKind::Opencode => "opencode",
}
}

Expand Down
12 changes: 12 additions & 0 deletions src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,15 @@ pub struct Config {
/// Each string should be an absolute path. Empty = use defaults.
pub codex_sessions_dir: Vec<String>,

/// 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<String>,

/// Path substring ignore rules.
///
/// Any discovered file whose absolute path contains one of these
Expand Down Expand Up @@ -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,
Expand Down
42 changes: 41 additions & 1 deletion src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down Expand Up @@ -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")
Expand All @@ -149,6 +163,21 @@ pub struct CommonArgs {
arg(long = "codex-sessions-dir", help = "Codex sessions dir, repeatable")
)]
pub codex_sessions_dir: Vec<String>,
#[cfg_attr(
feature = "cli",
arg(long = "opencode-db", help = "opencode DB file, repeatable")
)]
pub opencode_db: Vec<String>,
#[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(
Expand Down Expand Up @@ -215,6 +244,7 @@ pub(crate) enum Commands {
Heartbeat(HeartbeatArgs),
Codex(DailyArgs),
Claude(DailyArgs),
Opencode(DailyArgs),
Antigravity(AntigravityArgs),
Monthly(MonthlyArgs),
#[command(alias = "week")]
Expand Down Expand Up @@ -627,7 +657,7 @@ pub(crate) fn normalize_cli_args(mut argv: Vec<String>) -> Vec<String> {
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,
Expand Down Expand Up @@ -665,6 +695,16 @@ fn normalize_live_shortcuts(argv: &mut Vec<String>) {
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");
Expand Down
30 changes: 28 additions & 2 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -54,10 +54,16 @@ struct CommonConfig {
no_codex: Option<bool>,
#[serde(alias = "noAntigravity")]
no_antigravity: Option<bool>,
#[serde(alias = "noOpencode")]
no_opencode: Option<bool>,
#[serde(alias = "claudeProjectsDir")]
claude_projects_dir: Option<Vec<String>>,
#[serde(alias = "codexSessionsDir")]
codex_sessions_dir: Option<Vec<String>>,
#[serde(alias = "opencodeDb")]
opencode_db: Option<Vec<String>>,
#[serde(alias = "opencodeCost")]
opencode_cost: Option<OpencodeCostModeArg>,
#[serde(alias = "ignorePath")]
ignore_path: Option<Vec<String>>,
#[serde(alias = "noDefaultIgnores")]
Expand Down Expand Up @@ -250,6 +256,14 @@ pub(crate) fn apply_config(command: Commands) -> Result<Commands> {
}
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());
Expand Down Expand Up @@ -338,6 +352,7 @@ fn resolve_config_path(command: &Commands) -> Result<Option<PathBuf>> {
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(),
Expand Down Expand Up @@ -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()
{
Expand All @@ -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()
{
Expand Down
12 changes: 11 additions & 1 deletion src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/pipeline/activity_report.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}
}

Expand Down
2 changes: 1 addition & 1 deletion src/pipeline/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading