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.
This commit is contained in:
l0ng-ai
2026-10-28 11:57:18 +08:00
parent 6e4d927ff0
commit 93f87f49c1
7 changed files with 149 additions and 38 deletions
+20 -12
View File
@@ -104,17 +104,17 @@ pub(crate) fn commands_of(update: &Value) -> Vec<AgentCommand> {
.collect()
}
/// `usage_update`: `{ used, size, cost?: { amount, currency } }`.
pub(crate) fn usage_of(update: &Value) -> Option<AgentUsage> {
/// `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<AgentUsage>) -> Option<AgentUsage> {
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")));
}
}
+1 -1
View File
@@ -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,
+8 -2
View File
@@ -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;
+71 -19
View File
@@ -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 }) {
}}
>
<div className={styles.conversation}>
<Header task={task} />
<Header task={task} entries={entries ?? null} head={head} />
<PermissionBanner task={task} entries={entries ?? null} />
<Stream
task={task}
@@ -97,7 +98,7 @@ export function SessionEditor({ input }: { input: EditorInput }) {
/>
<SessionComposer task={task} entries={entries ?? null} />
</div>
<ChangesPane task={task} changes={changes ?? null} />
<ChangesPane task={task} changes={changes ?? null} head={head} />
</div>
);
}
@@ -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<string | null>(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 ?? <T>Local checkout</T>}
{where ?? (
<>
<T>Local checkout</T>
{head ? ` · ${head}` : ""}
</>
)}
{" · "}
<T>{statusLabel(task.status)}</T>
</span>
</span>
{task.usage && <Usage task={task} />}
<Meters task={task} entries={entries} />
{busy && <IconButton icon="stop" title="Stop" size="small" onClick={() => void cancelTask(task.id)} />}
<IconButton
icon="terminal"
@@ -142,10 +173,25 @@ function Header({ task }: { task: AgentTask }) {
}
/** Context window use: "12%", the numbers (and cost) in the tooltip. */
function Usage({ task }: { task: AgentTask }) {
const label = usageLabel(task.usage!);
/** Context use (when the agent reports it) and the time spent on the task. */
function Meters({ task, entries }: { task: AgentTask; entries: AgentEntry[] | null }) {
const tx = useTx();
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;
return (
<span className={styles.usage} title={label.detail} aria-label={`Context ${label.short}, ${label.detail}`}>
<span className={styles.meters}>
{usage && <Usage usage={usage} />}
{work >= 1000 && <span title={tx("Time the agent spent on this task")}>{formatDuration(work)}</span>}
</span>
);
}
function Usage({ usage }: { usage: AgentUsage }) {
const label = usageLabel(usage);
return (
<span className={styles.meter} title={label.detail} aria-label={`Context ${label.short}, ${label.detail}`}>
<span className={styles.usageBar} aria-hidden>
<span style={{ width: label.short }} />
</span>
@@ -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<AgentEntry, { type: "user" }>;
/** 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 <span className={styles.muted}>{formatDuration(now - since)}</span>;
}
@@ -644,7 +696,7 @@ const KIND_COLOR: Partial<Record<AgentChange["kind"], string>> = {
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<Set<string>>(() => 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[
<span className={styles.bannerSpacer} />
<IconButton icon="refresh" title="Refresh" size="small" onClick={() => void refreshChanges(task.id)} />
</div>
{task.base && (
{(head ?? task.base) && (
<div className={styles.changesSummary}>
<T>vs</T> {task.base}
<T>vs</T> {head ?? task.base}
{changes && changes.length > 0 && (
<>
{" "}
+20
View File
@@ -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([
+28 -4
View File
@@ -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<AgentEntry, { type: "user" }> => 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. */
+1
View File
@@ -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 的外观和行为:更改主题和字号,配置系统设置和通知。",