From 93f87f49c176dfee983ab4dce2e2e5ffb35ed4c9 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Wed, 28 Oct 2026 11:57:18 +0800 Subject: [PATCH] feat(agents): Current branch and time spent in the agent session header A local-checkout task shows the repository's current branch in its header, and Changes compares against it; both follow branch switches. The header also shows the time the agent has spent on the task's turns. A usage update without a cost (Claude sends one for its rate limits) no longer clears the session cost. --- crates/ide-agent/src/config.rs | 32 +++++---- crates/ide-agent/src/lib.rs | 2 +- src/features/agents/Session.module.css | 10 ++- src/features/agents/SessionEditor.tsx | 90 ++++++++++++++++++++------ src/features/agents/agents.test.ts | 20 ++++++ src/features/agents/composerModel.ts | 32 +++++++-- src/platform/locales/text/zh.json | 1 + 7 files changed, 149 insertions(+), 38 deletions(-) diff --git a/crates/ide-agent/src/config.rs b/crates/ide-agent/src/config.rs index 59fbfe18..ea0c33c2 100644 --- a/crates/ide-agent/src/config.rs +++ b/crates/ide-agent/src/config.rs @@ -104,17 +104,17 @@ pub(crate) fn commands_of(update: &Value) -> Vec { .collect() } -/// `usage_update`: `{ used, size, cost?: { amount, currency } }`. -pub(crate) fn usage_of(update: &Value) -> Option { +/// `usage_update`: `{ used, size, cost?: { amount, currency } }`. An update without a cost +/// keeps the last one (Claude also sends one for its rate limits, without the cost). +pub(crate) fn usage_of(update: &Value, prev: Option) -> Option { let tokens = |key: &str| update.get(key)?.as_f64().map(|n| n.clamp(0.0, f64::from(u32::MAX)) as u32); - let used = tokens("used")?; - let size = tokens("size").filter(|s| *s > 0)?; - Some(AgentUsage { - used, - size, - cost: update.pointer("/cost/amount").and_then(Value::as_f64), - currency: update.pointer("/cost/currency").and_then(Value::as_str).map(str::to_owned), - }) + let (Some(used), Some(size)) = (tokens("used"), tokens("size").filter(|s| *s > 0)) else { return prev }; + let (cost, currency) = match (update.pointer("/cost/amount").and_then(Value::as_f64), prev) { + (Some(cost), _) => (Some(cost), update.pointer("/cost/currency").and_then(Value::as_str).map(str::to_owned)), + (None, Some(p)) => (p.cost, p.currency), + (None, None) => (None, None), + }; + Some(AgentUsage { used, size, cost, currency }) } fn legacy( @@ -217,8 +217,16 @@ mod tests { ]})); assert_eq!(cmds[0].hint.as_deref(), Some("what to review")); assert_eq!((cmds[1].name.as_str(), cmds[1].hint.as_ref()), ("init", None)); - let u = usage_of(&json!({"used": 1200, "size": 200000, "cost": {"amount": 0.42, "currency": "USD"}})).unwrap(); + let u = usage_of(&json!({"used": 1200, "size": 200000, "cost": {"amount": 0.42, "currency": "USD"}}), None) + .unwrap(); assert_eq!((u.used, u.cost, u.currency.as_deref()), (1200, Some(0.42), Some("USD"))); - assert!(usage_of(&json!({"used": 1, "size": 0})).is_none()); + assert!(usage_of(&json!({"used": 1, "size": 0}), None).is_none()); + } + + #[test] + fn usage_without_a_cost_keeps_the_last_one() { + let u = usage_of(&json!({"used": 100, "size": 1000, "cost": {"amount": 1.5, "currency": "USD"}}), None); + let u = usage_of(&json!({"used": 200, "size": 1000, "_meta": {"_claude/rateLimit": {}}}), u).unwrap(); + assert_eq!((u.used, u.cost, u.currency.as_deref()), (200, Some(1.5), Some("USD"))); } } diff --git a/crates/ide-agent/src/lib.rs b/crates/ide-agent/src/lib.rs index 437dac00..584962e1 100644 --- a/crates/ide-agent/src/lib.rs +++ b/crates/ide-agent/src/lib.rs @@ -915,7 +915,7 @@ impl Task { true } "usage_update" => { - s.task.usage = config::usage_of(update).or(s.task.usage.take()); + s.task.usage = config::usage_of(update, s.task.usage.take()); true } _ => false, diff --git a/src/features/agents/Session.module.css b/src/features/agents/Session.module.css index e4581ca4..1d882ce7 100644 --- a/src/features/agents/Session.module.css +++ b/src/features/agents/Session.module.css @@ -435,14 +435,20 @@ white-space: nowrap; } -.usage { +.meters { flex: none; display: inline-flex; align-items: center; - gap: 5px; + gap: 10px; color: var(--fg-subtle); font-size: var(--fs-small); font-variant-numeric: tabular-nums; + white-space: nowrap; +} +.meter { + display: inline-flex; + align-items: center; + gap: 5px; } .usageBar { width: 36px; diff --git a/src/features/agents/SessionEditor.tsx b/src/features/agents/SessionEditor.tsx index 31f52f35..2fb2e527 100644 --- a/src/features/agents/SessionEditor.tsx +++ b/src/features/agents/SessionEditor.tsx @@ -1,9 +1,9 @@ /** An agent task as an editor tab: the conversation, a composer and the task's changed files. */ import { useEffect, useLayoutEffect, useMemo, useRef, useState, type RefObject } from "react"; import clsx from "clsx"; -import type { AgentChange, AgentEntry, AgentTask } from "@/bindings"; +import type { AgentChange, AgentEntry, AgentTask, AgentUsage } from "@/bindings"; import { executeAction } from "@/platform/actions"; -import { call, commands } from "@/platform/ipc"; +import { call, commands, onIdeEvent } from "@/platform/ipc"; import { basename, relativeDir, useProject } from "@/platform/project"; import { editorService, notifications, type EditorInput } from "@/workbench/contributions"; import { @@ -24,7 +24,7 @@ import { import { useTx } from "@/platform/i18n"; import { TaskStatusIcon } from "./AgentsView"; import { Composer } from "./Composer"; -import { canSend, emptyDraft, usageLabel } from "./composerModel"; +import { canSend, emptyDraft, usageLabel, workTime } from "./composerModel"; import { ConfigPickers } from "./ConfigPickers"; import { findRanges, formatDuration, formatSentAt } from "./conversation"; import { Markdown } from "./Markdown"; @@ -55,6 +55,7 @@ export function SessionEditor({ input }: { input: EditorInput }) { const task = useAgents((s) => s.tasks.get(id)); const entries = useAgents((s) => s.entries.get(id)); const changes = useAgents((s) => s.changes.get(id)); + const head = useLocalHead(task?.isolation === "local" ? task.status : null); useEffect(() => { void loadEntries(id).catch(() => undefined); void refreshChanges(id); @@ -86,7 +87,7 @@ export function SessionEditor({ input }: { input: EditorInput }) { }} >
-
+
- + ); } @@ -106,7 +107,32 @@ function providerName(id: string) { return useAgents.getState().providers.find((p) => p.id === id)?.name ?? id; } -function Header({ task }: { task: AgentTask }) { +/** + * Current branch of the project's repository for a local-checkout task (`status` is + * `null` otherwise); followed as git changes, since the agent or the user may switch it. + */ +function useLocalHead(status: AgentTask["status"] | null): string | null { + const [head, setHead] = useState(null); + const local = status !== null; + useEffect(() => { + if (!local) return; + let live = true; + const load = () => + void call(commands.vcsBranchInfo(null)).then( + (b) => live && setHead(b.head ?? b.headCommit?.slice(0, 8) ?? null), + () => live && setHead(null), + ); + load(); + const off = onIdeEvent("vcsChanged", load); + return () => { + live = false; + off(); + }; + }, [local, status]); + return local ? head : null; +} + +function Header({ task, entries, head }: { task: AgentTask; entries: AgentEntry[] | null; head: string | null }) { const busy = isBusy(task.status); const where = task.isolation === "worktree" ? branchLabel(task.branch) : null; return ( @@ -121,12 +147,17 @@ function Header({ task }: { task: AgentTask }) { {providerName(task.provider)} {task.model ? ` · ${task.model}` : ""} {" · "} - {where ?? Local checkout} + {where ?? ( + <> + Local checkout + {head ? ` · ${head}` : ""} + + )} {" · "} {statusLabel(task.status)} - {task.usage && } + {busy && void cancelTask(task.id)} />} + + {usage && } + {work >= 1000 && {formatDuration(work)}} + + ); +} + +function Usage({ usage }: { usage: AgentUsage }) { + const label = usageLabel(usage); + return ( + @@ -154,6 +200,16 @@ function Usage({ task }: { task: AgentTask }) { ); } +/** `Date.now()`, updated every `interval` ms. */ +function useNow(interval: number): number { + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + const t = setInterval(() => setNow(Date.now()), interval); + return () => clearInterval(t); + }, [interval]); + return now; +} + interface StreamProps { task: AgentTask; entries: AgentEntry[] | null; @@ -217,11 +273,7 @@ type UserEntry = Extract; /** Time since `since`, ticking every second. */ function Elapsed({ since }: { since: number }) { - const [now, setNow] = useState(() => Date.now()); - useEffect(() => { - const t = setInterval(() => setNow(Date.now()), 1000); - return () => clearInterval(t); - }, []); + const now = useNow(1000); return {formatDuration(now - since)}; } @@ -644,7 +696,7 @@ const KIND_COLOR: Partial> = { conflicted: "var(--vcs-conflict)", }; -function ChangesPane({ task, changes }: { task: AgentTask; changes: AgentChange[] | null }) { +function ChangesPane({ task, changes, head }: { task: AgentTask; changes: AgentChange[] | null; head: string | null }) { const [selected, setSelected] = useState>(() => new Set()); const added = changes?.reduce((n, c) => n + c.added, 0) ?? 0; const removed = changes?.reduce((n, c) => n + c.removed, 0) ?? 0; @@ -662,9 +714,9 @@ function ChangesPane({ task, changes }: { task: AgentTask; changes: AgentChange[ void refreshChanges(task.id)} /> - {task.base && ( + {(head ?? task.base) && (
- vs {task.base} + vs {head ?? task.base} {changes && changes.length > 0 && ( <> {" "} diff --git a/src/features/agents/agents.test.ts b/src/features/agents/agents.test.ts index 0718f5e8..b8b4c49b 100644 --- a/src/features/agents/agents.test.ts +++ b/src/features/agents/agents.test.ts @@ -14,6 +14,7 @@ import { recall, toPrompt, usageLabel, + workTime, } from "./composerModel"; import { age, attentionChange, branchLabel, groupTasks, upsertEntry } from "./model"; import { markdownHtml } from "./Markdown"; @@ -132,6 +133,25 @@ describe("agent composer", () => { }); }); +describe("usage meters", () => { + it("formats token counts", () => { + expect(usageLabel({ used: 83_100, size: 1_000_000, cost: null, currency: null }).detail).toBe("83.1k / 1M tokens"); + }); + + it("adds up the time spent on turns", () => { + const user = (sentMs: number, doneMs: number | null): AgentEntry => ({ + type: "user", + text: "", + attachments: [], + sentMs, + doneMs, + }); + const entries = [user(0, 5_000), { type: "agent", text: "" } as AgentEntry, user(10_000, null)]; + expect(workTime(entries, 12_000)).toBe(7_000); + expect(workTime(entries, null)).toBe(5_000); + }); +}); + describe("agents model", () => { it("groups tasks by what they need, newest first", () => { const groups = groupTasks([ diff --git a/src/features/agents/composerModel.ts b/src/features/agents/composerModel.ts index 0e02294a..cb8c2cb5 100644 --- a/src/features/agents/composerModel.ts +++ b/src/features/agents/composerModel.ts @@ -1,5 +1,5 @@ /** Pure composer helpers: `@` / `/` completion, attachments, history recall, settings. */ -import type { AgentCommand, AgentConfigOption, AgentImage, AgentPrompt, AgentUsage } from "@/bindings"; +import type { AgentCommand, AgentConfigOption, AgentEntry, AgentImage, AgentPrompt, AgentUsage } from "@/bindings"; import { tx } from "@/platform/i18n"; export interface FileRef { @@ -131,9 +131,33 @@ export function nextValue(option: AgentConfigOption, current = option.current): /** "12%" of the context window and a tooltip with the numbers. */ export function usageLabel(u: AgentUsage): { short: string; detail: string } { const pct = Math.min(100, Math.round((u.used / u.size) * 100)); - const k = (n: number) => (n >= 1000 ? `${Math.round(n / 1000)}k` : `${Math.round(n)}`); - const cost = u.cost != null ? ` · ${u.currency === "USD" || !u.currency ? "$" : `${u.currency} `}${u.cost.toFixed(2)}` : ""; - return { short: `${pct}%`, detail: tx`${k(u.used)} / ${k(u.size)} tokens${cost}` }; + const cost = u.cost != null ? ` · ${costLabel(u)}` : ""; + return { short: `${pct}%`, detail: tx`${tokens(u.used)} / ${tokens(u.size)} tokens${cost}` }; +} + +/** `850`, `83.1k`, `1M`. */ +function tokens(n: number): string { + if (n >= 1_000_000) return `${+(n / 1_000_000).toFixed(1)}M`; + if (n >= 1000) return `${+(n / 1000).toFixed(n >= 100_000 ? 0 : 1)}k`; + return `${Math.round(n)}`; +} + +/** Session cost (`$4.23`), when the agent reports one. */ +function costLabel(u: AgentUsage): string | null { + if (u.cost == null) return null; + return `${u.currency === "USD" || !u.currency ? "$" : `${u.currency} `}${u.cost.toFixed(2)}`; +} + +/** Time the agent spent on the task's turns; a running one counts up to `now` (`null` when idle). */ +export function workTime(entries: readonly AgentEntry[], now: number | null): number { + const users = entries.filter((e): e is Extract => e.type === "user"); + let total = 0; + users.forEach((e, i) => { + if (e.sentMs == null) return; + const end = e.doneMs ?? (i === users.length - 1 ? now : null); + if (end != null && end > e.sentMs) total += end - e.sentMs; + }); + return total; } /** `data:image/png;base64,AAA` → an image attachment. */ diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 4c6a1678..053b9b93 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -4197,6 +4197,7 @@ "Find in conversation": "在对话中查找", "0 results": "0 个结果", "Time the agent took to answer": "智能体回答所用的时间", + "Time the agent spent on this task": "智能体在此任务上花费的时间", "Edit and Resend": "编辑后重新发送", "Send Again": "再次发送", "Customize IDE appearance and behavior: change themes and font size, configure system settings and notifications.": "自定义 IDE 的外观和行为:更改主题和字号,配置系统设置和通知。",