Skip to content

Commit 54ee4e1

Browse files
committed
feat(ai): show live turn usage and elapsed time
1 parent c8119d7 commit 54ee4e1

28 files changed

Lines changed: 188 additions & 85 deletions

‎electron/ai-edition/chat-persistence.test.ts‎

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ describe("local Agent chat history", () => {
9696
const messages = selectSession("proj_usage", session.id)?.messages ?? [];
9797
expect(messages).toHaveLength(success ? 2 : 1);
9898
expect(messages.at(-1)?.usage).toEqual(reportedUsage);
99+
expect(messages.at(-1)?.durationMs).toEqual(expect.any(Number));
99100
if (success) expect(messages[0]?.usage).toBeUndefined();
100101
expect(readFileSync(path.join(root, "proj_usage.chat.json"), "utf8")).not.toContain(key);
101102
});
@@ -184,14 +185,10 @@ describe("local Agent chat history", () => {
184185
expect(result.document).toBeUndefined();
185186
expect(result.assistantMessage).toBeUndefined();
186187
expect(result.userMessage).toBeUndefined();
187-
expect(selectSession(projectId, session.id)?.messages).toEqual([
188-
{ ...user, checkpointId: null },
189-
]);
188+
expect(selectSession(projectId, session.id)?.messages).toEqual([]);
190189
await restart();
191190
const restored = selectSession(projectId, session.id)?.messages;
192-
expect(restored).toHaveLength(1);
193-
expect(restored?.[0]).toMatchObject({ id: user.id, content: "pending question" });
194-
expect(restored?.[0].usage).toBeUndefined();
191+
expect(restored).toEqual([]);
195192
});
196193

197194
it("uses the actual session created for a stale id", async () => {

‎electron/ai-edition/chat-persistence.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ const messageSchema = z.object({
6161
createdAt: z.string().min(1),
6262
toolCalls: z.array(toolCallSchema).optional(),
6363
usage: optionalUsageSchema.optional(),
64+
durationMs: tokenCountSchema.optional().catch(undefined),
6465
});
6566
const sessionSchema = z.object({
6667
id: z.string().min(1),

‎electron/ai-edition/chat-service.toolloop.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,7 @@ describe("runChat tool loop", () => {
241241
invokeMock.mockImplementationOnce(async (args) => ({
242242
text: "",
243243
document: args.document,
244+
usage: createTurnUsageAccumulator().snapshot(),
244245
mutated: false,
245246
}));
246247
const session = createSession("proj_empty_reply");

‎electron/ai-edition/chat-service.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
} from "../../src/lib/ai-edition/schema";
1919
import type { ShaderEffectChecker } from "../../src/lib/ai-edition/shaderEffects";
2020
import type {
21+
AgentTurnUsage,
2122
AiEditionChatMessage,
2223
AiEditionChatResult,
2324
AiEditionToolCallSummary,
@@ -297,6 +298,7 @@ export function deleteSession(projectId: string, sessionId: string): boolean {
297298
}
298299

299300
export interface ChatEventSink {
301+
usage?: (usage: AgentTurnUsage) => void;
300302
/** Streamed text delta from the model. */
301303
text?: (delta: string) => void;
302304
/** Streamed delta from the model's reasoning block (Anthropic/MiniMax
@@ -329,6 +331,7 @@ const noop = () => undefined;
329331

330332
/** ponytail: zero-config sink that swallows every event. */
331333
const NOOP_SINK: Required<ChatEventSink> = {
334+
usage: noop,
332335
text: noop,
333336
thinking: noop,
334337
toolStart: noop,
@@ -434,6 +437,7 @@ export async function runChat(
434437
const appliedToolCalls: AiEditionToolCallSummary[] = [];
435438

436439
const agentSink = {
440+
usage: (usage: AgentTurnUsage) => emit.usage(usage),
437441
text: (delta: string) => emit.text(delta),
438442
thinking: (delta: string) => emit.thinking(delta),
439443
toolStart: (name: string, args: unknown) => {
@@ -450,6 +454,7 @@ export async function runChat(
450454

451455
const { invokeOpenScreenAgent } = await import("./deep-agent/service");
452456

457+
const startedAt = performance.now();
453458
const result = await invokeOpenScreenAgent({
454459
document: workingDocument ?? emptyDocumentForTextOnly(projectId),
455460
model: {
@@ -469,6 +474,8 @@ export async function runChat(
469474
checkShaderEffect: env.checkShaderEffect,
470475
});
471476

477+
const durationMs = Math.max(0, Math.round(performance.now() - startedAt));
478+
472479
// The project was deleted while the agent ran: its sessions are gone, so a
473480
// reply recorded now would be reported as sent and then never be seen again.
474481
if (sessionsByProject.get(projectId) !== sessions) {
@@ -494,6 +501,7 @@ export async function runChat(
494501
// missed). Falls back to the generic message when the agent didn't
495502
// provide a reason.
496503
originatingMessage.usage = result.usage;
504+
originatingMessage.durationMs = durationMs;
497505
persistProject(projectId);
498506
return {
499507
success: false,
@@ -510,6 +518,7 @@ export async function runChat(
510518
role: "assistant",
511519
content: result.text,
512520
usage: result.usage,
521+
durationMs,
513522
createdAt: new Date().toISOString(),
514523
toolCalls: appliedToolCalls.length ? appliedToolCalls : undefined,
515524
};

‎electron/ai-edition/deep-agent/service.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,8 @@ import {
110110
import { createTurnUsageAccumulator } from "./usage";
111111

112112
export interface OpenScreenAgentSink {
113+
/** Latest cumulative usage from completed model calls; never sum stream chunks. */
114+
usage?: (usage: AgentTurnUsage) => void;
113115
text: (delta: string) => void;
114116
/** Streaming delta from the model's reasoning block (Anthropic/MiniMax
115117
* thinking). Provider-agnostic — for providers without thinking this is
@@ -733,6 +735,9 @@ export async function invokeOpenScreenAgent(args: InvokeArgs): Promise<InvokeRes
733735

734736
for await (const event of stream) {
735737
usage.observe(event);
738+
if (event.event === "on_chat_model_end" || event.event === "on_chat_model_error") {
739+
sink.usage?.(usage.snapshot());
740+
}
736741
const eventType = typeof event.event === "string" ? event.event : "";
737742
const data = event.data as Record<string, unknown> | undefined;
738743
const name = typeof event.name === "string" ? (event.name as string) : "";

‎electron/ai-edition/deep-agent/usage.integration.test.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,7 @@ it.each([
241241
});
242242
if (mode === "anthropic") expect(accumulator.snapshot().cacheCreationInputTokens).toBe(3);
243243
requests = 0;
244+
const usageUpdates = vi.fn();
244245
const result = await invokeOpenScreenAgent({
245246
document: createEmptyDocument({ projectId: "usage", title: "usage" }),
246247
model: {
@@ -263,13 +264,19 @@ it.each([
263264
history: [],
264265
userMessage: "Read",
265266
sink: {
267+
usage: usageUpdates,
266268
text: vi.fn(),
267269
thinking: vi.fn(),
268270
toolStart: vi.fn(),
269271
toolEnd: vi.fn(),
270272
error: vi.fn(),
271273
},
272274
});
275+
expect(usageUpdates).toHaveBeenCalled();
276+
expect(usageUpdates.mock.lastCall?.[0].totalTokens).toBe(result.usage.totalTokens);
277+
if (mode === "chat") {
278+
expect(usageUpdates.mock.calls.map(([value]) => value.totalTokens)).toEqual([15, 30]);
279+
}
273280
if (mode === "empty") expect(result.reason).toContain("Empty response");
274281
expect(result.usage).toMatchObject({
275282
inputTokens: mode === "anthropic" ? 17 : mode === "chat" ? 20 : 10,

‎electron/ipc/nativeBridge.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,7 @@ function buildChatEventSink(sender: Electron.WebContents, sessionId: string): Ch
194194
return {
195195
text: (delta) => send({ kind: "text", sessionId, delta }),
196196
thinking: (delta) => send({ kind: "thinking", sessionId, delta }),
197+
usage: (usage) => send({ kind: "usage", sessionId, usage }),
197198
toolStart: (name, args) => send({ kind: "toolStart", sessionId, name, args }),
198199
toolEnd: (name, ok, summary) => send({ kind: "toolEnd", sessionId, name, ok, summary }),
199200
error: (message) => send({ kind: "error", sessionId, message }),

‎src/components/ai-edition/ChatUsageSummary.test.tsx‎

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16,22 +16,22 @@ const complete: AgentTurnUsage = {
1616
countedCallCount: 3,
1717
status: "complete",
1818
};
19-
const show = (usage?: AgentTurnUsage) =>
19+
const show = (usage?: AgentTurnUsage, durationMs?: number, running = false) =>
2020
render(
2121
<I18nProvider>
2222
<TooltipProvider>
23-
<ChatUsageSummary usage={usage} />
23+
<ChatUsageSummary usage={usage} durationMs={durationMs} running={running} />
2424
</TooltipProvider>
2525
</I18nProvider>,
2626
);
2727
describe("ChatUsageSummary", () => {
28-
it("shows totals and model calls with accessible accounting details", () => {
28+
it("shows a concise total with accounting details in the tooltip", () => {
2929
const view = show(complete);
30-
expect(view.getByText("660 tokens · 3 model calls")).toHaveAttribute(
30+
expect(view.getByText("660 tokens")).toHaveAttribute(
3131
"aria-label",
3232
expect.stringContaining("Input: 500"),
3333
);
34-
expect(view.getByText("660 tokens · 3 model calls")).toHaveAttribute(
34+
expect(view.getByText("660 tokens")).toHaveAttribute(
3535
"aria-label",
3636
expect.stringContaining("3 of 3 model calls counted"),
3737
);
@@ -53,22 +53,40 @@ describe("ChatUsageSummary", () => {
5353
});
5454
it("shows explicitly reported zero", () => {
5555
expect(
56-
show({ ...complete, inputTokens: 0, outputTokens: 0, totalTokens: 0 }).getByText(
57-
"0 tokens · 3 model calls",
58-
),
56+
show({ ...complete, inputTokens: 0, outputTokens: 0, totalTokens: 0 }).getByText("0 tokens"),
5957
).toBeInTheDocument();
6058
});
6159
it("labels optional details as reported counts and omits missing details", () => {
6260
const view = show({ ...complete, cachedInputTokens: 0, reasoningOutputTokens: 12 });
63-
const label = view.getByText("660 tokens · 3 model calls").getAttribute("aria-label");
61+
const label = view.getByText("660 tokens").getAttribute("aria-label");
6462
expect(label).toContain("Reported cached input: 0");
6563
expect(label).toContain("Reported reasoning output: 12");
6664
expect(label).not.toContain("cache creation");
6765
});
6866
it("formats counts for the selected locale", () => {
6967
localStorage.setItem("openscreen-locale", "de");
70-
const view = show({ ...complete, totalTokens: 1234 });
71-
expect(view.getByText(/1.234/)).toBeInTheDocument();
68+
const view = show({ ...complete, totalTokens: 12340 });
69+
expect(view.getByText(/12,3k/)).toBeInTheDocument();
7270
localStorage.clear();
7371
});
72+
it("shows compact totals and elapsed time, with no visible breakdown or model-call count", () => {
73+
const view = show({ ...complete, totalTokens: 12340 }, 45000);
74+
expect(view.getByText("12.3k tokens · 45s")).toBeInTheDocument();
75+
expect(view.container.textContent).not.toMatch(/Input:|Output:|model calls/);
76+
});
77+
it("keeps elapsed time on unavailable usage without claiming zero tokens", () => {
78+
const view = show(
79+
{ ...complete, status: "unavailable", countedCallCount: 0, totalTokens: 0 },
80+
1200,
81+
);
82+
expect(view.getByText("Token usage unavailable · 1s")).toBeInTheDocument();
83+
});
84+
it("shows reported running counts as a lower bound and hides unavailable running usage", () => {
85+
const view = show(complete, undefined, true);
86+
expect(view.getByText("≥660 tokens")).toBeInTheDocument();
87+
view.unmount();
88+
expect(
89+
show({ ...complete, status: "unavailable" }, undefined, true).container,
90+
).toBeEmptyDOMElement();
91+
});
7492
});

‎src/components/ai-edition/ChatUsageSummary.tsx‎

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,22 +3,41 @@ import { useI18n, useScopedT } from "@/contexts/I18nContext";
33
import type { AgentTurnUsage } from "@/native/contracts";
44

55
/** Provider-reported usage for this turn, separate from the context-window budget. */
6-
export function ChatUsageSummary({ usage }: { usage?: AgentTurnUsage }) {
6+
export function ChatUsageSummary({
7+
usage,
8+
durationMs,
9+
running = false,
10+
}: {
11+
usage?: AgentTurnUsage;
12+
durationMs?: number;
13+
running?: boolean;
14+
}) {
715
const { locale } = useI18n();
816
const t = useScopedT("editor");
9-
if (!usage) return null;
17+
if (!usage || (running && usage.status === "unavailable")) return null;
1018
const format = (value: number) => new Intl.NumberFormat(locale).format(value);
19+
const tokens =
20+
usage.totalTokens >= 1000
21+
? `${new Intl.NumberFormat(locale, { maximumFractionDigits: 1 }).format(usage.totalTokens / 1000)}k`
22+
: format(usage.totalTokens);
23+
const summaryTokens = running ? `≥${tokens}` : tokens;
1124
const summary =
1225
usage.status === "unavailable"
1326
? t("chat.usageUnavailable")
14-
: usage.status === "partial"
15-
? t("chat.usagePartial", { tokens: format(usage.totalTokens) })
16-
: t("chat.usageComplete", {
17-
tokens: format(usage.totalTokens),
18-
calls: format(usage.modelCallCount),
19-
});
27+
: !running && usage.status === "partial"
28+
? t("chat.usagePartial", { tokens: summaryTokens })
29+
: t("chat.usageComplete", { tokens: summaryTokens });
30+
const duration =
31+
!running && durationMs !== undefined
32+
? ` · ${new Intl.NumberFormat(locale, {
33+
style: "unit",
34+
unit: "second",
35+
unitDisplay: "narrow",
36+
maximumFractionDigits: 0,
37+
}).format(Math.round(durationMs / 1000))}`
38+
: "";
2039
const details = [
21-
summary,
40+
`${summary}${duration}`,
2241
t("chat.usageCoverage", {
2342
counted: format(usage.countedCallCount),
2443
calls: format(usage.modelCallCount),
@@ -50,6 +69,7 @@ export function ChatUsageSummary({ usage }: { usage?: AgentTurnUsage }) {
5069
}}
5170
>
5271
{summary}
72+
{duration}
5373
</span>
5474
</Tooltip>
5575
);

0 commit comments

Comments
 (0)