From c6edc1b5204e8f689026e15bc1f3050a6b3d88d1 Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Wed, 7 Oct 2026 11:58:09 +0000 Subject: [PATCH] maple-agent: cut the Usage page's local tables and "Est. cost" The Usage page keeps the plan meter from the billing API and drops the local tables it computed from Goose's usage ledger: the "Turns", "Sessions", "Total tokens" and "Est. cost" stats, the "By model" and "Recent sessions" tables, and with them `load_usage`, `usage_from_ledger`, `UsageRow`, `UsageSummary`, the page's ledger read on open, and the `stat`, `usage_table` and `format_tokens` helpers. That was one of the app's two SQL reads of Goose's database and the only place that attributed subagent rows to their parent task. Co-Authored-By: Claude Fable 5.1 --- apps/maple-agent/README.md | 4 +- apps/maple-agent/app/src/settings.rs | 149 ------------------------ apps/maple-agent/app/src/ui/settings.rs | 128 +------------------- 3 files changed, 3 insertions(+), 278 deletions(-) diff --git a/apps/maple-agent/README.md b/apps/maple-agent/README.md index 0be0efeae..b0df630e8 100644 --- a/apps/maple-agent/README.md +++ b/apps/maple-agent/README.md @@ -108,8 +108,8 @@ clients for embedded CUA. Research has an independent dependency graph. - Settings: General (web tools, appearance, tool call details, desktop notifications, tool call summaries, composer Vim, application Vim, and the speech voice and speed), Keyboard Shortcuts, System prompt, Integrations - (detected built-ins and custom MCP servers), Usage (plan meter from the - billing API plus totals from the Goose ledger), and About. + (detected built-ins and custom MCP servers), Usage (the plan meter from + the billing API), and About. - Dark and light themes; the default follows the system. - Billing status from the Maple billing API. - Desktop notifications when a task finishes or asks a question while the diff --git a/apps/maple-agent/app/src/settings.rs b/apps/maple-agent/app/src/settings.rs index 32519270e..35cc5f79c 100644 --- a/apps/maple-agent/app/src/settings.rs +++ b/apps/maple-agent/app/src/settings.rs @@ -354,159 +354,10 @@ pub fn update_settings_and_wait(update: impl FnOnce(&mut AppSettings) + Send + ' let _ = rx.recv(); } -/// One aggregated usage row: per session or per model. -#[derive(Debug, Clone, Default)] -pub struct UsageRow { - pub label: String, - pub sessions: u64, - pub turns: u64, - pub total_tokens: i64, - pub cost: f64, -} - -#[derive(Debug, Clone, Default)] -pub struct UsageSummary { - pub totals: UsageRow, - pub by_model: Vec, - pub by_session: Vec, -} - -/// Read usage totals from the goose usage ledger for one account scope. -pub fn load_usage(account_scope: &str) -> UsageSummary { - let db = crate::backend::account_session_db(account_scope); - let Some(conn) = crate::backend::open_session_db_read_only(&db) else { - return UsageSummary::default(); - }; - usage_from_ledger(&conn) -} - -/// Aggregate one account's ledger. -/// -/// A subagent has a session of its own, and its provider calls land in -/// the ledger under it. Every row counts against the task that delegated -/// the work, so the reader sees what a task cost in total. Goose refuses -/// a subagent of a subagent, so resolving one parent is enough. -fn usage_from_ledger(conn: &rusqlite::Connection) -> UsageSummary { - let mut summary = UsageSummary::default(); - - if let Ok(mut stmt) = conn.prepare( - "SELECT COUNT(*), COALESCE(SUM(total_tokens),0), COALESCE(SUM(cost),0) \ - FROM usage_ledger", - ) && let Ok(row) = stmt.query_row([], |row| { - Ok(( - row.get::<_, i64>(0)?, - row.get::<_, i64>(1)?, - row.get::<_, f64>(2)?, - )) - }) { - summary.totals = UsageRow { - label: "All activity".to_string(), - sessions: 0, - turns: row.0.max(0) as u64, - total_tokens: row.1, - cost: row.2, - }; - } - - if let Ok(mut stmt) = conn.prepare( - "SELECT u.model, COUNT(DISTINCT COALESCE(s.parent_session_id, u.session_id)), COUNT(*), \ - COALESCE(SUM(u.total_tokens),0), COALESCE(SUM(u.cost),0) \ - FROM usage_ledger u LEFT JOIN sessions s ON s.id = u.session_id \ - GROUP BY u.model ORDER BY SUM(u.total_tokens) DESC", - ) && let Ok(rows) = stmt.query_map([], |row| { - Ok(UsageRow { - label: row - .get::<_, Option>(0)? - .unwrap_or_else(|| "unknown".into()), - sessions: row.get::<_, i64>(1)?.max(0) as u64, - turns: row.get::<_, i64>(2)?.max(0) as u64, - total_tokens: row.get::<_, i64>(3)?, - cost: row.get::<_, f64>(4)?, - }) - }) { - for row in rows.flatten() { - summary.totals.sessions += row.sessions; - summary.by_model.push(row); - } - } - - if let Ok(mut stmt) = conn.prepare( - "SELECT COALESCE(parent.name, s.name), COALESCE(s.parent_session_id, u.session_id) AS task, \ - COUNT(*), COALESCE(SUM(u.total_tokens),0), COALESCE(SUM(u.cost),0) \ - FROM usage_ledger u JOIN sessions s ON s.id = u.session_id \ - LEFT JOIN sessions parent ON parent.id = s.parent_session_id \ - GROUP BY task ORDER BY MAX(u.created_timestamp) DESC LIMIT 20", - ) && let Ok(rows) = stmt.query_map([], |row| { - Ok(UsageRow { - label: { - let name: String = row.get::<_, Option>(0)?.unwrap_or_default(); - let id: String = row.get(1)?; - if name.trim().is_empty() { id } else { name } - }, - sessions: 1, - turns: row.get::<_, i64>(2)?.max(0) as u64, - total_tokens: row.get::<_, i64>(3)?, - cost: row.get::<_, f64>(4)?, - }) - }) { - for row in rows.flatten() { - summary.by_session.push(row); - } - } - - summary -} - #[cfg(test)] mod tests { use super::*; - /// A subagent bills to the task that delegated the work, so the - /// usage screen shows one row per task and not one per subagent. - #[test] - fn subagent_usage_counts_against_its_parent_task() { - let conn = rusqlite::Connection::open_in_memory().unwrap(); - conn.execute_batch( - "CREATE TABLE sessions ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL DEFAULT '', - parent_session_id TEXT - ); - CREATE TABLE usage_ledger ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - session_id TEXT NOT NULL, - created_timestamp INTEGER NOT NULL, - model TEXT, - total_tokens INTEGER, - cost REAL - ); - INSERT INTO sessions VALUES ('task-1', 'Review the parser', NULL); - INSERT INTO sessions VALUES ('sub-1', 'Delegated task', 'task-1'); - INSERT INTO sessions VALUES ('task-2', 'Other work', NULL); - INSERT INTO usage_ledger (session_id, created_timestamp, model, total_tokens, cost) - VALUES ('task-1', 10, 'maple-1', 100, 1.0), - ('sub-1', 20, 'maple-1', 400, 4.0), - ('task-2', 30, 'maple-1', 700, 7.0);", - ) - .unwrap(); - - let usage = usage_from_ledger(&conn); - let rows = usage - .by_session - .iter() - .map(|row| (row.label.as_str(), row.turns, row.total_tokens)) - .collect::>(); - assert_eq!( - rows, - vec![("Other work", 1, 700), ("Review the parser", 2, 500)], - "the subagent's tokens belong to the task that delegated them" - ); - // Two tasks ran, not three sessions. - assert_eq!(usage.by_model.len(), 1); - assert_eq!(usage.by_model[0].sessions, 2); - assert_eq!(usage.totals.total_tokens, 1200); - } - /// A settings file written while the app still saved a permission mode /// and a shortcut for the approval card loads as it is; the next save /// drops the mode, and the retired shortcut slot is ignored at run time diff --git a/apps/maple-agent/app/src/ui/settings.rs b/apps/maple-agent/app/src/ui/settings.rs index 24e53b1ec..ee08ebc68 100644 --- a/apps/maple-agent/app/src/ui/settings.rs +++ b/apps/maple-agent/app/src/ui/settings.rs @@ -20,7 +20,7 @@ use crate::ui::popup::{Menu, MenuItem, Placement, Popup}; use crate::ui::text_input::TextInput; use crate::backend::AgentBackend; -use crate::settings::{self, AppSettings, UsageSummary}; +use crate::settings::{self, AppSettings}; use crate::shortcuts::{ ShortcutConflict, ShortcutConflictKind, ShortcutContextOverlap, ShortcutOverrides, ShortcutSnapshot, @@ -132,7 +132,6 @@ pub struct SettingsScreen { account: AccountState, billing: BillingState, api_keys: ApiKeysState, - usage: Option, /// Plan usage meter, same source as the sidebar card. plan: Option, /// Account MCP servers; None until loaded. @@ -292,7 +291,6 @@ impl SettingsScreen { account: AccountState::new(application_vim_enabled, application_focus.clone(), cx), billing: BillingState::new(), api_keys: ApiKeysState::new(application_vim_enabled, application_focus.clone(), cx), - usage: None, plan: None, mcp_servers: None, mcp_editor: None, @@ -321,7 +319,6 @@ impl SettingsScreen { this.load_account(cx); this.load_billing(cx); this.load_api_keys(cx); - this.load_usage(cx); this.load_plan(cx); this.load_mcp_servers(cx); this.load_integrations(cx); @@ -707,24 +704,6 @@ impl SettingsScreen { self.save_mcp_servers(servers, cx); } - fn load_usage(&self, cx: &mut Context) { - let (spawn_backend, usage_backend) = (self.backend.clone(), self.backend.clone()); - let user_id = self.user_id.clone(); - let task = spawn_backend.spawn(async move { - let scope = usage_backend.account_scope(&user_id); - scope.map(|scope| settings::load_usage(&scope)) - }); - cx.spawn(async move |this, cx| { - let usage = task.await.ok().flatten().unwrap_or_default(); - this.update(cx, |this, cx| { - this.usage = Some(usage); - cx.notify(); - }) - .ok(); - }) - .detach(); - } - /// Change one setting: apply it to the local copy, queue the write /// off the UI thread, and re-render. Every toggle goes through here. /// @@ -1699,30 +1678,6 @@ impl SettingsScreen { .text_color(gpui::rgb(theme::text_muted())) .child("Plan usage unavailable"), }); - pane = pane.child(section_title("Usage")); - if let Some(usage) = &self.usage { - pane = pane - .child( - div() - .flex() - .gap_6() - .child(stat("Turns", usage.totals.turns.to_string())) - .child(stat("Sessions", usage.totals.sessions.to_string())) - .child(stat( - "Total tokens", - format_tokens(usage.totals.total_tokens), - )) - .child(stat("Est. cost", format!("${:.2}", usage.totals.cost))), - ) - .child(usage_table("By model", &usage.by_model)) - .child(usage_table("Recent sessions", &usage.by_session)); - } else { - pane = pane.child( - div() - .text_color(gpui::rgb(theme::text_muted())) - .child("Loading usage…"), - ); - } } Section::About => { pane = pane @@ -3152,87 +3107,6 @@ fn info_row(label: &str, value: String) -> Div { ) } -fn stat(label: &str, value: String) -> Div { - div() - .flex() - .flex_col() - .gap_1() - .child( - div() - .text_xs() - .text_color(gpui::rgb(theme::text_muted())) - .child(label.to_string()), - ) - .child( - div() - .text_xl() - .font_weight(gpui::FontWeight::BOLD) - .text_color(gpui::rgb(theme::text_primary())) - .child(value), - ) -} - -fn usage_table(title: &str, rows: &[crate::settings::UsageRow]) -> Div { - let mut table = div().flex().flex_col().gap_2().child( - div() - .text_sm() - .font_weight(gpui::FontWeight::SEMIBOLD) - .text_color(gpui::rgb(theme::text_primary())) - .child(title.to_string()), - ); - for row in rows.iter().take(10) { - table = table.child( - div() - .flex() - .items_center() - .justify_between() - .gap_3() - .px_3() - .py_2() - .rounded(theme::RADIUS_SM) - .bg(gpui::rgb(theme::bg_elevated())) - .child( - div() - .flex_1() - .min_w_0() - .text_sm() - .text_color(gpui::rgb(theme::text_primary())) - .line_clamp(1) - .child(row.label.clone()), - ) - .child( - div() - .text_xs() - .text_color(gpui::rgb(theme::text_secondary())) - .child(format!("{} turns", row.turns)), - ) - .child( - div() - .text_xs() - .text_color(gpui::rgb(theme::text_secondary())) - .child(format_tokens(row.total_tokens)), - ) - .child( - div() - .text_xs() - .text_color(gpui::rgb(theme::text_muted())) - .child(format!("${:.2}", row.cost)), - ), - ); - } - table -} - -fn format_tokens(tokens: i64) -> String { - if tokens >= 1_000_000 { - format!("{:.1}M", tokens as f64 / 1_000_000.0) - } else if tokens >= 1_000 { - format!("{:.1}k", tokens as f64 / 1_000.0) - } else { - tokens.to_string() - } -} - #[cfg(test)] mod tests { use super::*;