Skip to content
371 changes: 371 additions & 0 deletions crates/tokscale-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,61 @@ enum Commands {
#[arg(long, help = "Disable spinner")]
no_spinner: bool,
},
#[command(about = "Show hourly usage report")]
Hourly {
#[arg(long)]
json: bool,
#[arg(long)]
light: bool,
#[arg(long, help = "Show only OpenCode usage")]
opencode: bool,
#[arg(long, help = "Show only Claude Code usage")]
claude: bool,
#[arg(long, help = "Show only Codex CLI usage")]
codex: bool,
#[arg(long, help = "Show only Gemini CLI usage")]
gemini: bool,
#[arg(long, help = "Show only Cursor IDE usage")]
cursor: bool,
#[arg(long, help = "Show only Amp usage")]
amp: bool,
#[arg(long, help = "Show only Droid usage")]
droid: bool,
#[arg(long, help = "Show only OpenClaw usage")]
openclaw: bool,
#[arg(long, help = "Show only Pi usage")]
pi: bool,
#[arg(long, help = "Show only Kimi CLI usage")]
kimi: bool,
#[arg(long, help = "Show only Qwen CLI usage")]
qwen: bool,
#[arg(long, help = "Show only Roo Code usage")]
roocode: bool,
#[arg(long, help = "Show only KiloCode usage")]
kilocode: bool,
#[arg(long, help = "Show only Kilo CLI usage")]
kilo: bool,
#[arg(long, help = "Show only Mux usage")]
mux: bool,
#[arg(long, help = "Show only Synthetic usage")]
synthetic: bool,
#[arg(long, help = "Show only today's usage")]
today: bool,
#[arg(long, help = "Show last 7 days")]
week: bool,
#[arg(long, help = "Show current month")]
month: bool,
#[arg(long, help = "Start date (YYYY-MM-DD)")]
since: Option<String>,
#[arg(long, help = "End date (YYYY-MM-DD)")]
until: Option<String>,
#[arg(long, help = "Filter by year (YYYY)")]
year: Option<String>,
#[arg(long, help = "Show processing time")]
benchmark: bool,
#[arg(long, help = "Disable spinner")]
no_spinner: bool,
},
#[command(about = "Show pricing for a model")]
Pricing {
model_id: String,
Expand Down Expand Up @@ -684,6 +739,68 @@ fn main() -> Result<()> {
)
}
}
Some(Commands::Hourly {
json,
light,
opencode,
claude,
codex,
gemini,
cursor,
amp,
droid,
openclaw,
pi,
kimi,
qwen,
roocode,
kilocode,
kilo,
mux,
synthetic,
today,
week,
month,
since,
until,
year,
benchmark,
no_spinner,
}) => {
let clients = build_client_filter(ClientFlags {
opencode,
claude,
codex,
gemini,
cursor,
amp,
droid,
openclaw,
pi,
kimi,
qwen,
roocode,
kilocode,
kilo,
mux,
synthetic,
});
let (since, until) = build_date_filter(today, week, month, since, until);
let year = normalize_year_filter(today, week, month, year);
run_hourly_report(
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Hourly subcommand bypasses the existing TUI/light dispatch logic, so tokscale hourly can never open the TUI in interactive mode and the --light flag is ignored (passed as _light_or_json but unused).

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At crates/tokscale-cli/src/main.rs, line 790:

<comment>Hourly subcommand bypasses the existing TUI/light dispatch logic, so `tokscale hourly` can never open the TUI in interactive mode and the `--light` flag is ignored (passed as `_light_or_json` but unused).</comment>

<file context>
@@ -684,6 +739,68 @@ fn main() -> Result<()> {
+            });
+            let (since, until) = build_date_filter(today, week, month, since, until);
+            let year = normalize_year_filter(today, week, month, year);
+            run_hourly_report(
+                json || light,
+                json,
</file context>
Fix with Cubic

json || light,
json,
clients,
since,
until,
year,
benchmark,
no_spinner || !can_use_tui,
today,
week,
month,
)
}
Some(Commands::Pricing {
model_id,
json,
Expand Down Expand Up @@ -1920,6 +2037,260 @@ fn run_monthly_report(
Ok(())
}

fn run_hourly_report(
_light_or_json: bool,
json: bool,
clients: Option<Vec<String>>,
since: Option<String>,
until: Option<String>,
year: Option<String>,
benchmark: bool,
no_spinner: bool,
today: bool,
week: bool,
month_flag: bool,
) -> Result<()> {
use std::time::Instant;
use tokio::runtime::Runtime;
use tokscale_core::{get_hourly_report, GroupBy, ReportOptions};

let date_range = get_date_range_label(today, week, month_flag, &since, &until, &year);

let spinner = if no_spinner {
None
} else {
Some(LightSpinner::start("Scanning session data..."))
};
let start = Instant::now();
let rt = Runtime::new()?;
let report = rt
.block_on(async {
get_hourly_report(ReportOptions {
home_dir: None,
clients,
since,
until,
year,
group_by: GroupBy::default(),
})
.await
})
.map_err(|e| anyhow::anyhow!(e))?;

if let Some(spinner) = spinner {
spinner.stop();
}

let processing_time_ms = start.elapsed().as_millis();

if json {
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct HourlyUsageJson {
hour: String,
clients: Vec<String>,
models: Vec<String>,
input: i64,
output: i64,
cache_read: i64,
cache_write: i64,
message_count: i32,
turn_count: i32,
cost: f64,
}

#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct HourlyReportJson {
entries: Vec<HourlyUsageJson>,
total_cost: f64,
processing_time_ms: u32,
}

let output = HourlyReportJson {
entries: report
.entries
.into_iter()
.map(|e| HourlyUsageJson {
hour: e.hour,
clients: e.clients,
models: e.models,
input: e.input,
output: e.output,
cache_read: e.cache_read,
cache_write: e.cache_write,
message_count: e.message_count,
turn_count: e.turn_count,
cost: e.cost,
})
.collect(),
total_cost: report.total_cost,
processing_time_ms: report.processing_time_ms,
};

println!("{}", serde_json::to_string_pretty(&output)?);
} else {
use comfy_table::{Cell, CellAlignment, Color, ContentArrangement, Table};

let term_width = crossterm::terminal::size()
.map(|(w, _)| w as usize)
.unwrap_or(120);
let compact = term_width < 100;

let mut table = Table::new();
table.load_preset(TABLE_PRESET);
let arrangement = if std::io::stdout().is_terminal() {
ContentArrangement::DynamicFullWidth
} else {
ContentArrangement::Dynamic
};
table.set_content_arrangement(arrangement);
table.enforce_styling();

if compact {
table.set_header(vec![
Cell::new("Hour").fg(Color::Cyan),
Cell::new("Source").fg(Color::Cyan),
Cell::new("Turn").fg(Color::Cyan),
Cell::new("Msgs").fg(Color::Cyan),
Cell::new("Input").fg(Color::Cyan),
Cell::new("Output").fg(Color::Cyan),
Cell::new("Cost").fg(Color::Cyan),
]);

for entry in &report.entries {
let clients_col = {
let mut c: Vec<String> = entry.clients.iter().map(|s| capitalize_client(s)).collect();
c.sort();
c.join(", ")
};
let turn_display = if entry.turn_count > 0 {
entry.turn_count.to_string()
} else {
"—".to_string()
};
table.add_row(vec![
Cell::new(&entry.hour).fg(Color::White),
Cell::new(&clients_col),
Cell::new(&turn_display).set_alignment(CellAlignment::Right),
Cell::new(entry.message_count).set_alignment(CellAlignment::Right),
Cell::new(format_tokens_with_commas(entry.input))
.set_alignment(CellAlignment::Right),
Cell::new(format_tokens_with_commas(entry.output))
.set_alignment(CellAlignment::Right),
Cell::new(format_currency(entry.cost))
.fg(Color::Green)
.set_alignment(CellAlignment::Right),
]);
}
} else {
table.set_header(vec![
Cell::new("Hour").fg(Color::Cyan),
Cell::new("Source").fg(Color::Cyan),
Cell::new("Models").fg(Color::Cyan),
Cell::new("Turn").fg(Color::Cyan),
Cell::new("Msgs").fg(Color::Cyan),
Cell::new("Input").fg(Color::Cyan),
Cell::new("Output").fg(Color::Cyan),
Cell::new("Cache R").fg(Color::Cyan),
Cell::new("Cache W").fg(Color::Cyan),
Cell::new("Cache×").fg(Color::Cyan),
Cell::new("Cost").fg(Color::Cyan),
]);

for entry in &report.entries {
let clients_col = {
let mut c: Vec<String> = entry.clients.iter().map(|s| capitalize_client(s)).collect();
c.sort();
c.join(", ")
};
let models_col = if entry.models.is_empty() {
"-".to_string()
} else {
let mut unique: Vec<String> = entry
.models
.iter()
.map(|m| format_model_name(m))
.collect::<std::collections::BTreeSet<_>>()
.into_iter()
.collect();
unique.sort();
unique.join(", ")
};

let cache_hit = {
let paid = (entry.input as u64).saturating_add(entry.cache_write as u64);
if paid == 0 {
if entry.cache_read > 0 { "∞".to_string() } else { "—".to_string() }
} else {
format!("{:.1}x", entry.cache_read as f64 / paid as f64)
}
};

let turn_display = if entry.turn_count > 0 {
entry.turn_count.to_string()
} else {
"—".to_string()
};

table.add_row(vec![
Cell::new(&entry.hour).fg(Color::White),
Cell::new(&clients_col),
Cell::new(&models_col),
Cell::new(&turn_display).set_alignment(CellAlignment::Right),
Cell::new(entry.message_count).set_alignment(CellAlignment::Right),
Cell::new(format_tokens_with_commas(entry.input))
.set_alignment(CellAlignment::Right),
Cell::new(format_tokens_with_commas(entry.output))
.set_alignment(CellAlignment::Right),
Cell::new(format_tokens_with_commas(entry.cache_read))
.set_alignment(CellAlignment::Right),
Cell::new(format_tokens_with_commas(entry.cache_write))
.set_alignment(CellAlignment::Right),
Cell::new(&cache_hit)
.fg(Color::Cyan)
.set_alignment(CellAlignment::Right),
Cell::new(format_currency(entry.cost))
.fg(Color::Green)
.set_alignment(CellAlignment::Right),
]);
}
}

// Title
use colored::Colorize;
let title = if let Some(ref range) = date_range {
format!("Hourly Usage ({})", range)
} else {
"Hourly Usage".to_string()
};
println!("\n {}\n", title.bold());

// Table
let table_str = table.to_string();
println!("{}", dim_borders(&table_str));

// Footer with total
println!(
"\n {} {}",
"Total:".bold(),
format_currency(report.total_cost)
.green()
.bold()
.to_string()
);

if benchmark {
println!(
"{}",
format!(" Processing time: {}ms (Rust native)", processing_time_ms).bright_black()
);
}
}

Ok(())
}

fn run_wrapped_command(
output: Option<String>,
year: Option<String>,
Expand Down
Loading
Loading