diff --git a/crates/ide-agent/src/config.rs b/crates/ide-agent/src/config.rs index 14b1d02f..a9ab1bc6 100644 --- a/crates/ide-agent/src/config.rs +++ b/crates/ide-agent/src/config.rs @@ -1,7 +1,9 @@ //! Session settings (model, mode, thinking effort…), slash commands and context use, as //! the agent reports them over ACP. -use crate::model::{AgentCommand, AgentConfigOption, AgentConfigValue, AgentConfigVia, AgentUsage}; +use crate::model::{ + AgentCommand, AgentConfigOption, AgentConfigValue, AgentConfigVia, AgentCost, AgentLimit, AgentUsage, +}; use serde_json::{Value, json}; /// Cursor exposes execution modes over ACP, but leaves approval policy to its client. @@ -150,7 +152,27 @@ pub(crate) fn commands_of(update: &Value) -> Vec { /// `usage_update`: `{ used, size }`. pub(crate) fn usage_of(update: &Value) -> Option { let tokens = |key: &str| update.get(key)?.as_f64().map(|n| n.clamp(0.0, f64::from(u32::MAX)) as u32); - Some(AgentUsage { used: tokens("used")?, size: tokens("size").filter(|s| *s > 0)? }) + let cost = update.get("cost").and_then(|c| { + Some(Box::new(AgentCost { + amount: c.get("amount")?.as_f64().filter(|a| a.is_finite() && *a >= 0.0)?, + currency: c.get("currency")?.as_str().filter(|c| !c.is_empty())?.to_owned(), + })) + }); + Some(AgentUsage { used: tokens("used")?, size: tokens("size").filter(|s| *s > 0)?, cost }) +} + +/// The usage-limit windows a [`crate::LIMITS_UPDATE`] carries; malformed entries are skipped. +pub(crate) fn limits_of(update: &Value) -> Vec { + let limits = update.get("limits").and_then(Value::as_array).into_iter().flatten(); + limits + .filter_map(|l| { + Some(AgentLimit { + window: l.get("window")?.as_str().filter(|w| !w.is_empty())?.to_owned(), + used_percent: l.get("usedPercent")?.as_f64().filter(|p| p.is_finite())?.clamp(0.0, 100.0), + resets_ms: l.get("resetsMs").and_then(Value::as_f64), + }) + }) + .collect() } fn legacy( diff --git a/crates/ide-agent/src/lib.rs b/crates/ide-agent/src/lib.rs index a11036b4..d85bd0b7 100644 --- a/crates/ide-agent/src/lib.rs +++ b/crates/ide-agent/src/lib.rs @@ -49,6 +49,8 @@ const SESSION_STATE: [&str; 5] = ["session_info_update", "config_option_update", "current_mode_update", "available_commands_update", "usage_update"]; /// A native agent's own update: it is about to retry a failed API request (see [`AgentRetry`]). pub(crate) const RETRY_UPDATE: &str = "openidea_retry"; +/// A native agent's own update: the usage limits of its account (see [`AgentLimit`]). +pub(crate) const LIMITS_UPDATE: &str = "openidea_limits"; /// Referenced files up to this size are embedded in the prompt (when the agent takes it). const MAX_EMBED: u64 = 256 * 1024; /// How long a probe waits for the agent to answer `initialize`. @@ -1372,6 +1374,13 @@ impl Task { } return; } + if kind == LIMITS_UPDATE { + if !self.replaying.load(Ordering::Acquire) { + let provider = self.state.lock().task.provider.clone(); + self.shared.emit(AgentEvent::Limits { provider, limits: config::limits_of(update) }); + } + return; + } if kind == "session_info_update" { // The agent's own title for the session replaces the first line of the prompt. let title = update.get("title").and_then(Value::as_str).map(str::trim).filter(|t| !t.is_empty()); diff --git a/crates/ide-agent/src/model.rs b/crates/ide-agent/src/model.rs index b3781cd9..a6a96e77 100644 --- a/crates/ide-agent/src/model.rs +++ b/crates/ide-agent/src/model.rs @@ -154,6 +154,30 @@ pub struct AgentUsage { pub used: u32, /// Size of the context window. pub size: u32, + /// What the session has cost so far (ACP `usage_update.cost`), when the agent reports it. + // Boxed so that `AgentEvent::Task` stays within clippy's `large_enum_variant` bound on Windows. + #[serde(default)] + pub cost: Option>, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct AgentCost { + pub amount: f64, + /// ISO 4217 code, e.g. `USD`. + pub currency: String, +} + +/// One usage-limit window of an agent's account. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct AgentLimit { + /// The window as the agent names it: `five_hour`, `seven_day`, `seven_day_opus`… + pub window: String, + /// Share of the window used, 0–100. + pub used_percent: f64, + /// When the window resets (ms since the epoch), if the agent said. + pub resets_ms: Option, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] @@ -431,6 +455,11 @@ pub enum AgentEvent { index: u32, entry: AgentEntry, }, + /// An agent reported the usage limits of its account (shared by every task of `provider`). + Limits { + provider: String, + limits: Vec, + }, /// The installed or configured agent providers changed. Providers, } diff --git a/crates/ide-agent/src/native/claude/mod.rs b/crates/ide-agent/src/native/claude/mod.rs index f4f27e1c..65152647 100644 --- a/crates/ide-agent/src/native/claude/mod.rs +++ b/crates/ide-agent/src/native/claude/mod.rs @@ -904,6 +904,7 @@ impl Session { if info.get("status").and_then(Value::as_str) == Some("rejected") { st.limit_resets_ms = info.get("resetsAt").and_then(Value::as_f64).map(|s| s * 1000.0); } + updates.extend(st.tr.on_rate_limit(&info)); } "command_lifecycle" => { let uuid = msg.get("command_uuid").and_then(Value::as_str); diff --git a/crates/ide-agent/src/native/claude/tests.rs b/crates/ide-agent/src/native/claude/tests.rs index b187e848..49c9f467 100644 --- a/crates/ide-agent/src/native/claude/tests.rs +++ b/crates/ide-agent/src/native/claude/tests.rs @@ -1013,6 +1013,39 @@ fn streamed_text_is_not_sent_twice() { assert_eq!(usage[0].update, json!({"sessionUpdate": "usage_update", "used": 15, "size": 1000})); } +#[test] +fn rate_limits_and_cost() { + let mut t = Translator::new("s", PathBuf::from("/w"), false); + let info = json!({"status": "allowed", "rateLimitType": "five_hour", "unifiedWindows": { + "five_hour": {"utilization": 0.19, "resetsAt": 100, "status": "allowed"}, + "seven_day": {"utilization": 0.09, "resetsAt": 200, "status": "allowed"}, + }}); + let limits = t.on_rate_limit(&info).unwrap(); + assert_eq!(limits.session, "s"); + assert_eq!(limits.update["sessionUpdate"], crate::LIMITS_UPDATE); + let windows: Vec<(&str, i64, f64)> = limits.update["limits"] + .as_array() + .unwrap() + .iter() + .map(|l| { + ( + l["window"].as_str().unwrap(), + l["usedPercent"].as_f64().unwrap().round() as i64, + l["resetsMs"].as_f64().unwrap(), + ) + }) + .collect(); + assert_eq!(windows, [("five_hour", 19, 100_000.0), ("seven_day", 9, 200_000.0)]); + // Older versions report the window that applies, alone. + let single = t.on_rate_limit(&json!({"rateLimitType": "seven_day", "utilization": 0.5})).unwrap(); + assert_eq!(single.update["limits"], json!([{"window": "seven_day", "usedPercent": 50.0, "resetsMs": null}])); + assert_eq!(t.on_rate_limit(&json!({"status": "allowed", "resetsAt": 100})), None, "no utilization, no update"); + + t.used = Some(10); + let usage = t.on_result(&json!({"total_cost_usd": 0.5, "modelUsage": {"x": {"contextWindow": 1000}}})); + assert_eq!(usage[0].update["cost"], json!({"amount": 0.5, "currency": "USD"})); +} + #[test] fn tool_calls_plans_and_subagents() { let mut t = Translator::new("s", PathBuf::from("/w"), true); diff --git a/crates/ide-agent/src/native/claude/translate.rs b/crates/ide-agent/src/native/claude/translate.rs index 066be5f0..7d9a91f3 100644 --- a/crates/ide-agent/src/native/claude/translate.rs +++ b/crates/ide-agent/src/native/claude/translate.rs @@ -5,7 +5,7 @@ //! Agent tool use that launched them: their messages carry it as `parent_tool_use_id`. use super::tools; -use crate::RETRY_UPDATE; +use crate::{LIMITS_UPDATE, RETRY_UPDATE}; use serde_json::{Value, json}; use std::collections::{HashMap, VecDeque}; use std::path::PathBuf; @@ -58,6 +58,8 @@ pub(super) struct Translator { pub used: Option, pub model: Option, windows: HashMap, + /// The session's cost so far in USD, as the last `result` reported it. + cost_usd: Option, } impl Translator { @@ -75,6 +77,7 @@ impl Translator { used: None, model: None, windows: HashMap::new(), + cost_usd: None, } } @@ -543,6 +546,9 @@ impl Translator { self.windows.insert(model.clone(), w); } } + if let Some(cost) = result.get("total_cost_usd").and_then(Value::as_f64).filter(|c| c.is_finite()) { + self.cost_usd = Some(cost); + } self.usage_update().into_iter().collect() } @@ -557,9 +563,33 @@ impl Translator { .or_else(|| self.windows.iter().find(|(k, _)| k.starts_with(m.as_str())).map(|(_, v)| v)) }) .or_else(|| (self.windows.len() == 1).then(|| self.windows.values().next()).flatten())?; - Some(Update { + let mut update = json!({ "sessionUpdate": "usage_update", "used": used, "size": size }); + if let Some(amount) = self.cost_usd { + update["cost"] = json!({ "amount": amount, "currency": "USD" }); + } + Some(Update { session: self.root.clone(), update }) + } + + /// The usage-limit windows a `rate_limit_event` reports, as a [`LIMITS_UPDATE`]: every + /// window of `unifiedWindows`, or the single `rateLimitType` window older versions send. + pub fn on_rate_limit(&self, info: &Value) -> Option { + let window = |name: &str, w: &Value| { + let utilization = w.get("utilization")?.as_f64()?; + Some(json!({ + "window": name, + "usedPercent": utilization * 100.0, + "resetsMs": w.get("resetsAt").and_then(Value::as_f64).map(|s| s * 1000.0), + })) + }; + let limits: Vec = match info.get("unifiedWindows").and_then(Value::as_object) { + Some(windows) => windows.iter().filter_map(|(name, w)| window(name, w)).collect(), + None => { + info.get("rateLimitType").and_then(Value::as_str).and_then(|t| window(t, info)).into_iter().collect() + } + }; + (!limits.is_empty()).then(|| Update { session: self.root.clone(), - update: json!({ "sessionUpdate": "usage_update", "used": used, "size": size }), + update: json!({ "sessionUpdate": LIMITS_UPDATE, "limits": limits }), }) } } diff --git a/crates/ide-agent/src/tests.rs b/crates/ide-agent/src/tests.rs index 6bd3ea2b..eb64fd67 100644 --- a/crates/ide-agent/src/tests.rs +++ b/crates/ide-agent/src/tests.rs @@ -145,7 +145,16 @@ impl Handler for FakeAgent { json!({"sessionUpdate": "available_commands_update", "availableCommands": [{"name": "review", "description": "Review", "input": {"hint": "focus"}}]}), ); - self.update(&session, json!({"sessionUpdate": "usage_update", "used": 50, "size": 1000})); + self.update( + &session, + json!({"sessionUpdate": "usage_update", "used": 50, "size": 1000, + "cost": {"amount": 0.25, "currency": "USD"}}), + ); + self.update( + &session, + json!({"sessionUpdate": LIMITS_UPDATE, + "limits": [{"window": "five_hour", "usedPercent": 19.0, "resetsMs": 5000.0}]}), + ); self.update(&session, json!({"sessionUpdate": "session_info_update", "title": "Lexer fix"})); self.say(&session, "Hi"); self.update( @@ -358,6 +367,10 @@ async fn a_task_runs_asks_for_permission_and_survives_a_restart() { assert_eq!((modes.via, modes.current.as_deref()), (AgentConfigVia::Mode, Some("ask"))); assert_eq!(t.commands.iter().map(|c| c.name.as_str()).collect::>(), ["review"]); assert_eq!(t.usage.as_ref().map(|u| (u.used, u.size)), Some((50, 1000))); + assert_eq!( + t.usage.as_ref().and_then(|u| u.cost.clone()), + Some(Box::new(AgentCost { amount: 0.25, currency: "USD".into() })) + ); assert_eq!(t.title, "Lexer fix", "the agent's title replaces the prompt's first line"); } mgr.set_config(&task.id, "mode", "code".into()).await.unwrap(); @@ -370,6 +383,18 @@ async fn a_task_runs_asks_for_permission_and_survives_a_restart() { assert!( events.iter().any(|e| matches!(e, AgentEvent::Task { task } if task.status == AgentStatus::NeedsInput)) ); + let limits = events.iter().find_map(|e| match e { + AgentEvent::Limits { provider, limits } => Some((provider.as_str(), limits.clone())), + _ => None, + }); + assert_eq!( + limits, + Some(( + task.provider.as_str(), + vec![AgentLimit { window: "five_hour".into(), used_percent: 19.0, resets_ms: Some(5000.0) }] + )), + "an agent's usage limits are its provider's" + ); let last_entry = events.iter().rev().find_map(|e| match e { AgentEvent::Entry { index, entry, .. } if !matches!(entry, AgentEntry::User { .. }) => { Some((*index, entry.clone())) @@ -772,6 +797,18 @@ fn retry_updates_become_a_countdown() { assert_eq!(retry_of(&json!({"sessionUpdate": RETRY_UPDATE}), 0.0), None, "no attempt"); } +#[test] +fn usage_limits_and_cost_skip_what_is_malformed() { + let limits = config::limits_of(&json!({"sessionUpdate": LIMITS_UPDATE, "limits": [ + {"window": "seven_day", "usedPercent": 140.0}, + {"window": "", "usedPercent": 10.0}, + {"window": "five_hour"}, + ]})); + assert_eq!(limits, [AgentLimit { window: "seven_day".into(), used_percent: 100.0, resets_ms: None }]); + let usage = config::usage_of(&json!({"used": 1, "size": 2, "cost": {"amount": -1.0, "currency": "USD"}})); + assert_eq!(usage.map(|u| u.cost), Some(None), "a negative cost is dropped, the context use kept"); +} + #[tokio::test(flavor = "multi_thread")] async fn cursor_approval_switch_resolves_pending_and_survives_restart() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/ide-core/src/settings.rs b/crates/ide-core/src/settings.rs index 9008406b..dd101f24 100644 --- a/crates/ide-core/src/settings.rs +++ b/crates/ide-core/src/settings.rs @@ -59,6 +59,28 @@ pub struct Settings { /// Build, Execution, Deployment › Run Targets. pub run_targets: Vec, pub cmake: CMakeSettings, + /// Tools › Agents › Chat. + pub agents: AgentSettings, +} + +/// What the meters in an agent task's header show. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(default, rename_all = "camelCase")] +pub struct AgentSettings { + /// Context window use. + pub show_context_usage: bool, + /// Time the agent spent on the task. + pub show_work_time: bool, + /// The account's usage-limit windows (Claude's 5-hour and weekly limits). + pub show_usage_limits: bool, + /// What the session has cost so far, as the agent reports it. + pub show_cost: bool, +} + +impl Default for AgentSettings { + fn default() -> Self { + Self { show_context_usage: true, show_work_time: true, show_usage_limits: true, show_cost: false } + } } /// Absent profiles retain automatic discovery for existing projects. @@ -192,6 +214,7 @@ impl Default for Settings { deployment: DeploymentSettings::default(), run_targets: Vec::new(), cmake: CMakeSettings::default(), + agents: AgentSettings::default(), } } } @@ -904,6 +927,20 @@ mod tests { } } + #[test] + fn agent_meters_default_and_round_trip() { + let old: Settings = serde_json::from_str(r#"{"agents":{"showCost":true}}"#).unwrap(); + assert!(old.agents.show_cost); + assert!(old.agents.show_context_usage && old.agents.show_work_time && old.agents.show_usage_limits); + assert!(!Settings::default().agents.show_cost); + let mut settings = Settings::default(); + settings.agents.show_usage_limits = false; + let json = serde_json::to_value(&settings).unwrap(); + assert_eq!(json["agents"]["showUsageLimits"], false); + let restored: Settings = serde_json::from_value(json).unwrap(); + assert_eq!(restored.agents, settings.agents); + } + #[test] fn main_menu_style_defaults_and_round_trips() { let old: Settings = serde_json::from_str(r#"{"appearance":{"compactMode":true}}"#).unwrap(); diff --git a/src/bindings.ts b/src/bindings.ts index 879951d4..074ab6ec 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -1161,6 +1161,12 @@ export type AgentConfigVia = /** `session/set_model` (agents that report `models` instead of a model config option). */ "model"; +export type AgentCost = { + amount: number | null, + /** ISO 4217 code, e.g. `USD`. */ + currency: string, +}; + /** One item of a task's conversation. */ export type AgentEntry = { type: "user"; text: string; /** Names of the files and images sent with it. */ @@ -1206,6 +1212,8 @@ export type AgentEvent = { type: "truncated"; task: string; length: number } | /** Entry `index` of `task` (of its subagent `subagent`) was added or replaced. */ { type: "entry"; task: string; subagent: string | null; index: number; entry: AgentEntry } | +/** An agent reported the usage limits of its account (shared by every task of `provider`). */ +{ type: "limits"; provider: string; limits: AgentLimit[] } | /** The installed or configured agent providers changed. */ { type: "providers" }; @@ -1216,6 +1224,16 @@ export type AgentImage = { name: string | null, }; +/** One usage-limit window of an agent's account. */ +export type AgentLimit = { + /** The window as the agent names it: `five_hour`, `seven_day`, `seven_day_opus`… */ + window: string, + /** Share of the window used, 0–100. */ + usedPercent: number | null, + /** When the window resets (ms since the epoch), if the agent said. */ + resetsMs: number | null, +}; + export type AgentPermissionOption = { id: string, name: string, @@ -1297,6 +1315,18 @@ export type AgentRetry = { error: string | null, }; +/** What the meters in an agent task's header show. */ +export type AgentSettings = { + /** Context window use. */ + showContextUsage?: boolean, + /** Time the agent spent on the task. */ + showWorkTime?: boolean, + /** The account's usage-limit windows (Claude's 5-hour and weekly limits). */ + showUsageLimits?: boolean, + /** What the session has cost so far, as the agent reports it. */ + showCost?: boolean, +}; + /** Where an agent comes from. */ export type AgentSource = { kind: "builtin" } | /** Defined by the user in `/agents/.toml`. */ @@ -1378,6 +1408,8 @@ export type AgentUsage = { used: number, /** Size of the context window. */ size: number, + /** What the session has cost so far (ACP `usage_update.cost`), when the agent reports it. */ + cost?: AgentCost | null, }; export type AppInfo = { @@ -5785,6 +5817,8 @@ export type Settings_Deserialize = { /** Build, Execution, Deployment › Run Targets. */ runTargets?: RunTarget[], cmake?: CMakeSettings_Deserialize, + /** Tools › Agents › Chat. */ + agents?: AgentSettings, }; export type Settings_Serialize = { @@ -5841,6 +5875,8 @@ export type Settings_Serialize = { /** Build, Execution, Deployment › Run Targets. */ runTargets: RunTarget[], cmake: CMakeSettings_Serialize, + /** Tools › Agents › Chat. */ + agents: AgentSettings, }; export type Severity = "error" | "warning" | "info" | "hint"; diff --git a/src/features/agents/SessionEditor.tsx b/src/features/agents/SessionEditor.tsx index deddc4c4..e6976584 100644 --- a/src/features/agents/SessionEditor.tsx +++ b/src/features/agents/SessionEditor.tsx @@ -2,7 +2,7 @@ import { Fragment, useEffect, useLayoutEffect, useMemo, useRef, useState, type RefObject } from "react"; import clsx from "clsx"; import { useShallow } from "zustand/react/shallow"; -import type { AgentChange, AgentEntry, AgentImage, AgentRetry, AgentTask, AgentUsage } from "@/bindings"; +import type { AgentChange, AgentEntry, AgentImage, AgentLimit, AgentRetry, AgentTask, AgentUsage } from "@/bindings"; import { executeAction } from "@/platform/actions"; import { call, commands, onIdeEvent } from "@/platform/ipc"; import { basename, relativeDir, useProject } from "@/platform/project"; @@ -28,7 +28,7 @@ import { isImeKey } from "@/platform/ime"; import { TaskStatusIcon } from "./AgentsView"; import { Composer } from "./Composer"; import { SubmitButton } from "./SubmitButton"; -import { canSend, emptyDraft, retryParts, usageLabel, workTime } from "./composerModel"; +import { canSend, costLabel, emptyDraft, limitLabel, retryParts, usageLabel, workTime } from "./composerModel"; import { ConfigPickers } from "./ConfigPickers"; import { findRanges, formatDuration, formatSentAt } from "./conversation"; import { ImagePreview, imageSrc } from "./ImagePreview"; @@ -287,21 +287,45 @@ function Header({ ); } -/** Context use (when the agent reports it) and the time spent on the task. */ +/** + * Context use, usage limits and cost (when the agent reports them) and the time spent on + * the task, each as Settings › Tools › Agents › Chat allows. + */ function Meters({ task, entries }: { task: AgentTask; entries: AgentEntry[] | null }) { const tx = useTx(); + const show = useProject((s) => s.settings?.agents); const busy = isBusy(task.status); const now = useNow(busy ? 1000 : 60_000); - const usage = task.usage; - const work = entries ? workTime(entries, busy ? now : null) : 0; + const usage = show?.showContextUsage !== false ? task.usage : null; + const reported = useAgents((s) => s.limits.get(task.provider)); + const limits = show?.showUsageLimits !== false ? (reported ?? []) : []; + const cost = show?.showCost === true ? task.usage?.cost : null; + const work = show?.showWorkTime !== false && entries ? workTime(entries, busy ? now : null) : 0; return ( {usage && } + {limits.map((l) => ( + + ))} + {cost && {costLabel(cost)}} {work >= 1000 && {formatDuration(work)}} ); } +/** A usage-limit window: "5h 19%", when it resets in the tooltip. */ +function Limit({ limit, now }: { limit: AgentLimit; now: number }) { + const label = limitLabel(limit, now); + return ( + + + + + {label.short} + + ); +} + /** Context window use: "12%", the numbers in the tooltip. */ function Usage({ usage }: { usage: AgentUsage }) { const label = usageLabel(usage); diff --git a/src/features/agents/agents.test.ts b/src/features/agents/agents.test.ts index c6671741..018f3465 100644 --- a/src/features/agents/agents.test.ts +++ b/src/features/agents/agents.test.ts @@ -17,6 +17,8 @@ import { toPrompt, retryParts, usageLabel, + limitLabel, + costLabel, workTime, } from "./composerModel"; import { @@ -151,6 +153,28 @@ describe("usage meters", () => { expect(usageLabel({ used: 83_100, size: 1_000_000 }).detail).toBe("83.1k / 1M tokens"); }); + it("names usage-limit windows and says when they reset", () => { + const hour = 3_600_000; + expect(limitLabel({ window: "five_hour", usedPercent: 19.4, resetsMs: 2 * hour + 51 * 60_000 }, 0)).toEqual({ + short: "5h 19%", + percent: 19, + detail: "5-hour limit: 19% used, resets in 2h 51m", + }); + expect(limitLabel({ window: "seven_day", usedPercent: 120, resetsMs: 5 * 24 * hour + 3 * hour }, 0).detail).toBe( + "Weekly limit: 100% used, resets in 5d 3h", + ); + expect(limitLabel({ window: "spend_limit", usedPercent: 7, resetsMs: null }, 0)).toEqual({ + short: "spend_limit 7%", + percent: 7, + detail: "spend_limit: 7% used", + }); + }); + + it("formats the session cost", () => { + expect(costLabel({ amount: 0.4231, currency: "USD" })).toBe("$0.42"); + expect(costLabel({ amount: 1.5, currency: "credits" })).toBe("1.50 credits"); + }); + it("adds up the time spent on turns", () => { const user = (sentMs: number, doneMs: number | null): AgentEntry => ({ type: "user", diff --git a/src/features/agents/composerModel.ts b/src/features/agents/composerModel.ts index 88564434..fdc93c01 100644 --- a/src/features/agents/composerModel.ts +++ b/src/features/agents/composerModel.ts @@ -2,8 +2,10 @@ import type { AgentCommand, AgentConfigOption, + AgentCost, AgentEntry, AgentImage, + AgentLimit, AgentPrompt, AgentRetry, AgentUsage, @@ -181,6 +183,44 @@ export function usageLabel(u: AgentUsage): { short: string; detail: string } { return { short: `${pct}%`, detail: tx`${tokens(u.used)} / ${tokens(u.size)} tokens` }; } +/** The usage-limit windows agents report, by the name they give them; others show that name. */ +const LIMIT_WINDOWS: Record = { + five_hour: { short: "5h", name: "5-hour limit" }, + seven_day: { short: "7d", name: "Weekly limit" }, + seven_day_opus: { short: "7d Opus", name: "Weekly Opus limit" }, + seven_day_sonnet: { short: "7d Sonnet", name: "Weekly Sonnet limit" }, +}; + +/** "5h 19%" for a usage-limit window, and a tooltip saying when it resets. */ +export function limitLabel(l: AgentLimit, now: number): { short: string; percent: number; detail: string } { + const known = LIMIT_WINDOWS[l.window]; + const percent = Math.min(100, Math.round(l.usedPercent ?? 0)); + const name = known ? tx(known.name) : l.window; + const used = tx`${name}: ${percent}% used`; + if (l.resetsMs == null) return { short: `${known?.short ?? l.window} ${percent}%`, percent, detail: used }; + const reset = untilReset(Math.max(0, l.resetsMs - now)); + return { short: `${known?.short ?? l.window} ${percent}%`, percent, detail: tx`${used}, resets in ${reset}` }; +} + +/** `45m`, `2h 51m`, `5d 3h`. */ +function untilReset(ms: number): string { + const m = Math.round(ms / 60_000); + if (m < 60) return `${m}m`; + const h = Math.floor(m / 60); + if (h < 24) return `${h}h ${String(m % 60).padStart(2, "0")}m`; + return `${Math.floor(h / 24)}d ${h % 24}h`; +} + +/** "$0.42": what the session has cost so far. */ +export function costLabel(c: AgentCost): string { + try { + return new Intl.NumberFormat("en-US", { style: "currency", currency: c.currency }).format(c.amount ?? 0); + } catch { + // Not an ISO 4217 code. + return `${(c.amount ?? 0).toFixed(2)} ${c.currency}`; + } +} + /** `850`, `83.1k`, `1M`. */ function tokens(n: number): string { if (n >= 1_000_000) return `${+(n / 1_000_000).toFixed(1)}M`; diff --git a/src/features/agents/sessionState.test.tsx b/src/features/agents/sessionState.test.tsx index b8c4a098..36c5244d 100644 --- a/src/features/agents/sessionState.test.tsx +++ b/src/features/agents/sessionState.test.tsx @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { registerActions } from "@/platform/actions"; +import { useProject } from "@/platform/project"; import type { AgentChange, AgentEntry, AgentTask } from "@/bindings"; import { AgentsView } from "./AgentsView"; import { SessionEditor } from "./SessionEditor"; @@ -227,3 +228,36 @@ describe("session state across tasks and hosts", () => { expect(useAgents.getState().scrollPositions.size).toBe(0); }); }); + +describe("meters in the task header", () => { + afterEach(() => { + useProject.setState({ settings: null }); + useAgents.setState({ limits: new Map() }); + }); + + it("shows the provider's usage limits by default and each meter as Settings › Tools › Agents › Chat allows", async () => { + const reported: AgentTask = { + ...task("A"), + usage: { used: 24_000, size: 200_000, cost: { amount: 0.42, currency: "USD" } }, + }; + const limits = [ + { window: "five_hour", usedPercent: 19, resetsMs: null }, + { window: "seven_day", usedPercent: 9.4, resetsMs: null }, + ]; + useAgents.setState({ tasks: new Map([["A", reported]]), limits: new Map([["codex", limits]]) }); + render(); + await screen.findByText("Answer A"); + expect(screen.getByText("12%")).toBeInTheDocument(); + expect(screen.getByText("5h 19%")).toBeInTheDocument(); + expect(screen.getByText("7d 9%")).toBeInTheDocument(); + expect(screen.queryByText("$0.42")).toBeNull(); + act(() => + useProject.setState({ + settings: { agents: { showContextUsage: false, showUsageLimits: false, showCost: true } }, + }), + ); + expect(screen.queryByText("12%")).toBeNull(); + expect(screen.queryByText("5h 19%")).toBeNull(); + expect(screen.getByText("$0.42")).toBeInTheDocument(); + }); +}); diff --git a/src/features/agents/store.ts b/src/features/agents/store.ts index b1b54f3e..f22a7223 100644 --- a/src/features/agents/store.ts +++ b/src/features/agents/store.ts @@ -8,6 +8,7 @@ import type { AgentEntry, AgentEvent, AgentImage, + AgentLimit, AgentProvider, AgentTask, Isolation, @@ -59,6 +60,8 @@ interface AgentsState { /** Scroll position and follow-output preference by conversation, shared by both hosts. */ scrollPositions: Map; providers: AgentProvider[]; + /** The usage limits each agent last reported for its account, by provider. */ + limits: Map; draft: Draft; composers: Map; /** The composer typed in last: where "Send to Agent" adds references. */ @@ -106,6 +109,7 @@ export const useAgents = create(() => ({ entries: new Map(), changes: new Map(), providers: [], + limits: new Map(), draft: { ...fallbackDraft, ...load>(DRAFT_KEY, {}) }, composers: new Map(), lastComposer: HOME, @@ -261,6 +265,10 @@ function onEvent(e: AgentEvent) { void refreshProviders(); break; } + case "limits": { + useAgents.setState({ limits: new Map(s.limits).set(e.provider, e.limits) }); + break; + } case "entry": { if ( !e.subagent && diff --git a/src/features/agents/unread.test.tsx b/src/features/agents/unread.test.tsx index 14fae199..4afd1ab9 100644 --- a/src/features/agents/unread.test.tsx +++ b/src/features/agents/unread.test.tsx @@ -132,6 +132,16 @@ describe("agent unread activity", () => { expect(backend.notify).toHaveBeenCalledTimes(1); }); + it("keeps the latest usage limits per provider without marking anything unread", () => { + const fiveHour = (usedPercent: number) => [{ window: "five_hour", usedPercent, resetsMs: null }]; + emit({ type: "limits", provider: "claude", limits: fiveHour(10) }); + emit({ type: "limits", provider: "codex", limits: fiveHour(50) }); + emit({ type: "limits", provider: "claude", limits: fiveHour(12) }); + expect(useAgents.getState().limits.get("claude")).toEqual(fiveHour(12)); + expect(useAgents.getState().limits.get("codex")).toEqual(fiveHour(50)); + expect(useAgents.getState().unread.size).toBe(0); + }); + it("ignores user messages, thoughts, empty replies and subagent streaming", () => { const entries: AgentEntry[] = [ { type: "user", text: "Do this", attachments: [] }, diff --git a/src/features/settings/__tests__/agents.test.ts b/src/features/settings/__tests__/agents.test.ts new file mode 100644 index 00000000..8016d6a4 --- /dev/null +++ b/src/features/settings/__tests__/agents.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import { validateSettings } from "../model/schema"; + +describe("agent chat settings", () => { + it("defaults every meter but the cost to on and keeps stored values", () => { + expect(validateSettings({}).agents).toEqual({ + showContextUsage: true, + showWorkTime: true, + showUsageLimits: true, + showCost: false, + }); + const stored = validateSettings({ agents: { showUsageLimits: false, showCost: true, showWorkTime: "no" } }).agents; + expect(stored).toEqual({ showContextUsage: true, showWorkTime: true, showUsageLimits: false, showCost: true }); + }); +}); diff --git a/src/features/settings/model/schema.ts b/src/features/settings/model/schema.ts index f9400f4c..50a2226a 100644 --- a/src/features/settings/model/schema.ts +++ b/src/features/settings/model/schema.ts @@ -7,7 +7,7 @@ import { validateCMake } from "@/platform/cmakeSettings"; import { DEFAULT_IGNORED_NAMES } from "@/platform/ignoredNames"; import { validateFileTypeOverrides, validateFileTypes, type FileTypeAssociations } from "@/platform/fileTypes"; -import type { LanguageServerSettings, Settings } from "@/platform/ipc"; +import type { AgentSettings, LanguageServerSettings, Settings } from "@/platform/ipc"; import { DEFAULT_LANGUAGES, DEFAULT_PROJECT_STRUCTURE, @@ -92,6 +92,8 @@ export interface ValidSettings extends WorkflowSettings { tools: Record; /** Settings › Language Servers, by server name (as the backend sends them). */ languageServers: Record; + /** Tools › Agents › Chat: the meters in an agent task's header. */ + agents: Required; } /** One `languageServers.` entry; every field optional, like in settings.toml. */ @@ -158,6 +160,7 @@ export const DEFAULT_SETTINGS: ValidSettings = { spelling: { ideWords: [], projectWords: [], customDictionaries: [] }, tools: {}, languageServers: {}, + agents: { showContextUsage: true, showWorkTime: true, showUsageLimits: true, showCost: false }, ...DEFAULT_WORKFLOW, }; @@ -201,6 +204,7 @@ export function validateSettings(raw: Settings | unknown): ValidSettings { const a = obj(root.appearance); const e = obj(root.editor); const f = obj(root.files); + const agents = obj(root.agents); const overrides = bindingMap(root.keymapOverrides); const customKeymaps: Record = {}; for (const [id, raw] of Object.entries(obj(root.customKeymaps))) { @@ -270,6 +274,12 @@ export function validateSettings(raw: Settings | unknown): ValidSettings { languageServers: Object.fromEntries( Object.entries(obj(root.languageServers)).map(([name, ls]) => [name, obj(ls) as LanguageServerEntry]), ), + agents: { + showContextUsage: bool(agents.showContextUsage, d.agents.showContextUsage), + showWorkTime: bool(agents.showWorkTime, d.agents.showWorkTime), + showUsageLimits: bool(agents.showUsageLimits, d.agents.showUsageLimits), + showCost: bool(agents.showCost, d.agents.showCost), + }, ...validateWorkflow(root), }; } diff --git a/src/features/settings/pages/tools.tsx b/src/features/settings/pages/tools.tsx index d591ca94..03ec44d4 100644 --- a/src/features/settings/pages/tools.tsx +++ b/src/features/settings/pages/tools.tsx @@ -1,7 +1,7 @@ /** - * Tools pages: Terminal, Actions on Save and External Tools. Consumed by the - * terminal feature, the editor save participants and the run feature - * (External Tools actions). + * Tools pages: Terminal, Actions on Save, External Tools and Agents › Chat. Consumed + * by the terminal feature, the editor save participants, the run feature + * (External Tools actions) and the agent task editor. */ import { FontPicker } from "../components/FontPicker"; @@ -623,6 +623,35 @@ export const TOOLS_PAGES: PageSpec[] = [ { options: [{ kind: "custom", label: "Save", component: ActionsOnSaveNote }] }, ], }, + { + // Under Settings › Tools › Agents (the agents feature registers `tools.agents`). + id: "tools.agents.chat", + title: "Chat", + group: TOOLS_GROUP, + order: 0, + keywords: ["agent", "claude", "codex", "usage", "quota", "rate limit", "cost", "tokens", "meter"], + sections: [ + { + title: "Shown in the Task Header", + options: [ + { kind: "toggle", key: "agents.showContextUsage", label: "Context window usage" }, + { kind: "toggle", key: "agents.showWorkTime", label: "Time spent on the task" }, + { + kind: "toggle", + key: "agents.showUsageLimits", + label: "Usage limits", + hint: "The account's 5-hour and weekly limits, when the agent reports them (Claude Code)", + }, + { + kind: "toggle", + key: "agents.showCost", + label: "Session cost", + hint: "What the session has cost so far, as the agent reports it (API pricing, also shown on subscriptions)", + }, + ], + }, + ], + }, { id: "tools.externalTools", title: "External Tools", diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 4f4a9081..e468db29 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -5688,5 +5688,20 @@ "Unresolved merge conflicts": "未解决的合并冲突", "Reports the <<<<<<<, ======= and >>>>>>> lines a merge leaves in a file, whatever its language and whether or not a language server is installed.": "报告合并在文件中留下的 <<<<<<<、======= 和 >>>>>>> 行,与文件语言以及是否安装了语言服务器无关。", "Merge/Rebase Widget": "合并/变基微件", - "Unresolved merge conflict markers": "未解决的合并冲突标记" + "Unresolved merge conflict markers": "未解决的合并冲突标记", + "What the session has cost so far, as the agent reports it": "智能体报告的本会话目前花费", + "{name}: {percent}% used": "{name}:已使用 {percent}%", + "{used}, resets in {reset}": "{used},{reset} 后重置", + "5-hour limit": "5 小时限额", + "Weekly limit": "每周限额", + "Weekly Opus limit": "每周 Opus 限额", + "Weekly Sonnet limit": "每周 Sonnet 限额", + "Chat": "聊天", + "Shown in the Task Header": "在任务标题栏中显示", + "Context window usage": "上下文窗口用量", + "Time spent on the task": "任务耗时", + "Usage limits": "用量限额", + "The account's 5-hour and weekly limits, when the agent reports them (Claude Code)": "账户的 5 小时和每周限额,在智能体报告时显示(Claude Code)", + "Session cost": "会话花费", + "What the session has cost so far, as the agent reports it (API pricing, also shown on subscriptions)": "智能体报告的本会话目前花费(按 API 价格计算,订阅账户也会显示)" }