mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
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:
@@ -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")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 && (
|
||||
<>
|
||||
{" "}
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 的外观和行为:更改主题和字号,配置系统设置和通知。",
|
||||
|
||||
Reference in New Issue
Block a user