Skip to content
Closed
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
4 changes: 2 additions & 2 deletions apps/maple-agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
149 changes: 0 additions & 149 deletions apps/maple-agent/app/src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<UsageRow>,
pub by_session: Vec<UsageRow>,
}

/// 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<String>>(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<String>>(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::<Vec<_>>();
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
Expand Down
128 changes: 1 addition & 127 deletions apps/maple-agent/app/src/ui/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -132,7 +132,6 @@ pub struct SettingsScreen {
account: AccountState,
billing: BillingState,
api_keys: ApiKeysState,
usage: Option<UsageSummary>,
/// Plan usage meter, same source as the sidebar card.
plan: Option<crate::billing::PlanUsage>,
/// Account MCP servers; None until loaded.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -707,24 +704,6 @@ impl SettingsScreen {
self.save_mcp_servers(servers, cx);
}

fn load_usage(&self, cx: &mut Context<Self>) {
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.
///
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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::*;
Expand Down
Loading