feat(agents): show Claude usage limits and session cost in the agent task header (#1041)

Claude Code reports the account's 5-hour and weekly usage limits (rate_limit_event) and the session cost (result.total_cost_usd); only the limit status was used. The limits now show as meters next to the context meter in the task header, with the reset time in the tooltip, and Settings › Tools › Agents › Chat turns each meter (context, work time, limits, cost) on or off. Limits are per provider and kept in memory; cost is off by default.

Closes #1057

Co-authored-by: matthiasdemauroy <295796358+matthiasdemauroy@users.noreply.github.com>
This commit is contained in:
matthiasdemauroy
2026-11-04 00:28:23 +08:00
committed by GitHub
co-authored by matthiasdemauroy
parent 4eda613c59
commit 39fe96bd01
19 changed files with 459 additions and 16 deletions
+24 -2
View File
@@ -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<AgentCommand> {
/// `usage_update`: `{ used, size }`.
pub(crate) fn usage_of(update: &Value) -> Option<AgentUsage> {
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<AgentLimit> {
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(
+9
View File
@@ -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());
+29
View File
@@ -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<Box<AgentCost>>,
}
#[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<f64>,
}
#[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<AgentLimit>,
},
/// The installed or configured agent providers changed.
Providers,
}
@@ -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);
@@ -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);
@@ -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<u64>,
pub model: Option<String>,
windows: HashMap<String, u64>,
/// The session's cost so far in USD, as the last `result` reported it.
cost_usd: Option<f64>,
}
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<Update> {
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<Value> = 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 }),
})
}
}
+38 -1
View File
@@ -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::<Vec<_>>(), ["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();
+37
View File
@@ -59,6 +59,28 @@ pub struct Settings {
/// Build, Execution, Deployment › Run Targets.
pub run_targets: Vec<RunTarget>,
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();
+36
View File
@@ -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 `<config>/agents/<id>.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";
+29 -5
View File
@@ -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 (
<span className={styles.meters}>
{usage && <Usage usage={usage} />}
{limits.map((l) => (
<Limit key={l.window} limit={l} now={now} />
))}
{cost && <span title={tx("What the session has cost so far, as the agent reports it")}>{costLabel(cost)}</span>}
{work >= 1000 && <span title={tx("Time the agent spent on this task")}>{formatDuration(work)}</span>}
</span>
);
}
/** 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 (
<span className={styles.meter} title={label.detail} aria-label={label.detail}>
<span className={styles.usageBar} aria-hidden>
<span style={{ width: `${label.percent}%` }} />
</span>
{label.short}
</span>
);
}
/** Context window use: "12%", the numbers in the tooltip. */
function Usage({ usage }: { usage: AgentUsage }) {
const label = usageLabel(usage);
+24
View File
@@ -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",
+40
View File
@@ -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<string, { short: string; name: string }> = {
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`;
+34
View File
@@ -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(<SessionEditor input={input("A")} />);
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();
});
});
+8
View File
@@ -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<string, { top: number; stick: boolean }>;
providers: AgentProvider[];
/** The usage limits each agent last reported for its account, by provider. */
limits: Map<string, AgentLimit[]>;
draft: Draft;
composers: Map<string, ComposerDraft>;
/** The composer typed in last: where "Send to Agent" adds references. */
@@ -106,6 +109,7 @@ export const useAgents = create<AgentsState>(() => ({
entries: new Map(),
changes: new Map(),
providers: [],
limits: new Map(),
draft: { ...fallbackDraft, ...load<Partial<Draft>>(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 &&
+10
View File
@@ -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: [] },
@@ -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 });
});
});
+11 -1
View File
@@ -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<string, { path: string }>;
/** Settings › Language Servers, by server name (as the backend sends them). */
languageServers: Record<string, LanguageServerEntry>;
/** Tools › Agents › Chat: the meters in an agent task's header. */
agents: Required<AgentSettings>;
}
/** One `languageServers.<name>` 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<string, CustomKeymapSettings> = {};
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),
};
}
+32 -3
View File
@@ -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",
+16 -1
View File
@@ -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 价格计算,订阅账户也会显示)"
}