mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
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:
co-authored by
matthiasdemauroy
parent
4eda613c59
commit
39fe96bd01
@@ -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(
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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 }),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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,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 &&
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 价格计算,订阅账户也会显示)"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user