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::*;