diff --git a/packages/app/e2e/browser/provider-usage-settings.spec.ts b/packages/app/e2e/browser/provider-usage-settings.spec.ts
deleted file mode 100644
index 0763233e10..0000000000
--- a/packages/app/e2e/browser/provider-usage-settings.spec.ts
+++ /dev/null
@@ -1,152 +0,0 @@
-import { expect, test } from "../support/fixtures";
-import { gotoAppShell, openSettings } from "../support/helpers/app";
-import { installProviderUsageFixture } from "../support/helpers/provider-usage";
-import { getServerId } from "../support/helpers/server-id";
-import { openSettingsHostSection } from "../support/helpers/settings";
-
-test.describe("provider usage settings", () => {
- test("renders every provider returned by the daemon usage RPC", async ({ page }) => {
- test.setTimeout(120_000);
- const serverId = getServerId();
- const usageFixture = await installProviderUsageFixture(page, [
- {
- fetchedAt: "2026-06-19T00:00:00.000Z",
- providers: [
- {
- providerId: "claude",
- displayName: "Claude",
- status: "available",
- planLabel: "Max 20x",
- windows: [{ id: "session", label: "Session", usedPct: 7 }],
- },
- {
- providerId: "codex",
- displayName: "Codex",
- status: "available",
- planLabel: "Pro 20x",
- windows: [{ id: "weekly", label: "Weekly", usedPct: 29 }],
- },
- {
- providerId: "glm",
- displayName: "GLM coding plan",
- status: "available",
- planLabel: "GLM coding plan",
- sourceLabel: "OpenUsage 0.6.27",
- windows: [
- { id: "biweekly", label: "Biweekly", usedPct: 23 },
- { id: "daily", label: "Daily", remainingPct: 30 },
- ],
- balances: [
- { id: "credits", label: "Credits", remaining: 1234, unit: "credits" },
- { id: "extra", label: "Extra usage", used: 5, limit: 20, unit: "usd" },
- ],
- details: [{ id: "valid", label: "Valid until", value: "2026-12-31" }],
- },
- ],
- },
- ]);
-
- await gotoAppShell(page);
- await openSettings(page);
- expect(usageFixture.requestCount()).toBe(0);
- await openSettingsHostSection(page, serverId, "usage");
- await usageFixture.waitForRequestCount(1);
-
- const card = page.getByTestId("provider-usage-card");
- await expect(card).toBeVisible({ timeout: 10_000 });
- await expect(card.getByText("Claude", { exact: true })).toBeVisible();
- await expect(card.getByText("Codex", { exact: true })).toBeVisible();
- await expect(card.getByText("GLM coding plan", { exact: true }).first()).toBeVisible();
- await expect(card.getByText("Biweekly", { exact: true })).toBeVisible();
- await expect(card.getByText("Daily", { exact: true })).toBeVisible();
- await expect(card.getByText("70%")).toBeVisible();
- await expect(card.getByText("Credits", { exact: true })).toBeVisible();
- await expect(card.getByText("1,234 left", { exact: true })).toBeVisible();
- await expect(card.getByText("Extra usage", { exact: true })).toBeVisible();
- await expect(card.getByText("$5.00 / $20.00", { exact: true })).toBeVisible();
- await expect(card.getByText("Valid until", { exact: true })).toBeVisible();
- await expect(card.getByText("2026-12-31", { exact: true })).toBeVisible();
- await expect(card.getByText(/OpenUsage 0\.6\.27/)).toBeVisible();
- });
-
- test("refresh invalidates and refetches usage", async ({ page }) => {
- test.setTimeout(120_000);
- const serverId = getServerId();
- const usageFixture = await installProviderUsageFixture(page, [
- {
- fetchedAt: "2026-06-19T00:00:00.000Z",
- providers: [
- {
- providerId: "glm",
- displayName: "GLM coding plan",
- status: "available",
- planLabel: "GLM coding plan",
- windows: [{ id: "biweekly", label: "Biweekly", usedPct: 23 }],
- },
- ],
- },
- {
- fetchedAt: "2026-06-19T00:01:00.000Z",
- providers: [
- {
- providerId: "glm",
- displayName: "GLM coding plan",
- status: "available",
- planLabel: "GLM coding plan",
- windows: [{ id: "biweekly", label: "Biweekly", usedPct: 64 }],
- },
- ],
- },
- ]);
-
- await gotoAppShell(page);
- await openSettings(page);
- await openSettingsHostSection(page, serverId, "usage");
- await usageFixture.waitForRequestCount(1);
- await expect(page.getByText("23%")).toBeVisible({ timeout: 10_000 });
-
- await page.getByRole("button", { name: "Refresh", exact: true }).click();
- await usageFixture.waitForRequestCount(2);
-
- expect(usageFixture.requestCount()).toBe(2);
- await expect(page.getByText("64%")).toBeVisible();
- });
-
- test("one provider error does not collapse the usage list", async ({ page }) => {
- test.setTimeout(120_000);
- const serverId = getServerId();
- await installProviderUsageFixture(page, [
- {
- fetchedAt: "2026-06-19T00:00:00.000Z",
- providers: [
- {
- providerId: "claude",
- displayName: "Claude",
- status: "error",
- planLabel: null,
- windows: [],
- error: "Claude auth expired",
- },
- {
- providerId: "codex",
- displayName: "Codex",
- status: "available",
- planLabel: "Pro 20x",
- windows: [{ id: "weekly", label: "Weekly", usedPct: 71 }],
- },
- ],
- },
- ]);
-
- await gotoAppShell(page);
- await openSettings(page);
- await openSettingsHostSection(page, serverId, "usage");
-
- const card = page.getByTestId("provider-usage-card");
- await expect(card).toBeVisible({ timeout: 10_000 });
- await expect(card.getByText("Error", { exact: true })).toBeVisible();
- await expect(card.getByText("Claude auth expired", { exact: true })).toBeVisible();
- await expect(card.getByText("Codex", { exact: true })).toBeVisible();
- await expect(card.getByText("71%")).toBeVisible();
- });
-});
diff --git a/packages/app/e2e/browser/provider-usage-tooltip.spec.ts b/packages/app/e2e/browser/provider-usage-tooltip.spec.ts
deleted file mode 100644
index 680b7b4b80..0000000000
--- a/packages/app/e2e/browser/provider-usage-tooltip.spec.ts
+++ /dev/null
@@ -1,113 +0,0 @@
-import { expect, test, type Page } from "../support/fixtures";
-import { expectComposerVisible } from "../support/helpers/composer";
-import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
-import { installProviderUsageFixture } from "../support/helpers/provider-usage";
-
-const MOBILE_VIEWPORT = { width: 390, height: 844 };
-
-async function openMockAgent(page: Page) {
- await page.setViewportSize(MOBILE_VIEWPORT);
- const session = await seedMockAgentWorkspace({
- repoPrefix: "provider-usage-tooltip-",
- title: "Provider usage tooltip e2e",
- initialPrompt: "emit 1 coalesced agent stream update for provider usage tooltip.",
- });
- await openAgentRoute(page, session);
- await expectComposerVisible(page);
- await expect(page.getByTestId("context-window-meter")).toBeVisible({ timeout: 30_000 });
- return session;
-}
-
-test.describe("provider usage tooltip", () => {
- test("fetches usage when the context tooltip opens and renders the active provider", async ({
- page,
- }) => {
- test.setTimeout(180_000);
- const usageFixture = await installProviderUsageFixture(page, [
- {
- fetchedAt: "2026-06-19T00:00:00.000Z",
- providers: [
- {
- providerId: "mock",
- displayName: "Mock provider",
- status: "available",
- planLabel: "Test plan",
- windows: [
- {
- id: "session",
- label: "Session",
- usedPct: 42,
- remainingPct: 58,
- resetsAt: "2026-06-19T05:00:00.000Z",
- },
- ],
- },
- ],
- },
- ]);
- const session = await openMockAgent(page);
- try {
- expect(usageFixture.requestCount()).toBe(0);
-
- await page.getByTestId("context-window-meter").hover();
- await usageFixture.waitForRequestCount(1);
-
- await expect(page.getByText("Mock provider", { exact: true })).toBeVisible({
- timeout: 10_000,
- });
- await expect(page.getByText("Test plan")).toBeVisible();
- await expect(page.getByText("Session", { exact: true })).toBeVisible();
- await expect(page.getByText("42%")).toBeVisible();
- } finally {
- await session.cleanup();
- }
- });
-
- test("refreshes usage again each time the tooltip is shown", async ({ page }) => {
- test.setTimeout(180_000);
- const usageFixture = await installProviderUsageFixture(page, [
- {
- fetchedAt: "2026-06-19T00:00:00.000Z",
- providers: [
- {
- providerId: "mock",
- displayName: "Mock provider",
- status: "available",
- planLabel: "Test plan",
- windows: [{ id: "session", label: "Session", usedPct: 41 }],
- },
- ],
- },
- {
- fetchedAt: "2026-06-19T00:01:00.000Z",
- providers: [
- {
- providerId: "mock",
- displayName: "Mock provider",
- status: "available",
- planLabel: "Test plan",
- windows: [{ id: "session", label: "Session", usedPct: 64 }],
- },
- ],
- },
- ]);
- const session = await openMockAgent(page);
- try {
- const meter = page.getByTestId("context-window-meter");
-
- await meter.hover();
- await usageFixture.waitForRequestCount(1);
- await expect(page.getByText("41%")).toBeVisible({ timeout: 10_000 });
-
- await page.mouse.move(0, 0);
- await expect(page.getByText("Mock provider", { exact: true })).toHaveCount(0);
-
- await meter.hover();
- await usageFixture.waitForRequestCount(2);
- expect(usageFixture.requestCount()).toBe(2);
- await expect(page.getByText("64%")).toBeVisible();
- } finally {
- await session.cleanup();
- }
- });
-});
diff --git a/packages/app/e2e/browser/usage-composer-pill.spec.ts b/packages/app/e2e/browser/usage-composer-pill.spec.ts
new file mode 100644
index 0000000000..beb9468925
--- /dev/null
+++ b/packages/app/e2e/browser/usage-composer-pill.spec.ts
@@ -0,0 +1,115 @@
+import type { UsageReportEntry } from "@getpaseo/protocol/messages";
+import { expect, test, type Page } from "../support/fixtures";
+import { expectComposerVisible, submitMessage } from "../support/helpers/composer";
+import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
+import { installUsageReportsFixture } from "../support/helpers/usage-reports";
+
+function agentEntry(usedPct: number): UsageReportEntry {
+ return {
+ sourceId: "fixture",
+ sourceLabel: "Fixture plan",
+ icon: '',
+ report: {
+ account: { key: "fixture-account" },
+ status: "available",
+ planLabel: "Test plan",
+ windows: [
+ { id: "session", label: "Session", usedPct: 3 },
+ { id: "weekly", label: "Weekly", usedPct, headline: true },
+ ],
+ },
+ };
+}
+
+async function openMockAgent(page: Page) {
+ const session = await seedMockAgentWorkspace({
+ repoPrefix: "usage-composer-pill-",
+ title: "Usage composer pill e2e",
+ initialPrompt: "emit 1 coalesced agent stream update for usage composer pill.",
+ });
+ await openAgentRoute(page, session);
+ await expectComposerVisible(page);
+ return session;
+}
+
+test.describe("usage composer pill", () => {
+ test("shows the headline percent and opens the usage card with a forced refresh", async ({
+ page,
+ }) => {
+ test.setTimeout(180_000);
+ const usage = await installUsageReportsFixture(page, {
+ agentEntries: [agentEntry(42), agentEntry(64)],
+ });
+ const session = await openMockAgent(page);
+ try {
+ const pill = page.getByTestId("usage-composer-pill");
+ await expect(pill).toContainText("42%", { timeout: 30_000 });
+ expect(usage.agentRequests()[0]).toEqual({ agentId: session.agentId, forceRefresh: false });
+
+ const requestsBeforeOpen = usage.agentRequests().length;
+ await pill.click();
+ await usage.waitForAgentRequests(requestsBeforeOpen + 1);
+ expect(usage.agentRequests().at(-1)?.forceRefresh).toBe(true);
+
+ const popover = page.getByTestId("usage-composer-popover");
+ await expect(popover.getByText("Fixture plan", { exact: true })).toBeVisible();
+ await expect(popover.getByText("Test plan")).toBeVisible();
+ await expect(popover.getByText("64%")).toBeVisible();
+ await expect(pill).toContainText("64%");
+ } finally {
+ await session.cleanup();
+ }
+ });
+
+ test("refetches the agent's report when a turn completes", async ({ page }) => {
+ test.setTimeout(180_000);
+ const usage = await installUsageReportsFixture(page, {
+ agentEntries: [agentEntry(42), agentEntry(77)],
+ });
+ const session = await openMockAgent(page);
+ try {
+ const pill = page.getByTestId("usage-composer-pill");
+ await expect(pill).toContainText("42%", { timeout: 30_000 });
+ const requestsBeforeTurn = usage.agentRequests().length;
+
+ await submitMessage(page, "emit 1 coalesced agent stream update for usage composer pill.");
+
+ await usage.waitForAgentRequests(requestsBeforeTurn + 1);
+ expect(usage.agentRequests().at(-1)).toEqual({
+ agentId: session.agentId,
+ forceRefresh: false,
+ });
+ await expect(pill).toContainText("77%");
+ } finally {
+ await session.cleanup();
+ }
+ });
+
+ test("is hidden when the agent has no usage report", async ({ page }) => {
+ test.setTimeout(180_000);
+ const usage = await installUsageReportsFixture(page, { agentEntries: [null] });
+ const session = await openMockAgent(page);
+ try {
+ await usage.waitForAgentRequests(1);
+ await expect(page.getByTestId("usage-composer-pill")).toHaveCount(0);
+ } finally {
+ await session.cleanup();
+ }
+ });
+
+ test("is hidden on a host without usage sources", async ({ page }) => {
+ test.setTimeout(180_000);
+ const usage = await installUsageReportsFixture(page, {
+ usageSources: false,
+ agentEntries: [agentEntry(42)],
+ });
+ const session = await openMockAgent(page);
+ try {
+ await expect(page.getByTestId("context-window-meter")).toBeVisible({ timeout: 30_000 });
+ await expect(page.getByTestId("usage-composer-pill")).toHaveCount(0);
+ expect(usage.agentRequests()).toHaveLength(0);
+ } finally {
+ await session.cleanup();
+ }
+ });
+});
diff --git a/packages/app/e2e/browser/usage-screen.spec.ts b/packages/app/e2e/browser/usage-screen.spec.ts
new file mode 100644
index 0000000000..cc180c4706
--- /dev/null
+++ b/packages/app/e2e/browser/usage-screen.spec.ts
@@ -0,0 +1,58 @@
+import { expect, test } from "../support/fixtures";
+import { gotoAppShell } from "../support/helpers/app";
+import { getServerId } from "../support/helpers/server-id";
+import { installUsageReportsFixture } from "../support/helpers/usage-reports";
+
+test.describe("usage screen", () => {
+ test("opens from the sidebar and groups reports under their host", async ({ page }) => {
+ test.setTimeout(120_000);
+ const serverId = getServerId();
+ const usage = await installUsageReportsFixture(page, {
+ lists: [
+ [
+ {
+ sourceId: "alpha",
+ sourceLabel: "Alpha plan",
+ report: {
+ account: { key: "a" },
+ status: "available",
+ windows: [{ id: "weekly", label: "Weekly", usedPct: 31, headline: true }],
+ },
+ },
+ {
+ sourceId: "beta",
+ sourceLabel: "Beta plan",
+ report: { account: { key: "b" }, status: "unavailable", windows: [] },
+ },
+ ],
+ ],
+ });
+
+ await gotoAppShell(page);
+ await page.locator('[data-testid="sidebar-usage"]:visible').first().click();
+ await expect(page).toHaveURL(/\/usage$/);
+ await usage.waitForListRequests(1);
+
+ const group = page.getByTestId(`usage-host-${serverId}`);
+ await expect(group.getByText("Alpha plan", { exact: true })).toBeVisible({ timeout: 10_000 });
+ await expect(group.getByText("31%")).toBeVisible();
+ await expect(group.getByText("Beta plan", { exact: true })).toBeVisible();
+ await expect(group.getByText("Unavailable", { exact: true })).toBeVisible();
+ });
+
+ test("tells the user to update a host without usage sources", async ({ page }) => {
+ test.setTimeout(120_000);
+ const serverId = getServerId();
+ const usage = await installUsageReportsFixture(page, { usageSources: false });
+
+ await gotoAppShell(page);
+ await page.locator('[data-testid="sidebar-usage"]:visible').first().click();
+
+ await expect(
+ page.getByTestId(`usage-host-${serverId}`).getByText("Update the host to see usage", {
+ exact: true,
+ }),
+ ).toBeVisible({ timeout: 10_000 });
+ expect(usage.listRequests()).toHaveLength(0);
+ });
+});
diff --git a/packages/app/e2e/browser/usage-settings.spec.ts b/packages/app/e2e/browser/usage-settings.spec.ts
new file mode 100644
index 0000000000..c825f76c3c
--- /dev/null
+++ b/packages/app/e2e/browser/usage-settings.spec.ts
@@ -0,0 +1,135 @@
+import type { UsageReportEntry } from "@getpaseo/protocol/messages";
+import { expect, test } from "../support/fixtures";
+import { gotoAppShell, openSettings } from "../support/helpers/app";
+import { getServerId } from "../support/helpers/server-id";
+import { openSettingsHostSection } from "../support/helpers/settings";
+import { installUsageReportsFixture } from "../support/helpers/usage-reports";
+
+const ICON = '';
+
+function report(input: {
+ sourceId: string;
+ sourceLabel: string;
+ report: Partial;
+}): UsageReportEntry {
+ return {
+ sourceId: input.sourceId,
+ sourceLabel: input.sourceLabel,
+ icon: ICON,
+ report: {
+ account: { key: `${input.sourceId}-account` },
+ status: "available",
+ windows: [],
+ ...input.report,
+ },
+ };
+}
+
+test.describe("usage settings", () => {
+ test("renders every report returned by usage.list_reports", async ({ page }) => {
+ test.setTimeout(120_000);
+ const serverId = getServerId();
+ const usage = await installUsageReportsFixture(page, {
+ lists: [
+ [
+ report({
+ sourceId: "alpha",
+ sourceLabel: "Alpha plan",
+ report: {
+ planLabel: "Max",
+ account: { key: "a", label: "dev@example.com" },
+ windows: [{ id: "session", label: "Session", usedPct: 7, headline: true }],
+ },
+ }),
+ report({
+ sourceId: "beta",
+ sourceLabel: "Beta plan",
+ report: {
+ planLabel: "Coding plan",
+ windows: [
+ { id: "biweekly", label: "Biweekly", usedPct: 23 },
+ { id: "daily", label: "Daily", remainingPct: 30 },
+ ],
+ balances: [
+ { id: "credits", label: "Credits", remaining: 1234, unit: "credits" },
+ { id: "extra", label: "Extra usage", used: 5, limit: 20, unit: "usd" },
+ ],
+ details: [{ id: "valid", label: "Valid until", value: "2026-12-31" }],
+ },
+ }),
+ report({
+ sourceId: "gamma",
+ sourceLabel: "Gamma plan",
+ report: { status: "error", error: "Gamma auth expired" },
+ }),
+ ],
+ ],
+ });
+
+ await gotoAppShell(page);
+ await openSettings(page);
+ expect(usage.listRequests()).toHaveLength(0);
+ await openSettingsHostSection(page, serverId, "usage");
+ await usage.waitForListRequests(1);
+
+ const card = page.getByTestId("usage-card");
+ await expect(card.getByText("Alpha plan", { exact: true })).toBeVisible({ timeout: 10_000 });
+ await expect(card.getByText("dev@example.com", { exact: true })).toBeVisible();
+ await expect(card.getByText("Beta plan", { exact: true })).toBeVisible();
+ await expect(card.getByText("70%")).toBeVisible();
+ await expect(card.getByText("1,234 left", { exact: true })).toBeVisible();
+ await expect(card.getByText("$5.00 / $20.00", { exact: true })).toBeVisible();
+ await expect(card.getByText("2026-12-31", { exact: true })).toBeVisible();
+ await expect(card.getByText("Gamma auth expired", { exact: true })).toBeVisible();
+ });
+
+ test("refresh forces a fresh report", async ({ page }) => {
+ test.setTimeout(120_000);
+ const serverId = getServerId();
+ const windows = (usedPct: number) => [{ id: "w", label: "Weekly", usedPct }];
+ const usage = await installUsageReportsFixture(page, {
+ lists: [
+ [
+ report({
+ sourceId: "alpha",
+ sourceLabel: "Alpha plan",
+ report: { windows: windows(23) },
+ }),
+ ],
+ [
+ report({
+ sourceId: "alpha",
+ sourceLabel: "Alpha plan",
+ report: { windows: windows(64) },
+ }),
+ ],
+ ],
+ });
+
+ await gotoAppShell(page);
+ await openSettings(page);
+ await openSettingsHostSection(page, serverId, "usage");
+ await expect(page.getByText("23%")).toBeVisible({ timeout: 10_000 });
+
+ await page.getByRole("button", { name: "Refresh", exact: true }).click();
+ await usage.waitForListRequests(2);
+
+ expect(usage.listRequests().at(-1)).toEqual({ forceRefresh: true });
+ await expect(page.getByText("64%")).toBeVisible();
+ });
+
+ test("asks to update a host without usage sources and never calls it", async ({ page }) => {
+ test.setTimeout(120_000);
+ const serverId = getServerId();
+ const usage = await installUsageReportsFixture(page, { usageSources: false });
+
+ await gotoAppShell(page);
+ await openSettings(page);
+ await openSettingsHostSection(page, serverId, "usage");
+
+ await expect(
+ page.getByTestId("usage-card").getByText("Update the host to see usage", { exact: true }),
+ ).toBeVisible({ timeout: 10_000 });
+ expect(usage.listRequests()).toHaveLength(0);
+ });
+});
diff --git a/packages/app/e2e/support/helpers/provider-usage.ts b/packages/app/e2e/support/helpers/provider-usage.ts
deleted file mode 100644
index 9ce3bc8849..0000000000
--- a/packages/app/e2e/support/helpers/provider-usage.ts
+++ /dev/null
@@ -1,154 +0,0 @@
-import type { Page } from "@playwright/test";
-import type { ProviderUsage } from "@getpaseo/protocol/messages";
-import { daemonWsRoutePattern } from "./daemon-port";
-
-interface ProviderUsageFixturePayload {
- fetchedAt: string;
- providers: ProviderUsage[];
-}
-
-export interface ProviderUsageFixture {
- requestCount(): number;
- waitForRequestCount(count: number): Promise;
-}
-
-type WebSocketMessage = string | Buffer;
-
-function parseJson(message: WebSocketMessage): unknown {
- const raw = typeof message === "string" ? message : message.toString("utf8");
- try {
- return JSON.parse(raw);
- } catch {
- return null;
- }
-}
-
-function getSessionMessage(message: WebSocketMessage): Record | null {
- const envelope = parseJson(message);
- if (!envelope || typeof envelope !== "object") {
- return null;
- }
- const maybeEnvelope = envelope as { type?: unknown; message?: unknown };
- if (maybeEnvelope.type !== "session" || !maybeEnvelope.message) {
- return null;
- }
- if (typeof maybeEnvelope.message !== "object") {
- return null;
- }
- return maybeEnvelope.message as Record;
-}
-
-function withProviderUsageFeature(message: WebSocketMessage): string | null {
- const envelope = parseJson(message);
- if (!envelope || typeof envelope !== "object") {
- return null;
- }
- const maybeEnvelope = envelope as {
- type?: unknown;
- message?: {
- type?: unknown;
- payload?: Record;
- };
- };
- const payload = maybeEnvelope.message?.payload;
- if (
- maybeEnvelope.type !== "session" ||
- maybeEnvelope.message?.type !== "status" ||
- payload?.status !== "server_info"
- ) {
- return null;
- }
- return JSON.stringify({
- ...maybeEnvelope,
- message: {
- ...maybeEnvelope.message,
- payload: {
- ...payload,
- features: {
- ...(typeof payload.features === "object" && payload.features !== null
- ? payload.features
- : {}),
- providerUsageList: true,
- },
- },
- },
- });
-}
-
-export async function installProviderUsageFixture(
- page: Page,
- payloads: ProviderUsageFixturePayload[],
-): Promise {
- let requests = 0;
- const waiters: Array<{ count: number; resolve: () => void }> = [];
-
- function notifyWaiters() {
- for (const waiter of waiters.splice(0)) {
- if (requests >= waiter.count) {
- waiter.resolve();
- } else {
- waiters.push(waiter);
- }
- }
- }
-
- function payloadForRequest(): ProviderUsageFixturePayload {
- const index = Math.min(requests - 1, payloads.length - 1);
- const payload = payloads[index];
- if (!payload) {
- throw new Error("Provider usage fixture requires at least one payload.");
- }
- return payload;
- }
-
- await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
- const server = ws.connectToServer();
-
- ws.onMessage((message) => {
- const sessionMessage = getSessionMessage(message);
- if (sessionMessage?.type === "provider.usage.list.request") {
- requests += 1;
- const requestId = sessionMessage.requestId;
- if (typeof requestId !== "string") {
- throw new Error("provider.usage.list.request missing requestId");
- }
- const payload = payloadForRequest();
- notifyWaiters();
- ws.send(
- JSON.stringify({
- type: "session",
- message: {
- type: "provider.usage.list.response",
- payload: {
- requestId,
- fetchedAt: payload.fetchedAt,
- providers: payload.providers,
- },
- },
- }),
- );
- return;
- }
- server.send(message);
- });
-
- server.onMessage((message) => {
- const serverInfo = typeof message === "string" ? withProviderUsageFeature(message) : null;
- ws.send(serverInfo ?? message);
- });
- });
-
- return {
- requestCount() {
- return requests;
- },
- waitForRequestCount(count: number) {
- if (requests >= count) {
- return Promise.resolve();
- }
- return new Promise((resolve) => {
- waiters.push({ count, resolve });
- });
- },
- };
-}
diff --git a/packages/app/e2e/support/helpers/usage-reports.ts b/packages/app/e2e/support/helpers/usage-reports.ts
new file mode 100644
index 0000000000..5b5938c157
--- /dev/null
+++ b/packages/app/e2e/support/helpers/usage-reports.ts
@@ -0,0 +1,147 @@
+import type { Page } from "@playwright/test";
+import type { UsageReportEntry } from "@getpaseo/protocol/messages";
+import { daemonWsRoutePattern } from "./daemon-port";
+
+export interface UsageReportsFixture {
+ listRequests(): Array<{ forceRefresh: boolean }>;
+ agentRequests(): Array<{ agentId: string; forceRefresh: boolean }>;
+ waitForListRequests(count: number): Promise;
+ waitForAgentRequests(count: number): Promise;
+}
+
+interface UsageReportsFixtureOptions {
+ /** Successive `usage.list_reports` responses; the last one repeats. */
+ lists?: UsageReportEntry[][];
+ /** Successive `agent.get_usage_report` responses; the last one repeats. */
+ agentEntries?: Array;
+ /** Advertise `features.usageSources`. False simulates a host from before usage sources. */
+ usageSources?: boolean;
+}
+
+type WebSocketMessage = string | Buffer;
+
+function parseJson(message: WebSocketMessage): unknown {
+ const raw = typeof message === "string" ? message : message.toString("utf8");
+ try {
+ return JSON.parse(raw);
+ } catch {
+ return null;
+ }
+}
+
+function getSessionMessage(message: WebSocketMessage): Record | null {
+ const envelope = parseJson(message) as { type?: unknown; message?: unknown } | null;
+ if (!envelope || envelope.type !== "session" || typeof envelope.message !== "object") {
+ return null;
+ }
+ return envelope.message as Record;
+}
+
+function withUsageSourcesFeature(message: WebSocketMessage, enabled: boolean): string | null {
+ const envelope = parseJson(message) as {
+ type?: unknown;
+ message?: { type?: unknown; payload?: Record };
+ } | null;
+ const payload = envelope?.message?.payload;
+ if (
+ envelope?.type !== "session" ||
+ envelope.message?.type !== "status" ||
+ payload?.status !== "server_info"
+ ) {
+ return null;
+ }
+ const features =
+ typeof payload.features === "object" && payload.features !== null ? payload.features : {};
+ return JSON.stringify({
+ ...envelope,
+ message: {
+ ...envelope.message,
+ payload: { ...payload, features: { ...features, usageSources: enabled } },
+ },
+ });
+}
+
+function pick(values: T[], index: number): T {
+ const value = values[Math.min(index, values.length - 1)];
+ if (value === undefined) throw new Error("Usage fixture requires at least one response.");
+ return value;
+}
+
+function createCounter() {
+ let count = 0;
+ const waiters: Array<{ count: number; resolve: () => void }> = [];
+ return {
+ increment() {
+ count += 1;
+ for (const waiter of waiters.splice(0)) {
+ if (count >= waiter.count) waiter.resolve();
+ else waiters.push(waiter);
+ }
+ },
+ waitFor(target: number): Promise {
+ if (count >= target) return Promise.resolve();
+ return new Promise((resolve) => waiters.push({ count: target, resolve }));
+ },
+ };
+}
+
+export async function installUsageReportsFixture(
+ page: Page,
+ options: UsageReportsFixtureOptions,
+): Promise {
+ const listRequests: Array<{ forceRefresh: boolean }> = [];
+ const agentRequests: Array<{ agentId: string; forceRefresh: boolean }> = [];
+ const listCounter = createCounter();
+ const agentCounter = createCounter();
+ const usageSources = options.usageSources ?? true;
+
+ await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
+ const server = ws.connectToServer();
+
+ ws.onMessage((message) => {
+ const request = getSessionMessage(message);
+ const requestId = request?.requestId;
+ if (request?.type === "usage.list_reports.request" && typeof requestId === "string") {
+ listRequests.push({ forceRefresh: request.forceRefresh === true });
+ const reports = pick(options.lists ?? [[]], listRequests.length - 1);
+ ws.send(
+ JSON.stringify({
+ type: "session",
+ message: { type: "usage.list_reports.response", payload: { requestId, reports } },
+ }),
+ );
+ listCounter.increment();
+ return;
+ }
+ if (request?.type === "agent.get_usage_report.request" && typeof requestId === "string") {
+ agentRequests.push({
+ agentId: String(request.agentId),
+ forceRefresh: request.forceRefresh === true,
+ });
+ const entry = pick(options.agentEntries ?? [null], agentRequests.length - 1);
+ ws.send(
+ JSON.stringify({
+ type: "session",
+ message: { type: "agent.get_usage_report.response", payload: { requestId, entry } },
+ }),
+ );
+ agentCounter.increment();
+ return;
+ }
+ server.send(message);
+ });
+
+ server.onMessage((message) => {
+ const serverInfo =
+ typeof message === "string" ? withUsageSourcesFeature(message, usageSources) : null;
+ ws.send(serverInfo ?? message);
+ });
+ });
+
+ return {
+ listRequests: () => [...listRequests],
+ agentRequests: () => [...agentRequests],
+ waitForListRequests: (count) => listCounter.waitFor(count),
+ waitForAgentRequests: (count) => agentCounter.waitFor(count),
+ };
+}
diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx
index 59b198bdf5..cf682e7611 100644
--- a/packages/app/src/app/_layout.tsx
+++ b/packages/app/src/app/_layout.tsx
@@ -860,6 +860,7 @@ function AppWithSidebar({ children }: { children: ReactNode }) {
pathname === "/new" ||
pathname === "/sessions" ||
pathname === "/schedules" ||
+ pathname === "/usage" ||
routeHasKnownHost);
return {children};
@@ -892,6 +893,7 @@ function RootStack() {
+
diff --git a/packages/app/src/app/usage.tsx b/packages/app/src/app/usage.tsx
new file mode 100644
index 0000000000..2928cca3f3
--- /dev/null
+++ b/packages/app/src/app/usage.tsx
@@ -0,0 +1,10 @@
+import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
+import { UsageScreen } from "@/usage";
+
+export default function UsageRoute() {
+ return (
+
+
+
+ );
+}
diff --git a/packages/app/src/components/context-window-meter.tsx b/packages/app/src/components/context-window-meter.tsx
index 6fe837562e..e587123f19 100644
--- a/packages/app/src/components/context-window-meter.tsx
+++ b/packages/app/src/components/context-window-meter.tsx
@@ -1,11 +1,8 @@
-import { useCallback, useState } from "react";
import { Pressable, Text, View } from "react-native";
import Svg, { Circle } from "react-native-svg";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
-import { ProviderUsageTooltipSection } from "@/provider-usage/tooltip-section";
-import { useProviderUsage } from "@/provider-usage/use-provider-usage";
import { formatTokenCount } from "./context-window-meter.utils";
interface ContextWindowMeterProps {
@@ -13,9 +10,6 @@ interface ContextWindowMeterProps {
usedTokens: number | null;
totalCostUsd?: number | null;
showPercentage?: boolean;
- serverId?: string;
- /** The Paseo provider key, e.g. "claude", "gemini", "codex" */
- provider?: string | null;
/** Reserve the meter footprint and show a loading ring while usage is pending. */
pending?: boolean;
/** Optional glyph envelope for icon-toolbar alignment. */
@@ -101,30 +95,13 @@ export function ContextWindowMeter({
usedTokens,
totalCostUsd,
showPercentage = false,
- serverId,
- provider,
pending = false,
glyphSize,
}: ContextWindowMeterProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
- const [isTooltipOpen, setIsTooltipOpen] = useState(false);
- const { view: providerUsageView, refresh: refreshProviderUsage } = useProviderUsage(
- serverId ?? null,
- { enabled: isTooltipOpen },
- );
const percentage =
maxTokens !== null && usedTokens !== null ? getUsagePercentage(maxTokens, usedTokens) : null;
- const handleTooltipOpenChange = useCallback(
- (nextOpen: boolean) => {
- setIsTooltipOpen(nextOpen);
- if (nextOpen) {
- void refreshProviderUsage().catch(() => {});
- }
- },
- [refreshProviderUsage],
- );
-
const geometry = getMeterGeometry(showPercentage, glyphSize);
// No usage yet: reserve the footprint with a track-only ring while a session is
@@ -167,13 +144,7 @@ export function ContextWindowMeter({
typeof totalCostUsd === "number" ? formatSessionCost(totalCostUsd) : null;
return (
-
+
) : null}
-
diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx
index 1cdfe83e6d..4902a3ae93 100644
--- a/packages/app/src/components/left-sidebar.tsx
+++ b/packages/app/src/components/left-sidebar.tsx
@@ -1,5 +1,5 @@
import { router } from "expo-router";
-import { FolderPlus, GitBranch, Import, Server, Settings, X } from "lucide-react-native";
+import { FolderPlus, Gauge, GitBranch, Import, Server, Settings, X } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
import {
@@ -47,7 +47,11 @@ import { usePanelStore } from "@/stores/panel-store";
import { useOwnsWindowChromeCorner, WindowChromeSafeArea } from "@/utils/desktop-window";
import { useCloseAgentListGesture } from "@/mobile-panels/gestures";
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
-import { buildSettingsAddHostRoute, buildSettingsRoute } from "@/utils/host-routes";
+import {
+ buildSettingsAddHostRoute,
+ buildSettingsRoute,
+ buildUsageRoute,
+} from "@/utils/host-routes";
import { openHostOverview } from "@/navigation/settings-navigation";
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
import { SidebarCalloutSlot } from "./sidebar-callout-slot";
@@ -426,6 +430,25 @@ function SidebarHostPicker({
);
}
+function SidebarUsageButton({ theme }: { theme: SidebarTheme }) {
+ const { t } = useTranslation();
+ const isCompactLayout = useIsCompactFormFactor();
+ const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
+ const handlePress = useCallback(() => {
+ if (isCompactLayout) showMobileAgent();
+ router.push(buildUsageRoute());
+ }, [isCompactLayout, showMobileAgent]);
+ return (
+
+ );
+}
+
function IconTooltipContent({
label,
shortcutKeys,
@@ -489,6 +512,7 @@ function SidebarFooter({
icon={Import}
theme={theme}
/>
+
();
const snapshotIconComponents = new Map();
+/** Renders a sanitized SVG string from a host catalog, tinted through `currentColor`. */
+export function SvgIcon({ svg, size, color }: ProviderIconProps & { svg: string }) {
+ return createElement(SvgXml, { xml: svg, width: size, height: size, color });
+}
+
function createSvgIcon(provider: string, iconSvg: string): ProviderIconComponent {
const SvgProviderIcon: ProviderIconComponent = ({ size, color }) =>
- createElement(SvgXml, {
- xml: iconSvg,
- width: size,
- height: size,
- color,
- });
+ SvgIcon({ svg: iconSvg, size, color });
SvgProviderIcon.displayName = `SvgProviderIcon(${provider})`;
return SvgProviderIcon;
}
diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx
index a8bdec9ede..c54a980ea7 100644
--- a/packages/app/src/composer/index.tsx
+++ b/packages/app/src/composer/index.tsx
@@ -48,6 +48,7 @@ import {
type DraftAgentControlsProps,
} from "@/composer/agent-controls";
import { ContextWindowMeter } from "@/components/context-window-meter";
+import { UsageComposerPill } from "@/usage";
import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker";
import { selectAgentTurnPresentation, useSessionStore } from "@/stores/session-store";
import { useFilePicker } from "@/hooks/use-file-picker";
@@ -274,7 +275,6 @@ function buildAgentStateSelector(serverId: string, agentId: string) {
contextWindowUsedTokens: agent?.lastUsage?.contextWindowUsedTokens ?? null,
totalCostUsd: agent?.lastUsage?.totalCostUsd ?? null,
model: agent?.model ?? null,
- provider: agent?.provider ?? null,
};
};
}
@@ -284,8 +284,6 @@ function renderContextWindowMeter(
contextWindowUsedTokens: number | null,
totalCostUsd: number | null,
showPercentage: boolean,
- serverId: string,
- provider: string | null,
pending: boolean,
glyphSize: number,
): ReactElement | null {
@@ -299,8 +297,6 @@ function renderContextWindowMeter(
usedTokens={contextWindowUsedTokens}
totalCostUsd={totalCostUsd}
showPercentage={showPercentage}
- serverId={serverId}
- provider={provider}
pending={pending}
glyphSize={glyphSize}
/>
@@ -2085,8 +2081,6 @@ function ComposerContentImpl({
contextWindowUsedTokens,
agentState.totalCostUsd,
false,
- serverId,
- agentState.provider,
contextWindowPending,
contextWindowMeterGlyphSize,
),
@@ -2094,15 +2088,18 @@ function ComposerContentImpl({
contextWindowMaxTokens,
contextWindowUsedTokens,
agentState.totalCostUsd,
- serverId,
- agentState.provider,
contextWindowPending,
contextWindowMeterGlyphSize,
],
);
const beforeVoiceContent = useMemo(
- () => resolveContextWindowPlacement(contextWindowMeter, hasAgent),
- [contextWindowMeter, hasAgent],
+ () => (
+ <>
+ {hasAgent ? : null}
+ {resolveContextWindowPlacement(contextWindowMeter, hasAgent)}
+ >
+ ),
+ [agentId, contextWindowMeter, hasAgent, serverId],
);
const hasGithubAttachment = useMemo(
diff --git a/packages/app/src/provider-usage/copy.ts b/packages/app/src/provider-usage/copy.ts
deleted file mode 100644
index 1dcc817ef5..0000000000
--- a/packages/app/src/provider-usage/copy.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-// User-facing copy for the provider-usage surfaces, centralized so localization
-// is a single-file change. INTEGRATION: move these into the i18n resources
-// (a `providerUsage` namespace across all locales) and swap to `t(...)` at the
-// call sites once the feature is wired to data. Kept inline for now because the
-// surfaces are not yet mounted and the locale files are being edited elsewhere.
-export const providerUsageCopy = {
- title: "Plan usage",
- refresh: "Refresh",
- refreshing: "Refreshing...",
- loading: "Loading usage...",
- empty: "No usage data",
- errorTitle: "Unable to load usage",
- hostUnavailable: "Connect to this host to see provider usage",
- hostUpgradeRequired: "Update the host to see provider usage",
- clientUnavailable: "Host connection is not ready",
- retry: "Try again",
- tooltipLoading: "Loading plan usage…",
-} as const;
diff --git a/packages/app/src/provider-usage/settings-section.tsx b/packages/app/src/provider-usage/settings-section.tsx
deleted file mode 100644
index 6cb6ff68dd..0000000000
--- a/packages/app/src/provider-usage/settings-section.tsx
+++ /dev/null
@@ -1,99 +0,0 @@
-import { RefreshCw } from "lucide-react-native";
-import { useMemo } from "react";
-import { Text, View } from "react-native";
-import { StyleSheet } from "react-native-unistyles";
-import { Alert } from "@/components/ui/alert";
-import { Button } from "@/components/ui/button";
-import { settingsStyles } from "@/styles/settings";
-import { SettingsSection } from "@/components/settings/headings/settings-section";
-import { providerUsageCopy } from "./copy";
-import { ProviderUsageList } from "./list";
-import type { ProviderUsageView } from "./types";
-
-export function ProviderUsageSettingsSection({
- view,
- onRefresh,
-}: {
- view: ProviderUsageView;
- onRefresh: () => void;
-}) {
- const busy = view.kind === "loading" || (view.kind === "ready" && view.isRefreshing);
-
- const refreshButton = useMemo(
- () => (
-
- ),
- [busy, onRefresh],
- );
-
- return (
-
-
-
- );
-}
-
-function ProviderUsageBody({
- view,
- onRefresh,
-}: {
- view: ProviderUsageView;
- onRefresh: () => void;
-}) {
- if (view.kind === "loading") {
- return (
-
- {providerUsageCopy.loading}
-
- );
- }
-
- if (view.kind === "error") {
- return (
-
-
-
- );
- }
-
- if (view.payload.providers.length === 0) {
- return (
-
- {providerUsageCopy.empty}
-
- );
- }
-
- return ;
-}
-
-const styles = StyleSheet.create((theme) => ({
- emptyCard: {
- padding: theme.spacing[4],
- alignItems: "center",
- },
- emptyText: {
- color: theme.colors.foregroundMuted,
- fontSize: theme.fontSize.base,
- },
-}));
diff --git a/packages/app/src/provider-usage/tooltip-section.tsx b/packages/app/src/provider-usage/tooltip-section.tsx
deleted file mode 100644
index 6a5a896301..0000000000
--- a/packages/app/src/provider-usage/tooltip-section.tsx
+++ /dev/null
@@ -1,75 +0,0 @@
-import { Text, View } from "react-native";
-import { StyleSheet } from "react-native-unistyles";
-import { ProviderUsageCard } from "./card";
-import { providerUsageCopy } from "./copy";
-import type { ProviderUsage, ProviderUsageView } from "./types";
-
-function matchProvider(
- providers: ProviderUsage[],
- activeProviderId: string | null | undefined,
-): ProviderUsage | null {
- if (!activeProviderId) return null;
- const target = activeProviderId.toLowerCase();
- return providers.find((usage) => usage.providerId.toLowerCase() === target) ?? null;
-}
-
-// Renders the active agent's provider usage inside the context-meter tooltip.
-// Returns nothing when the active provider has no usage entry, so the meter's
-// own context section stays the whole tooltip.
-export function ProviderUsageTooltipSection({
- view,
- activeProviderId,
-}: {
- view: ProviderUsageView;
- activeProviderId: string | null | undefined;
-}) {
- if (view.kind === "loading") {
- return (
- <>
-
- {providerUsageCopy.tooltipLoading}
- >
- );
- }
-
- if (view.kind === "error") {
- return (
- <>
-
- {view.message}
- >
- );
- }
-
- const usage = matchProvider(view.payload.providers, activeProviderId);
- if (!usage) return null;
-
- return (
- <>
-
-
- >
- );
-}
-
-const styles = StyleSheet.create((theme) => ({
- divider: {
- height: 1,
- // Same token the popover draws its own outline with, so the rule reads as the
- // popover's edge. `border` is invisible here (equals the popover background).
- backgroundColor: theme.colors.borderAccent,
- marginVertical: theme.spacing[2],
- // Cancel the tooltip content's horizontal padding so the rule spans edge to edge.
- marginHorizontal: -theme.spacing[2],
- },
- detail: {
- color: theme.colors.foregroundMuted,
- fontSize: theme.fontSize.sm,
- lineHeight: theme.fontSize.sm * 1.4,
- },
- error: {
- color: theme.colors.palette.red[300],
- fontSize: theme.fontSize.sm,
- lineHeight: theme.fontSize.sm * 1.4,
- },
-}));
diff --git a/packages/app/src/provider-usage/types.ts b/packages/app/src/provider-usage/types.ts
deleted file mode 100644
index 52ff7c31f0..0000000000
--- a/packages/app/src/provider-usage/types.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-import type {
- ProviderUsage,
- ProviderUsageBalance,
- ProviderUsageDetail,
- ProviderUsageListResponseMessage,
- ProviderUsageStatus,
- ProviderUsageTone,
- ProviderUsageWindow,
-} from "@getpaseo/protocol/messages";
-
-export type {
- ProviderUsage,
- ProviderUsageBalance,
- ProviderUsageDetail,
- ProviderUsageStatus,
- ProviderUsageTone,
- ProviderUsageWindow,
-};
-
-export type ProviderUsageBalanceUnit = ProviderUsageBalance["unit"];
-export type ProviderUsageListPayload = ProviderUsageListResponseMessage["payload"];
-
-export type ProviderUsageView =
- | { kind: "loading" }
- | { kind: "error"; message: string }
- | { kind: "ready"; payload: ProviderUsageListPayload; isRefreshing: boolean };
diff --git a/packages/app/src/provider-usage/use-provider-usage.ts b/packages/app/src/provider-usage/use-provider-usage.ts
deleted file mode 100644
index ce133726c3..0000000000
--- a/packages/app/src/provider-usage/use-provider-usage.ts
+++ /dev/null
@@ -1,103 +0,0 @@
-import { useCallback, useMemo } from "react";
-import { useQuery, useQueryClient } from "@tanstack/react-query";
-import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
-import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
-import { useSessionStore } from "@/stores/session-store";
-import { providerUsageCopy } from "./copy";
-import type { ProviderUsageListPayload, ProviderUsageView } from "./types";
-
-export const PROVIDER_USAGE_STALE_TIME_MS = 5 * 60 * 1000;
-
-type ProviderUsageClient = Pick;
-
-export function providerUsageQueryKey(serverId: string | null | undefined) {
- return ["providerUsage", serverId ?? ""] as const;
-}
-
-async function fetchProviderUsage(client: ProviderUsageClient): Promise {
- return client.listProviderUsage();
-}
-
-interface UseProviderUsageOptions {
- enabled?: boolean;
-}
-
-export function useProviderUsage(
- serverId: string | null | undefined,
- options: UseProviderUsageOptions = {},
-): {
- view: ProviderUsageView;
- refresh: () => Promise;
- canFetch: boolean;
-} {
- const queryClient = useQueryClient();
- const client = useHostRuntimeClient(serverId ?? "");
- const isConnected = useHostRuntimeIsConnected(serverId ?? "");
- const supportsProviderUsage = useSessionStore(
- (state) => state.sessions[serverId ?? ""]?.serverInfo?.features?.providerUsageList === true,
- );
- const queryKey = useMemo(() => providerUsageQueryKey(serverId), [serverId]);
- const canFetch = Boolean(serverId && client && isConnected && supportsProviderUsage);
- const enabled = Boolean((options.enabled ?? true) && canFetch);
-
- const queryFn = useCallback(async () => {
- if (!client) {
- throw new Error(providerUsageCopy.clientUnavailable);
- }
- return fetchProviderUsage(client);
- }, [client]);
-
- const query = useQuery({
- queryKey,
- queryFn,
- enabled,
- staleTime: PROVIDER_USAGE_STALE_TIME_MS,
- refetchOnMount: true,
- refetchOnReconnect: false,
- refetchOnWindowFocus: false,
- });
-
- const refresh = useCallback(async () => {
- if (!canFetch) return;
- await queryClient.invalidateQueries({ queryKey });
- await queryClient.fetchQuery({
- queryKey,
- queryFn,
- staleTime: PROVIDER_USAGE_STALE_TIME_MS,
- });
- }, [canFetch, queryClient, queryFn, queryKey]);
-
- const view = useMemo(() => {
- if (!serverId || !client || !isConnected) {
- return { kind: "error", message: providerUsageCopy.hostUnavailable };
- }
- if (!supportsProviderUsage) {
- return { kind: "error", message: providerUsageCopy.hostUpgradeRequired };
- }
- if (query.data) {
- return {
- kind: "ready",
- payload: query.data,
- isRefreshing: query.isFetching,
- };
- }
- if (query.isError) {
- return {
- kind: "error",
- message: query.error instanceof Error ? query.error.message : String(query.error),
- };
- }
- return { kind: "loading" };
- }, [
- client,
- isConnected,
- query.data,
- query.error,
- query.isError,
- query.isFetching,
- serverId,
- supportsProviderUsage,
- ]);
-
- return { view, refresh, canFetch };
-}
diff --git a/packages/app/src/screens/settings/host-page.tsx b/packages/app/src/screens/settings/host-page.tsx
index 5a9fd26b68..f7b27f820f 100644
--- a/packages/app/src/screens/settings/host-page.tsx
+++ b/packages/app/src/screens/settings/host-page.tsx
@@ -50,8 +50,7 @@ import {
useHosts,
} from "@/runtime/host-runtime";
import { ProvidersSection } from "@/screens/settings/providers-section";
-import { ProviderUsageSettingsSection } from "@/provider-usage/settings-section";
-import { useProviderUsage } from "@/provider-usage/use-provider-usage";
+import { HostUsageSection } from "@/usage";
import { HostAppearanceSection } from "@/screens/settings/host-appearance-section";
import { SettingsSection } from "@/components/settings/headings/settings-section";
import { useSessionStore } from "@/stores/session-store";
@@ -343,10 +342,6 @@ export function HostProvidersPage({ serverId }: { serverId: string }) {
export function HostUsagePage({ serverId }: { serverId: string }) {
const host = useHostProfile(serverId);
- const { view: providerUsageView, refresh: refreshProviderUsage } = useProviderUsage(serverId);
- const handleRefresh = useCallback(() => {
- void refreshProviderUsage();
- }, [refreshProviderUsage]);
if (!host) {
return ;
@@ -354,7 +349,7 @@ export function HostUsagePage({ serverId }: { serverId: string }) {
return (
-
+
);
}
diff --git a/packages/app/src/provider-usage/balance-bar.tsx b/packages/app/src/usage/balance-bar.tsx
similarity index 91%
rename from packages/app/src/provider-usage/balance-bar.tsx
rename to packages/app/src/usage/balance-bar.tsx
index dbf18bea87..8bf3ad9d83 100644
--- a/packages/app/src/provider-usage/balance-bar.tsx
+++ b/packages/app/src/usage/balance-bar.tsx
@@ -2,14 +2,14 @@ import { useMemo } from "react";
import { Text, View, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { clampPct, formatAmount, formatResetLabel } from "./format";
-import type { ProviderUsageBalance, ProviderUsageTone } from "./types";
+import type { UsageBalance, UsageTone } from "./types";
interface ResolvedBalance {
amountText: string;
usedPct: number | null;
}
-function resolveBalance(balance: ProviderUsageBalance): ResolvedBalance {
+function resolveBalance(balance: UsageBalance): ResolvedBalance {
const { used, remaining, limit, unit } = balance;
if (limit != null && limit > 0) {
const usedAmount = used ?? (remaining != null ? limit - remaining : null);
@@ -26,7 +26,7 @@ function resolveBalance(balance: ProviderUsageBalance): ResolvedBalance {
return { amountText: "—", usedPct: null };
}
-function fillToneStyle(tone: ProviderUsageTone) {
+function fillToneStyle(tone: UsageTone) {
switch (tone) {
case "ok":
return styles.fillOk;
@@ -39,7 +39,7 @@ function fillToneStyle(tone: ProviderUsageTone) {
}
}
-export function ProviderUsageBalanceBar({ balance }: { balance: ProviderUsageBalance }) {
+export function UsageBalanceBar({ balance }: { balance: UsageBalance }) {
const { amountText, usedPct } = resolveBalance(balance);
const tone = balance.tone ?? "default";
const resetLabel = formatResetLabel(balance.resetsAt);
diff --git a/packages/app/src/provider-usage/card.tsx b/packages/app/src/usage/card.tsx
similarity index 69%
rename from packages/app/src/provider-usage/card.tsx
rename to packages/app/src/usage/card.tsx
index 10ef161fcc..4728b4a94a 100644
--- a/packages/app/src/provider-usage/card.tsx
+++ b/packages/app/src/usage/card.tsx
@@ -1,51 +1,27 @@
import { useMemo } from "react";
import { Text, View } from "react-native";
-import { StyleSheet, withUnistyles } from "react-native-unistyles";
-import { getProviderIcon } from "@/components/provider-icons";
+import { StyleSheet } from "react-native-unistyles";
import { StatusBadge } from "@/components/ui/status-badge";
-import type { Theme } from "@/styles/theme";
-import { ProviderUsageBalanceBar } from "./balance-bar";
-import { formatAgo } from "./format";
-import type { ProviderUsage } from "./types";
-import { ProviderUsageWindowBar } from "./window-bar";
+import { UsageBalanceBar } from "./balance-bar";
+import { UsageSourceIcon } from "./source-icon";
+import type { UsageReport, UsageReportEntry } from "./types";
+import { UsageWindowBar } from "./window-bar";
-interface ProviderUsageIconProps {
- iconKey: string;
- size: number;
- color?: string;
+function statusText(report: UsageReport): string | null {
+ if (report.status === "available") return null;
+ return report.status === "error" ? "Error" : "Unavailable";
}
-function ProviderUsageIcon({ iconKey, size, color = "" }: ProviderUsageIconProps) {
- const Icon = getProviderIcon(iconKey);
- return ;
-}
-
-const ThemedProviderUsageIcon = withUnistyles(ProviderUsageIcon);
-
-const mutedIconColor = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
-
-function statusText(usage: ProviderUsage): string | null {
- if (usage.status === "available") return null;
- return usage.status === "error" ? "Error" : "Unavailable";
-}
-
-function footerText(usage: ProviderUsage): string | null {
- const updated = formatAgo(usage.fetchedAt);
- const parts = [usage.sourceLabel, updated ? `Updated ${updated}` : null].filter(
- (part): part is string => typeof part === "string" && part.length > 0,
- );
- return parts.length > 0 ? parts.join(" · ") : null;
-}
-
-export function ProviderUsageCard({
- usage,
+export function UsageCard({
+ entry,
compact = false,
}: {
- usage: ProviderUsage;
+ entry: UsageReportEntry;
compact?: boolean;
}) {
+ const usage = entry.report;
const status = statusText(usage);
- const footer = footerText(usage);
+ const footer = usage.account.label ?? null;
const balances = usage.balances ?? [];
const details = usage.details ?? [];
@@ -65,9 +41,9 @@ export function ProviderUsageCard({
return (
-
+
- {usage.displayName}
+ {entry.sourceLabel}
{usage.planLabel ? : null}
@@ -88,10 +64,10 @@ export function ProviderUsageCard({
{usage.windows.length > 0 || balances.length > 0 ? (
{usage.windows.map((window) => (
-
+
))}
{balances.map((balance) => (
-
+
))}
) : null}
diff --git a/packages/app/src/usage/composer-pill.tsx b/packages/app/src/usage/composer-pill.tsx
new file mode 100644
index 0000000000..b21065c2fb
--- /dev/null
+++ b/packages/app/src/usage/composer-pill.tsx
@@ -0,0 +1,86 @@
+import { useCallback } from "react";
+import { Text, View } from "react-native";
+import { StyleSheet } from "react-native-unistyles";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import type { MenuTriggerState } from "@/components/ui/menu";
+import {
+ isToolbarLabelTriggerHighlighted,
+ toolbarLabelTriggerStyle,
+ toolbarLabelTriggerTextStyle,
+} from "@/components/ui/toolbar-label-trigger";
+import { UsageCard } from "./card";
+import { usageCopy } from "./copy";
+import type { UsagePill } from "./model";
+import { useAgentUsage } from "./queries";
+import { UsageSourceIcon } from "./source-icon";
+
+function pillTriggerStyle(state: MenuTriggerState) {
+ return [toolbarLabelTriggerStyle(state), styles.trigger];
+}
+
+/** The agent's plan usage beside the composer controls. Opens the usage card. */
+export function UsageComposerPill({ serverId, agentId }: { serverId: string; agentId: string }) {
+ const { pill, entry, refresh } = useAgentUsage(serverId, agentId);
+ const handleOpenChange = useCallback(
+ (open: boolean) => {
+ if (open) refresh();
+ },
+ [refresh],
+ );
+
+ if (!pill || !entry) return null;
+
+ return (
+
+
+ {(state) => (
+
+ )}
+
+
+
+
+
+
+
+ );
+}
+
+function UsagePillContent({ pill, highlighted }: { pill: UsagePill; highlighted: boolean }) {
+ return (
+ <>
+
+ {pill.text ? (
+
+ {pill.text}
+
+ ) : null}
+ >
+ );
+}
+
+const styles = StyleSheet.create((theme) => ({
+ trigger: {
+ height: 28,
+ flexShrink: 0,
+ },
+ popover: {
+ padding: theme.spacing[3],
+ },
+}));
diff --git a/packages/app/src/usage/copy.ts b/packages/app/src/usage/copy.ts
new file mode 100644
index 0000000000..b8330f43d6
--- /dev/null
+++ b/packages/app/src/usage/copy.ts
@@ -0,0 +1,16 @@
+// User-facing copy for the usage surfaces, kept in one file so localization is a
+// single-file change.
+export const usageCopy = {
+ title: "Usage",
+ planUsage: "Plan usage",
+ refresh: "Refresh",
+ refreshing: "Refreshing...",
+ loading: "Loading usage...",
+ empty: "No usage data",
+ noHosts: "No connected hosts",
+ errorTitle: "Unable to load usage",
+ hostUnavailable: "Connect to this host to see usage",
+ hostUpgradeRequired: "Update the host to see usage",
+ clientUnavailable: "Host connection is not ready",
+ retry: "Try again",
+} as const;
diff --git a/packages/app/src/provider-usage/format.ts b/packages/app/src/usage/format.ts
similarity index 63%
rename from packages/app/src/provider-usage/format.ts
rename to packages/app/src/usage/format.ts
index d903818f48..ac8bffefd0 100644
--- a/packages/app/src/provider-usage/format.ts
+++ b/packages/app/src/usage/format.ts
@@ -1,5 +1,5 @@
import { formatTokenCount } from "@/components/context-window-meter.utils";
-import type { ProviderUsageBalanceUnit } from "./types";
+import type { UsageBalanceUnit } from "./types";
export function clampPct(value: number): number {
return Math.max(0, Math.min(100, value));
@@ -28,20 +28,7 @@ export function formatResetLabel(iso: string | null | undefined): string | null
return rel === "now" ? "resetting now" : `resets ${rel}`;
}
-export function formatAgo(iso: string | null | undefined): string | null {
- if (!iso) return null;
- const diffMs = Date.now() - new Date(iso).getTime();
- if (!Number.isFinite(diffMs)) return null;
- if (diffMs < 60_000) return "just now";
- const diffMinutes = Math.floor(diffMs / 60_000);
- const diffHours = Math.floor(diffMinutes / 60);
- const diffDays = Math.floor(diffHours / 24);
- if (diffDays > 0) return `${diffDays}d ago`;
- if (diffHours > 0) return `${diffHours}h ago`;
- return `${diffMinutes}m ago`;
-}
-
-export function formatAmount(value: number, unit: ProviderUsageBalanceUnit): string {
+export function formatAmount(value: number, unit: UsageBalanceUnit): string {
switch (unit) {
case "usd":
return `$${value.toFixed(2)}`;
diff --git a/packages/app/src/usage/host-usage-section.tsx b/packages/app/src/usage/host-usage-section.tsx
new file mode 100644
index 0000000000..857a56e955
--- /dev/null
+++ b/packages/app/src/usage/host-usage-section.tsx
@@ -0,0 +1,11 @@
+import { usageCopy } from "./copy";
+import { useHostUsage } from "./queries";
+import { UsageSection } from "./usage-section";
+
+/** A host's usage reports, for its settings page. */
+export function HostUsageSection({ serverId }: { serverId: string }) {
+ const { view, refresh } = useHostUsage(serverId);
+ return (
+
+ );
+}
diff --git a/packages/app/src/usage/index.ts b/packages/app/src/usage/index.ts
new file mode 100644
index 0000000000..54f745a04d
--- /dev/null
+++ b/packages/app/src/usage/index.ts
@@ -0,0 +1,3 @@
+export { UsageComposerPill } from "./composer-pill";
+export { HostUsageSection } from "./host-usage-section";
+export { UsageScreen } from "./usage-screen";
diff --git a/packages/app/src/provider-usage/list.tsx b/packages/app/src/usage/list.tsx
similarity index 60%
rename from packages/app/src/provider-usage/list.tsx
rename to packages/app/src/usage/list.tsx
index c9ff6742b2..24f8ccd67b 100644
--- a/packages/app/src/provider-usage/list.tsx
+++ b/packages/app/src/usage/list.tsx
@@ -2,16 +2,16 @@ import { Fragment } from "react";
import { View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { settingsStyles } from "@/styles/settings";
-import { ProviderUsageCard } from "./card";
-import type { ProviderUsage } from "./types";
+import { UsageCard } from "./card";
+import type { UsageReportEntry } from "./types";
-export function ProviderUsageList({ providers }: { providers: ProviderUsage[] }) {
+export function UsageList({ reports }: { reports: UsageReportEntry[] }) {
return (
- {providers.map((usage, index) => (
-
+ {reports.map((entry, index) => (
+
{index > 0 ? : null}
-
+
))}
diff --git a/packages/app/src/usage/model.test.ts b/packages/app/src/usage/model.test.ts
new file mode 100644
index 0000000000..ad73ea761b
--- /dev/null
+++ b/packages/app/src/usage/model.test.ts
@@ -0,0 +1,170 @@
+import { describe, expect, it } from "vitest";
+import {
+ groupUsageByHost,
+ headlineWindow,
+ resolveUsagePill,
+ resolveUsageView,
+ type UsageQueryState,
+} from "./model";
+import type { UsageReportEntry, UsageWindow } from "./types";
+
+function entry(input: {
+ windows?: UsageWindow[];
+ planLabel?: string;
+ icon?: string;
+ sourceId?: string;
+}): UsageReportEntry {
+ return {
+ sourceId: input.sourceId ?? "fixture",
+ sourceLabel: "Fixture source",
+ ...(input.icon ? { icon: input.icon } : {}),
+ report: {
+ account: { key: "account-1" },
+ status: "available",
+ ...(input.planLabel ? { planLabel: input.planLabel } : {}),
+ windows: input.windows ?? [],
+ },
+ };
+}
+
+function ready(data: UsageReportEntry[]): UsageQueryState {
+ return { data, error: null, isFetching: false };
+}
+
+describe("resolveUsagePill", () => {
+ it("shows the source icon and the headline window percent", () => {
+ const pill = resolveUsagePill({
+ supportsUsage: true,
+ entry: entry({
+ icon: "",
+ windows: [
+ { id: "session", label: "Session", usedPct: 12 },
+ { id: "weekly", label: "Weekly", usedPct: 64.6, headline: true },
+ ],
+ }),
+ });
+
+ expect(pill).toEqual({ icon: "", sourceLabel: "Fixture source", text: "65%" });
+ });
+
+ it("derives the headline percent from remaining percent", () => {
+ const pill = resolveUsagePill({
+ supportsUsage: true,
+ entry: entry({
+ windows: [{ id: "daily", label: "Daily", remainingPct: 30, headline: true }],
+ }),
+ });
+
+ expect(pill?.text).toBe("70%");
+ });
+
+ it("shows the plan label when no window is the headline", () => {
+ const pill = resolveUsagePill({
+ supportsUsage: true,
+ entry: entry({
+ planLabel: "Pro",
+ windows: [{ id: "session", label: "Session", usedPct: 40 }],
+ }),
+ });
+
+ expect(pill).toEqual({ icon: null, sourceLabel: "Fixture source", text: "Pro" });
+ });
+
+ it("shows only the icon with neither a headline window nor a plan label", () => {
+ const pill = resolveUsagePill({ supportsUsage: true, entry: entry({}) });
+
+ expect(pill).toEqual({ icon: null, sourceLabel: "Fixture source", text: null });
+ });
+
+ it("is hidden when the agent has no usage report", () => {
+ expect(resolveUsagePill({ supportsUsage: true, entry: null })).toBeNull();
+ });
+
+ it("is hidden on a host without usage sources, whatever data is cached", () => {
+ const cached = entry({ windows: [{ id: "w", label: "W", usedPct: 5, headline: true }] });
+
+ expect(resolveUsagePill({ supportsUsage: false, entry: cached })).toBeNull();
+ });
+});
+
+describe("headlineWindow", () => {
+ it("does not fall back to the first window", () => {
+ expect(headlineWindow(entry({ windows: [{ id: "a", label: "A", usedPct: 1 }] }).report)).toBe(
+ null,
+ );
+ });
+});
+
+describe("resolveUsageView", () => {
+ it("asks for a host update when the host lacks usage sources", () => {
+ expect(
+ resolveUsageView({ isConnected: true, supportsUsage: false, query: ready([entry({})]) }),
+ ).toEqual({ kind: "unavailable", message: "Update the host to see usage" });
+ });
+
+ it("asks for a connection before anything else", () => {
+ expect(
+ resolveUsageView({ isConnected: false, supportsUsage: false, query: undefined }),
+ ).toEqual({ kind: "unavailable", message: "Connect to this host to see usage" });
+ });
+
+ it("moves from loading to ready to error", () => {
+ const base = { isConnected: true, supportsUsage: true };
+ expect(resolveUsageView({ ...base, query: undefined })).toEqual({ kind: "loading" });
+ expect(
+ resolveUsageView({ ...base, query: { data: [], error: null, isFetching: true } }),
+ ).toEqual({ kind: "ready", reports: [], isRefreshing: true });
+ expect(
+ resolveUsageView({
+ ...base,
+ query: { data: undefined, error: new Error("boom"), isFetching: false },
+ }),
+ ).toEqual({ kind: "error", message: "boom" });
+ });
+});
+
+describe("groupUsageByHost", () => {
+ it("groups reports under each connected host in host order", () => {
+ const first = entry({ sourceId: "one" });
+ const second = entry({ sourceId: "two" });
+ const groups = groupUsageByHost(
+ [
+ { serverId: "b", label: "Beta", isConnected: true, supportsUsage: true },
+ { serverId: "offline", label: "Offline", isConnected: false, supportsUsage: true },
+ { serverId: "a", label: "Alpha", isConnected: true, supportsUsage: true },
+ ],
+ new Map([
+ ["a", ready([first])],
+ ["b", ready([second, first])],
+ ]),
+ );
+
+ expect(groups).toEqual([
+ {
+ serverId: "b",
+ label: "Beta",
+ view: { kind: "ready", reports: [second, first], isRefreshing: false },
+ },
+ {
+ serverId: "a",
+ label: "Alpha",
+ view: { kind: "ready", reports: [first], isRefreshing: false },
+ },
+ ]);
+ });
+
+ it("shows the update message in an old host's group", () => {
+ const groups = groupUsageByHost(
+ [
+ { serverId: "new", label: "New", isConnected: true, supportsUsage: true },
+ { serverId: "old", label: "Old", isConnected: true, supportsUsage: false },
+ ],
+ new Map([["new", ready([])]]),
+ );
+
+ expect(groups.map((group) => [group.label, group.view])).toEqual([
+ ["New", { kind: "ready", reports: [], isRefreshing: false }],
+ ["Old", { kind: "unavailable", message: "Update the host to see usage" }],
+ ]);
+ });
+});
diff --git a/packages/app/src/usage/model.ts b/packages/app/src/usage/model.ts
new file mode 100644
index 0000000000..ef74cdcdae
--- /dev/null
+++ b/packages/app/src/usage/model.ts
@@ -0,0 +1,95 @@
+import { usageCopy } from "./copy";
+import type { UsageReport, UsageReportEntry, UsageView, UsageWindow } from "./types";
+
+export function usedPercent(window: UsageWindow): number | null {
+ if (window.usedPct != null) return window.usedPct;
+ if (window.remainingPct != null) return 100 - window.remainingPct;
+ return null;
+}
+
+/** The window the source marked as its headline. Sources own that choice; there is no fallback. */
+export function headlineWindow(report: UsageReport): UsageWindow | null {
+ return report.windows.find((window) => window.headline === true) ?? null;
+}
+
+export interface UsagePill {
+ icon: string | null;
+ sourceLabel: string;
+ /** Headline percent, else the plan label, else nothing beside the icon. */
+ text: string | null;
+}
+
+export function resolveUsagePill(input: {
+ supportsUsage: boolean;
+ entry: UsageReportEntry | null | undefined;
+}): UsagePill | null {
+ const { supportsUsage, entry } = input;
+ if (!supportsUsage || !entry) return null;
+ const window = headlineWindow(entry.report);
+ const percent = window ? usedPercent(window) : null;
+ return {
+ icon: entry.icon ?? null,
+ sourceLabel: entry.sourceLabel,
+ text:
+ percent != null
+ ? `${Math.round(Math.max(0, Math.min(100, percent)))}%`
+ : (entry.report.planLabel ?? null),
+ };
+}
+
+export interface UsageQueryState {
+ data: UsageReportEntry[] | undefined;
+ error: unknown;
+ isFetching: boolean;
+}
+
+export function resolveUsageView(input: {
+ isConnected: boolean;
+ supportsUsage: boolean;
+ query: UsageQueryState | undefined;
+}): UsageView {
+ const { isConnected, supportsUsage, query } = input;
+ if (!isConnected) return { kind: "unavailable", message: usageCopy.hostUnavailable };
+ if (!supportsUsage) return { kind: "unavailable", message: usageCopy.hostUpgradeRequired };
+ if (query?.data) {
+ return { kind: "ready", reports: query.data, isRefreshing: query.isFetching };
+ }
+ if (query?.error) {
+ return {
+ kind: "error",
+ message: query.error instanceof Error ? query.error.message : String(query.error),
+ };
+ }
+ return { kind: "loading" };
+}
+
+export interface UsageHost {
+ serverId: string;
+ label: string;
+ isConnected: boolean;
+ supportsUsage: boolean;
+}
+
+export interface UsageHostGroup {
+ serverId: string;
+ label: string;
+ view: UsageView;
+}
+
+/** One group per connected host, in host order. */
+export function groupUsageByHost(
+ hosts: readonly UsageHost[],
+ queries: ReadonlyMap,
+): UsageHostGroup[] {
+ return hosts
+ .filter((host) => host.isConnected)
+ .map((host) => ({
+ serverId: host.serverId,
+ label: host.label,
+ view: resolveUsageView({
+ isConnected: true,
+ supportsUsage: host.supportsUsage,
+ query: queries.get(host.serverId),
+ }),
+ }));
+}
diff --git a/packages/app/src/usage/queries.ts b/packages/app/src/usage/queries.ts
new file mode 100644
index 0000000000..8937e37f9b
--- /dev/null
+++ b/packages/app/src/usage/queries.ts
@@ -0,0 +1,178 @@
+import { useCallback, useMemo } from "react";
+import { useQueryClient, type QueryClient } from "@tanstack/react-query";
+import { useShallow } from "zustand/shallow";
+import { useFetchQueries, useFetchQuery } from "@/data/query";
+import {
+ getHostRuntimeStore,
+ useHostRuntimeConnectionStatuses,
+ useHostRuntimeIsConnected,
+ useHosts,
+} from "@/runtime/host-runtime";
+import { useSessionStore, type SessionState } from "@/stores/session-store";
+import { usageCopy } from "./copy";
+import {
+ groupUsageByHost,
+ resolveUsagePill,
+ resolveUsageView,
+ type UsageHostGroup,
+ type UsagePill,
+ type UsageQueryState,
+} from "./model";
+import type { UsageReportEntry, UsageView } from "./types";
+
+// The daemon caches each report for five minutes, so re-reading it is cheap. Only
+// an explicit refresh passes `forceRefresh` and reaches the source's API.
+const REPORTS_STALE_TIME_MS = 60_000;
+
+function usageReportsQueryKey(serverId: string) {
+ return ["usage", "reports", serverId] as const;
+}
+
+function agentUsageQueryKey(serverId: string, agentId: string, model: string | null) {
+ return ["usage", "agent", serverId, agentId, model] as const;
+}
+
+function requireClient(serverId: string) {
+ const client = getHostRuntimeStore().getClient(serverId);
+ if (!client) throw new Error(usageCopy.clientUnavailable);
+ return client;
+}
+
+async function listReports(serverId: string, forceRefresh = false): Promise {
+ return (await requireClient(serverId).listUsageReports({ forceRefresh })).reports;
+}
+
+async function getAgentReport(
+ serverId: string,
+ agentId: string,
+ forceRefresh = false,
+): Promise {
+ return (await requireClient(serverId).getAgentUsageReport({ agentId, forceRefresh })).entry;
+}
+
+function supportsUsage(session: SessionState | undefined): boolean {
+ return session?.serverInfo?.features?.usageSources === true;
+}
+
+async function refreshReports(queryClient: QueryClient, serverId: string): Promise {
+ await queryClient.fetchQuery({
+ queryKey: usageReportsQueryKey(serverId),
+ queryFn: () => listReports(serverId, true),
+ staleTime: 0,
+ });
+}
+
+function toQueryState(query: {
+ data: UsageReportEntry[] | undefined;
+ error: unknown;
+ isFetching: boolean;
+}): UsageQueryState {
+ return { data: query.data, error: query.error, isFetching: query.isFetching };
+}
+
+/** Usage reports for one host, as shown on its settings page. */
+export function useHostUsage(serverId: string): { view: UsageView; refresh: () => void } {
+ const queryClient = useQueryClient();
+ const isConnected = useHostRuntimeIsConnected(serverId);
+ const isSupported = useSessionStore((state) => supportsUsage(state.sessions[serverId]));
+ const query = useFetchQuery({
+ queryKey: usageReportsQueryKey(serverId),
+ queryFn: () => listReports(serverId),
+ enabled: isConnected && isSupported,
+ dataShape: "list",
+ staleTimeMs: REPORTS_STALE_TIME_MS,
+ });
+ const refresh = useCallback(() => {
+ void refreshReports(queryClient, serverId).catch(() => undefined);
+ }, [queryClient, serverId]);
+ const view = resolveUsageView({
+ isConnected,
+ supportsUsage: isSupported,
+ query: toQueryState(query),
+ });
+ return { view, refresh };
+}
+
+/** Usage reports for every connected host, grouped by host. */
+export function useUsageByHost(): {
+ groups: UsageHostGroup[];
+ refresh: (serverId: string) => void;
+} {
+ const queryClient = useQueryClient();
+ const hosts = useHosts();
+ const serverIds = useMemo(() => hosts.map((host) => host.serverId), [hosts]);
+ const connectionStatuses = useHostRuntimeConnectionStatuses(serverIds);
+ const supportedServerIds = useSessionStore(
+ useShallow((state) => serverIds.filter((serverId) => supportsUsage(state.sessions[serverId]))),
+ );
+ const usageHosts = useMemo(
+ () =>
+ hosts.map((host) => ({
+ serverId: host.serverId,
+ label: host.label,
+ isConnected: connectionStatuses.get(host.serverId) === "online",
+ supportsUsage: supportedServerIds.includes(host.serverId),
+ })),
+ [connectionStatuses, hosts, supportedServerIds],
+ );
+ const results = useFetchQueries(
+ usageHosts.map((host) => ({
+ queryKey: usageReportsQueryKey(host.serverId),
+ queryFn: () => listReports(host.serverId),
+ enabled: host.isConnected && host.supportsUsage,
+ dataShape: "list",
+ staleTimeMs: REPORTS_STALE_TIME_MS,
+ })),
+ );
+ const groups = groupUsageByHost(
+ usageHosts,
+ new Map(usageHosts.map((host, index) => [host.serverId, toQueryState(results[index])])),
+ );
+ const refresh = useCallback(
+ (serverId: string) => {
+ void refreshReports(queryClient, serverId).catch(() => undefined);
+ },
+ [queryClient],
+ );
+ return { groups, refresh };
+}
+
+/**
+ * The usage report for the account an agent is spending. The daemon resolves the
+ * account at fetch time, so the query refetches when the model changes (a new key)
+ * and when a turn completes (the query is paused while the agent runs, and its
+ * data is always stale, so resuming refetches).
+ */
+export function useAgentUsage(
+ serverId: string,
+ agentId: string,
+): { pill: UsagePill | null; entry: UsageReportEntry | null; refresh: () => void } {
+ const queryClient = useQueryClient();
+ const isConnected = useHostRuntimeIsConnected(serverId);
+ const isSupported = useSessionStore((state) => supportsUsage(state.sessions[serverId]));
+ const { model, isRunning } = useSessionStore(
+ useShallow((state) => {
+ const agent = state.sessions[serverId]?.agents?.get(agentId);
+ return { model: agent?.model ?? null, isRunning: agent?.status === "running" };
+ }),
+ );
+ const queryKey = agentUsageQueryKey(serverId, agentId, model);
+ const query = useFetchQuery({
+ queryKey,
+ queryFn: () => getAgentReport(serverId, agentId),
+ enabled: isConnected && isSupported && !isRunning,
+ dataShape: "value",
+ staleTimeMs: 0,
+ });
+ const refresh = useCallback(() => {
+ void queryClient
+ .fetchQuery({
+ queryKey: agentUsageQueryKey(serverId, agentId, model),
+ queryFn: () => getAgentReport(serverId, agentId, true),
+ staleTime: 0,
+ })
+ .catch(() => undefined);
+ }, [agentId, model, queryClient, serverId]);
+ const entry = isSupported ? (query.data ?? null) : null;
+ return { pill: resolveUsagePill({ supportsUsage: isSupported, entry }), entry, refresh };
+}
diff --git a/packages/app/src/usage/source-icon.tsx b/packages/app/src/usage/source-icon.tsx
new file mode 100644
index 0000000000..779f42f841
--- /dev/null
+++ b/packages/app/src/usage/source-icon.tsx
@@ -0,0 +1,26 @@
+import { Gauge } from "lucide-react-native";
+import { withUnistyles } from "react-native-unistyles";
+import { SvgIcon } from "@/components/provider-icons";
+import type { Theme } from "@/styles/theme";
+
+function UsageSourceIconBase({
+ svg,
+ size,
+ color = "",
+}: {
+ svg: string | null;
+ size: number;
+ color?: string;
+}) {
+ if (!svg) return ;
+ return ;
+}
+
+const ThemedUsageSourceIcon = withUnistyles(UsageSourceIconBase);
+
+const mutedIconColor = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
+
+/** The source's catalog SVG, or a generic gauge when the source ships none. */
+export function UsageSourceIcon({ svg, size }: { svg: string | null; size: number }) {
+ return ;
+}
diff --git a/packages/app/src/provider-usage/tone.ts b/packages/app/src/usage/tone.ts
similarity index 74%
rename from packages/app/src/provider-usage/tone.ts
rename to packages/app/src/usage/tone.ts
index 5cf6f68bab..4727853c2f 100644
--- a/packages/app/src/provider-usage/tone.ts
+++ b/packages/app/src/usage/tone.ts
@@ -1,6 +1,6 @@
-import type { ProviderUsageTone } from "./types";
+import type { UsageTone } from "./types";
-export function deriveTone(usedPct: number | null | undefined): ProviderUsageTone {
+export function deriveTone(usedPct: number | null | undefined): UsageTone {
if (usedPct == null) return "default";
if (usedPct > 90) return "danger";
if (usedPct >= 70) return "warning";
diff --git a/packages/app/src/usage/types.ts b/packages/app/src/usage/types.ts
new file mode 100644
index 0000000000..2e2485b2d5
--- /dev/null
+++ b/packages/app/src/usage/types.ts
@@ -0,0 +1,15 @@
+import type { ProviderUsageTone, UsageReportEntry } from "@getpaseo/protocol/messages";
+
+export type { UsageReportEntry };
+export type UsageReport = UsageReportEntry["report"];
+export type UsageTone = ProviderUsageTone;
+export type UsageWindow = UsageReport["windows"][number];
+export type UsageBalance = NonNullable[number];
+export type UsageBalanceUnit = UsageBalance["unit"];
+
+/** What a host's usage surface shows. `unavailable` is a host state, not a failed request. */
+export type UsageView =
+ | { kind: "unavailable"; message: string }
+ | { kind: "loading" }
+ | { kind: "error"; message: string }
+ | { kind: "ready"; reports: UsageReportEntry[]; isRefreshing: boolean };
diff --git a/packages/app/src/usage/usage-screen.tsx b/packages/app/src/usage/usage-screen.tsx
new file mode 100644
index 0000000000..8fc4c429db
--- /dev/null
+++ b/packages/app/src/usage/usage-screen.tsx
@@ -0,0 +1,70 @@
+import { useCallback } from "react";
+import { ScrollView, View } from "react-native";
+import { useIsFocused } from "@react-navigation/native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { StyleSheet } from "react-native-unistyles";
+import { MenuHeader } from "@/components/headers/menu-header";
+import { usageCopy } from "./copy";
+import type { UsageHostGroup } from "./model";
+import { useUsageByHost } from "./queries";
+import { UsageMessage, UsageSection } from "./usage-section";
+
+export function UsageScreen() {
+ const isFocused = useIsFocused();
+ return (
+
+
+ {isFocused ? : null}
+
+ );
+}
+
+function UsageScreenContent() {
+ const insets = useSafeAreaInsets();
+ const { groups, refresh } = useUsageByHost();
+ return (
+
+
+ {groups.length === 0 ? : null}
+ {groups.map((group) => (
+
+ ))}
+
+
+ );
+}
+
+function HostUsageGroup({
+ group,
+ onRefresh,
+}: {
+ group: UsageHostGroup;
+ onRefresh: (serverId: string) => void;
+}) {
+ const handleRefresh = useCallback(() => onRefresh(group.serverId), [group.serverId, onRefresh]);
+ return (
+
+ );
+}
+
+const styles = StyleSheet.create((theme) => ({
+ container: {
+ flex: 1,
+ backgroundColor: theme.colors.surface0,
+ },
+ scroll: {
+ flex: 1,
+ },
+ content: {
+ padding: theme.spacing[4],
+ paddingTop: theme.spacing[6],
+ width: "100%",
+ maxWidth: 720,
+ alignSelf: "center",
+ },
+}));
diff --git a/packages/app/src/usage/usage-section.tsx b/packages/app/src/usage/usage-section.tsx
new file mode 100644
index 0000000000..37d0105261
--- /dev/null
+++ b/packages/app/src/usage/usage-section.tsx
@@ -0,0 +1,93 @@
+import { RefreshCw } from "lucide-react-native";
+import { useMemo } from "react";
+import { Text, View } from "react-native";
+import { StyleSheet } from "react-native-unistyles";
+import { Alert } from "@/components/ui/alert";
+import { Button } from "@/components/ui/button";
+import { settingsStyles } from "@/styles/settings";
+import { SettingsSection } from "@/components/settings/headings/settings-section";
+import { usageCopy } from "./copy";
+import { UsageList } from "./list";
+import type { UsageView } from "./types";
+
+export function UsageSection({
+ title,
+ view,
+ onRefresh,
+ testID,
+}: {
+ title: string;
+ view: UsageView;
+ onRefresh: () => void;
+ testID?: string;
+}) {
+ const busy = view.kind === "loading" || (view.kind === "ready" && view.isRefreshing);
+
+ const refreshButton = useMemo(
+ () =>
+ view.kind === "unavailable" ? null : (
+
+ ),
+ [busy, onRefresh, view.kind],
+ );
+
+ return (
+
+
+
+ );
+}
+
+function UsageBody({ view, onRefresh }: { view: UsageView; onRefresh: () => void }) {
+ if (view.kind === "unavailable") {
+ return ;
+ }
+
+ if (view.kind === "loading") {
+ return ;
+ }
+
+ if (view.kind === "error") {
+ return (
+
+
+
+ );
+ }
+
+ if (view.reports.length === 0) {
+ return ;
+ }
+
+ return ;
+}
+
+export function UsageMessage({ text }: { text: string }) {
+ return (
+
+ {text}
+
+ );
+}
+
+const styles = StyleSheet.create((theme) => ({
+ emptyCard: {
+ padding: theme.spacing[4],
+ alignItems: "center",
+ },
+ emptyText: {
+ color: theme.colors.foregroundMuted,
+ fontSize: theme.fontSize.base,
+ },
+}));
diff --git a/packages/app/src/provider-usage/window-bar.tsx b/packages/app/src/usage/window-bar.tsx
similarity index 85%
rename from packages/app/src/provider-usage/window-bar.tsx
rename to packages/app/src/usage/window-bar.tsx
index 0bd198521b..9627cb1571 100644
--- a/packages/app/src/provider-usage/window-bar.tsx
+++ b/packages/app/src/usage/window-bar.tsx
@@ -2,16 +2,11 @@ import { useMemo } from "react";
import { Text, View, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { clampPct, formatPct, formatResetLabel } from "./format";
+import { usedPercent } from "./model";
import { deriveTone } from "./tone";
-import type { ProviderUsageTone, ProviderUsageWindow } from "./types";
+import type { UsageTone, UsageWindow } from "./types";
-function resolveUsedPct(window: ProviderUsageWindow): number | null {
- if (window.usedPct != null) return window.usedPct;
- if (window.remainingPct != null) return 100 - window.remainingPct;
- return null;
-}
-
-function fillToneStyle(tone: ProviderUsageTone) {
+function fillToneStyle(tone: UsageTone) {
switch (tone) {
case "ok":
return styles.fillOk;
@@ -24,8 +19,8 @@ function fillToneStyle(tone: ProviderUsageTone) {
}
}
-export function ProviderUsageWindowBar({ window }: { window: ProviderUsageWindow }) {
- const usedPct = resolveUsedPct(window);
+export function UsageWindowBar({ window }: { window: UsageWindow }) {
+ const usedPct = usedPercent(window);
const tone = window.tone ?? deriveTone(usedPct);
const fillWidth = clampPct(usedPct ?? 0);
diff --git a/packages/app/src/utils/host-routes.ts b/packages/app/src/utils/host-routes.ts
index c13d6f25f9..1dc3ff48bb 100644
--- a/packages/app/src/utils/host-routes.ts
+++ b/packages/app/src/utils/host-routes.ts
@@ -428,6 +428,10 @@ export function buildSchedulesRoute() {
return "/schedules" as const;
}
+export function buildUsageRoute() {
+ return "/usage" as const;
+}
+
export function buildOpenProjectRoute() {
return "/open-project" as const;
}