Skip to content

Commit 416d8a0

Browse files
committed
feat(server, web): add context usage tracking and display in chat component; enhance event handling and tests
1 parent c24f0f8 commit 416d8a0

8 files changed

Lines changed: 257 additions & 8 deletions

File tree

server/src/agent/events.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,25 @@
33
* frontend consumes. We deliberately flatten the streaming deltas and drop
44
* Pi-internal lifecycle noise so the client contract stays small.
55
*/
6-
import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
6+
import type { AgentSessionEvent, ContextUsage } from "@earendil-works/pi-coding-agent";
77
import { skillLabelForRead } from "./skill-label.ts";
88

99
export interface ClientFrame {
1010
type: string;
1111
[k: string]: unknown;
1212
}
1313

14+
/** Keep Pi's context-utilization shape explicit on the wire. */
15+
export function contextUsageFrame(usage: ContextUsage | undefined): ClientFrame | null {
16+
if (!usage) return null;
17+
return {
18+
type: "context_usage",
19+
tokens: usage.tokens,
20+
contextWindow: usage.contextWindow,
21+
percent: usage.percent,
22+
};
23+
}
24+
1425
/** Frontmatter skill name when a `read` call is a skill activation. */
1526
export function skillFieldFor(
1627
toolName: string,

server/src/api/sessions.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import type { FastifyInstance } from "fastify";
1010
import { activePaths, getProject, touchProject } from "../projects.ts";
1111
import { corsResponseHeaders } from "../cors.ts";
1212
import { currentProjectId } from "../scope.ts";
13-
import { toClientFrame, type ClientFrame } from "../agent/events.ts";
13+
import { contextUsageFrame, toClientFrame, type ClientFrame } from "../agent/events.ts";
1414
import { setFusionConfig } from "../agent/fusion-bridge.ts";
1515
import {
1616
pendingInterviewFor,
@@ -113,7 +113,11 @@ export async function registerSessionRoutes(app: FastifyInstance): Promise<void>
113113
reply.code(404);
114114
return { detail: "No such session" };
115115
}
116-
return { messages: toHistory(file, paths.sandbox) };
116+
const session = await getSession(currentProjectId(), paths, req.params.id);
117+
return {
118+
messages: toHistory(file, paths.sandbox),
119+
contextUsage: session?.getContextUsage() ?? null,
120+
};
117121
} catch (err) {
118122
reply.code(400);
119123
return { detail: (err as Error).message };
@@ -484,10 +488,15 @@ export async function registerSessionRoutes(app: FastifyInstance): Promise<void>
484488
const write = (frame: ClientFrame) => {
485489
if (!raw.writableEnded) raw.write(`data: ${JSON.stringify(frame)}\n\n`);
486490
};
491+
const writeContextUsage = () => {
492+
const frame = contextUsageFrame(session.getContextUsage());
493+
if (frame) write(frame);
494+
};
487495
// Synthetic route-level frame (Pi events carry no run id): lets the
488496
// client stamp provisional notebook entries with this run before the
489497
// authoritative refetch.
490498
write({ type: "run_start", runId });
499+
writeContextUsage();
491500

492501
// Hard budget cap: refuse to run if the project has reached its limit.
493502
const budget = isBudgetExceeded(projectId);
@@ -518,6 +527,7 @@ export async function registerSessionRoutes(app: FastifyInstance): Promise<void>
518527
}
519528
const frame = toClientFrame(ev, sandboxRoot);
520529
if (frame) write(frame);
530+
if (ev.type === "turn_end") writeContextUsage();
521531
});
522532

523533
req.raw.on("close", () => {
@@ -559,6 +569,7 @@ export async function registerSessionRoutes(app: FastifyInstance): Promise<void>
559569
// restart/compaction-proof); `tokens` is Pi's in-context cumulative;
560570
// `runCost`/`runTokens` are the delta for THIS turn, so the UI can
561571
// attribute a price to the message that just completed.
572+
writeContextUsage();
562573
write({
563574
type: "cost",
564575
cost: sessionCostSummary(req.params.id, projectId).totalUsd,

server/test/backend.test.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,11 @@ import {
3131
} from "../src/agent/web-access-bridge.ts";
3232
import { guessMime, isUserVisible } from "../src/sandbox-fs.ts";
3333
import { listProjectSkills, seedProjectSkills } from "../src/agent/skills.ts";
34-
import { toClientFrame, relativizeSandboxPaths } from "../src/agent/events.ts";
34+
import {
35+
contextUsageFrame,
36+
toClientFrame,
37+
relativizeSandboxPaths,
38+
} from "../src/agent/events.ts";
3539
import { helperPython, HELPERS_DIR } from "../src/helpers-env.ts";
3640
import { sciHelperFor } from "../src/api/sci-helpers.ts";
3741

@@ -255,6 +259,22 @@ describe("skills", () => {
255259
});
256260

257261
describe("events → client frames", () => {
262+
it("maps Pi context utilization onto the client wire shape", () => {
263+
expect(
264+
contextUsageFrame({
265+
tokens: 42_000,
266+
contextWindow: 200_000,
267+
percent: 21,
268+
}),
269+
).toEqual({
270+
type: "context_usage",
271+
tokens: 42_000,
272+
contextWindow: 200_000,
273+
percent: 21,
274+
});
275+
expect(contextUsageFrame(undefined)).toBeNull();
276+
});
277+
258278
it("maps text/thinking deltas and tool/lifecycle events", () => {
259279
expect(toClientFrame({ type: "agent_start" } as never)).toEqual({ type: "agent_start" });
260280
expect(

web/src/components/chat-tab.tsx

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import { onChatPrefill } from "@/lib/chat-prefill";
4141
import { buildSkillsContext, type Skill } from "@/components/skills-selector";
4242
import { AddContextMenu } from "@/components/add-context-menu";
4343
import { ContextChipsBar } from "@/components/context-chips";
44+
import { ContextUsageIndicator } from "@/components/context-usage-indicator";
4445
import { CitationBadge } from "@/components/citation-badge";
4546
import { NotebookEntryChip, ReasoningBlock, ToolActivityList } from "@/components/tool-activity";
4647
import { InterviewCard } from "@/components/interview-form";
@@ -54,7 +55,12 @@ import {
5455
type PromptImage,
5556
} from "@/lib/image-attachments";
5657
import { suggestSkillsForFiles } from "@/lib/skill-suggestions";
57-
import { useAgent, type ActivityItem, type ChatMessage } from "@/lib/use-agent";
58+
import {
59+
useAgent,
60+
type ActivityItem,
61+
type ChatMessage,
62+
type ContextUsage,
63+
} from "@/lib/use-agent";
5864
import type { NotebookEntry } from "@/lib/notebook";
5965
import { routeSubmit, steerNotStreamingFallback, type SendIntent } from "@/lib/chat-routing";
6066
import { SpeechInput } from "@/components/ai-elements/speech-input";
@@ -462,6 +468,7 @@ function ChatInput({
462468
onDbsChange,
463469
selectedModel,
464470
onModelChange,
471+
contextUsage,
465472
selectedComputeTarget,
466473
onComputeTargetChange,
467474
thinkingLevel,
@@ -495,6 +502,7 @@ function ChatInput({
495502
onDbsChange: (dbs: Database[]) => void;
496503
selectedModel: Model;
497504
onModelChange: (model: Model) => void;
505+
contextUsage: ContextUsage | null;
498506
selectedComputeTarget: ModalInstance | null;
499507
onComputeTargetChange: (instance: ModalInstance | null) => void;
500508
thinkingLevel: ThinkingLevel;
@@ -823,6 +831,7 @@ function ChatInput({
823831
onChange={onComputeTargetChange}
824832
modalConfigured={modalConfigured}
825833
/>
834+
<ContextUsageIndicator usage={contextUsage} />
826835
</div>
827836
<div className="flex items-center gap-1.5 shrink-0">
828837
<InfoTooltip
@@ -1057,7 +1066,19 @@ export const ChatTab = forwardRef<ChatTabHandle, ChatTabProps>(function ChatTab(
10571066
},
10581067
ref,
10591068
) {
1060-
const { messages, status, send, stop, steer, pendingSteers, getSessionId, loadSession, notebookEntries, subagentCompletions } = useAgent();
1069+
const {
1070+
messages,
1071+
contextUsage,
1072+
status,
1073+
send,
1074+
stop,
1075+
steer,
1076+
pendingSteers,
1077+
getSessionId,
1078+
loadSession,
1079+
notebookEntries,
1080+
subagentCompletions,
1081+
} = useAgent();
10611082
const isStreaming = status === "streaming" || status === "submitted";
10621083
// Scopes the deep-link querySelector to THIS tab's transcript.
10631084
const rootRef = useRef<HTMLDivElement>(null);
@@ -1468,6 +1489,7 @@ export const ChatTab = forwardRef<ChatTabHandle, ChatTabProps>(function ChatTab(
14681489
onDbsChange={setSelectedDbs}
14691490
selectedModel={selectedModel}
14701491
onModelChange={setSelectedModel}
1492+
contextUsage={contextUsage}
14711493
selectedComputeTarget={selectedComputeTarget}
14721494
onComputeTargetChange={setSelectedComputeTarget}
14731495
thinkingLevel={thinkingLevel}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { render, screen } from "@testing-library/react";
2+
import { describe, expect, it } from "vitest";
3+
4+
import { TooltipProvider } from "@/components/ui/tooltip";
5+
import { ContextUsageIndicator } from "./context-usage-indicator";
6+
7+
describe("ContextUsageIndicator", () => {
8+
it("shows Pi context utilization accessibly", () => {
9+
render(
10+
<TooltipProvider>
11+
<ContextUsageIndicator
12+
usage={{ tokens: 42_000, contextWindow: 200_000, percent: 21 }}
13+
/>
14+
</TooltipProvider>,
15+
);
16+
17+
expect(screen.getByRole("status")).toHaveTextContent("21%");
18+
expect(screen.getByRole("status")).toHaveAccessibleName(
19+
"Model context 21.0 percent, 42,000 of 200,000 tokens",
20+
);
21+
});
22+
23+
it("shows the post-compaction unknown state and hides without usage", () => {
24+
const { rerender } = render(
25+
<TooltipProvider>
26+
<ContextUsageIndicator
27+
usage={{ tokens: null, contextWindow: 200_000, percent: null }}
28+
/>
29+
</TooltipProvider>,
30+
);
31+
expect(screen.getByRole("status")).toHaveTextContent("?%");
32+
33+
rerender(
34+
<TooltipProvider>
35+
<ContextUsageIndicator usage={null} />
36+
</TooltipProvider>,
37+
);
38+
expect(screen.queryByRole("status")).not.toBeInTheDocument();
39+
});
40+
});
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
"use client";
2+
3+
import { GaugeIcon } from "lucide-react";
4+
5+
import { InfoTooltip } from "@/components/ui/info-tooltip";
6+
import { cn, formatCompactTokens } from "@/lib/utils";
7+
import type { ContextUsage } from "@/lib/use-agent";
8+
9+
export function ContextUsageIndicator({ usage }: { usage: ContextUsage | null }) {
10+
if (!usage) return null;
11+
12+
const known = usage.tokens !== null && usage.percent !== null;
13+
const tokens = usage.tokens ?? 0;
14+
const percent = usage.percent ?? 0;
15+
const fill = Math.min(100, Math.max(0, percent));
16+
const critical = known && percent > 90;
17+
const warning = known && percent > 70;
18+
const value = known ? `${Math.round(percent)}%` : "?%";
19+
const ariaLabel = known
20+
? `Model context ${percent.toFixed(1)} percent, ${tokens.toLocaleString()} of ${usage.contextWindow.toLocaleString()} tokens`
21+
: `Model context usage recalculating, ${usage.contextWindow.toLocaleString()} token window`;
22+
23+
return (
24+
<InfoTooltip
25+
content={
26+
<>
27+
<b>Model context</b>
28+
<br />
29+
{known
30+
? `${formatCompactTokens(tokens)} of ${formatCompactTokens(usage.contextWindow)} tokens (${percent.toFixed(1)}%)`
31+
: `Recalculating after compaction · ${formatCompactTokens(usage.contextWindow)} token window`}
32+
<br />
33+
Pi estimates how much of the selected model&apos;s context window this
34+
conversation currently uses.
35+
</>
36+
}
37+
>
38+
<span
39+
role="status"
40+
aria-label={ariaLabel}
41+
className={cn(
42+
"inline-flex h-7 cursor-help items-center gap-1.5 rounded-md px-2 font-mono text-[11px] tabular-nums text-muted-foreground",
43+
critical && "bg-destructive/10 text-destructive",
44+
warning && !critical && "bg-amber-500/10 text-amber-700 dark:text-amber-400",
45+
)}
46+
>
47+
<GaugeIcon className="size-3.5 shrink-0" aria-hidden />
48+
<span>{value}</span>
49+
<span className="h-1 w-6 overflow-hidden rounded-full bg-muted" aria-hidden>
50+
<span
51+
className={cn(
52+
"block h-full rounded-full transition-[width]",
53+
critical ? "bg-destructive" : warning ? "bg-amber-500" : "bg-primary",
54+
)}
55+
style={{ width: `${fill}%` }}
56+
/>
57+
</span>
58+
</span>
59+
</InfoTooltip>
60+
);
61+
}

web/src/lib/use-agent.test.ts

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { act, renderHook } from "@testing-library/react";
22
import { afterEach, describe, expect, it, vi } from "vitest";
33
import * as projects from "@/lib/projects";
4-
import { buildRunBody, useAgent } from "@/lib/use-agent";
4+
import { buildRunBody, parseContextUsage, useAgent } from "@/lib/use-agent";
55

66
/** Build an SSE response body streaming one `data: <json>\n\n` frame per entry. */
77
function sseStream(frames: unknown[]): ReadableStream<Uint8Array> {
@@ -56,6 +56,22 @@ describe("buildRunBody", () => {
5656
});
5757
});
5858

59+
describe("parseContextUsage", () => {
60+
it("accepts known and post-compaction Pi usage", () => {
61+
expect(
62+
parseContextUsage({ tokens: 42_000, contextWindow: 200_000, percent: 21 }),
63+
).toEqual({ tokens: 42_000, contextWindow: 200_000, percent: 21 });
64+
expect(
65+
parseContextUsage({ tokens: null, contextWindow: 200_000, percent: null }),
66+
).toEqual({ tokens: null, contextWindow: 200_000, percent: null });
67+
});
68+
69+
it("rejects malformed usage", () => {
70+
expect(parseContextUsage({ tokens: -1, contextWindow: 200_000, percent: -1 })).toBeNull();
71+
expect(parseContextUsage({ tokens: 1, contextWindow: 0, percent: 1 })).toBeNull();
72+
});
73+
});
74+
5975
describe("useAgent notebook accumulation", () => {
6076
afterEach(() => vi.restoreAllMocks());
6177

@@ -87,4 +103,37 @@ describe("useAgent notebook accumulation", () => {
87103

88104
expect(result.current.notebookEntries.map((e) => e.id)).toEqual(["tc_1"]);
89105
});
106+
107+
it("tracks Pi context utilization from the SSE stream", async () => {
108+
vi.spyOn(projects, "apiFetch").mockImplementation(async (path: string) => {
109+
if (path === "/sessions") {
110+
return new Response(JSON.stringify({ id: "s1" }), { status: 200 });
111+
}
112+
if (path === "/sessions/s1/run") {
113+
return new Response(
114+
sseStream([
115+
{
116+
type: "context_usage",
117+
tokens: 42_000,
118+
contextWindow: 200_000,
119+
percent: 21,
120+
},
121+
]),
122+
{ status: 200 },
123+
);
124+
}
125+
throw new Error(`unexpected apiFetch path: ${path}`);
126+
});
127+
128+
const { result } = renderHook(() => useAgent());
129+
await act(async () => {
130+
await result.current.send("hi");
131+
});
132+
133+
expect(result.current.contextUsage).toEqual({
134+
tokens: 42_000,
135+
contextWindow: 200_000,
136+
percent: 21,
137+
});
138+
});
90139
});

0 commit comments

Comments
 (0)