diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx index 2d5e5398bc51..10878179d2ac 100644 --- a/apps/mobile/src/features/usage/UsageRouteScreen.tsx +++ b/apps/mobile/src/features/usage/UsageRouteScreen.tsx @@ -101,8 +101,8 @@ export function UsageRouteScreen() { ); const isFocused = useIsFocused(); const limits = useRefreshLimits(selectedEnvironmentIds, isFocused && tab === "limits"); - const cursorAccessEnvironments = selectedEnvironments.filter((environment) => - environment.summary?.sources.some((source) => source.action === "enableCursorKeychain"), + const cursorAccessEnvironments = selectedEnvironments.filter( + (environment) => environment.needsCursorKeychainAccess, ); const refreshAfterCursorEnable = () => { void refresh(); diff --git a/apps/mobile/src/state/usage.ts b/apps/mobile/src/state/usage.ts index d49c26a40a44..c5895d353fef 100644 --- a/apps/mobile/src/state/usage.ts +++ b/apps/mobile/src/state/usage.ts @@ -16,7 +16,7 @@ import { type UsageSummary, type UsageSummaryInput, } from "@t3tools/contracts"; -import { refreshUsage } from "@t3tools/client-runtime/state/usage"; +import { needsCursorKeychainAccess, refreshUsage } from "@t3tools/client-runtime/state/usage"; import { mergeUsage, type EnvironmentUsage, type MergedUsage } from "@t3tools/shared/usageMerge"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; @@ -33,6 +33,7 @@ export interface EnvironmentUsageStatus { readonly isConnected: boolean; readonly error: string | null; readonly summary: UsageSummary | null; + readonly needsCursorKeychainAccess: boolean; } /** @@ -50,13 +51,18 @@ const usageByWindowAtom = Atom.family((windowKey: string) => const statuses: EnvironmentUsageStatus[] = []; for (const [environmentId, presentation] of presentations) { const result = get(serverEnvironment.usageSummary({ environmentId, input })); + const summary = Option.getOrNull(AsyncResult.value(result)); statuses.push({ environmentId, label: presentation.entry.target.label, isPending: result.waiting, isConnected: presentation.connection.phase === "connected", error: result._tag === "Failure" ? "This environment could not report usage." : null, - summary: Option.getOrNull(AsyncResult.value(result)), + summary, + needsCursorKeychainAccess: needsCursorKeychainAccess( + summary, + get(serverEnvironment.providersValueAtom(environmentId)), + ), }); } return statuses; diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 1387bb3a9d9e..5f2d7dd45632 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -121,8 +121,8 @@ export function UsagePage() { selectedEnvironmentIds, ); const presentations = useAtomValue(environmentPresentations.presentationsAtom); - const cursorAccessEnvironments = selectedEnvironments.filter((environment) => - environment.summary?.sources.some((source) => source.action === "enableCursorKeychain"), + const cursorAccessEnvironments = selectedEnvironments.filter( + (environment) => environment.needsCursorKeychainAccess, ); const sourceMessages = [ ...new Set( diff --git a/apps/web/src/state/usage.test.tsx b/apps/web/src/state/usage.test.tsx index e01478e6a71a..c80b642b76b7 100644 --- a/apps/web/src/state/usage.test.tsx +++ b/apps/web/src/state/usage.test.tsx @@ -23,6 +23,7 @@ function environment(id: string, cost: number | null, hostId = id): EnvironmentU label: id, isPending: cost === null, error: null, + needsCursorKeychainAccess: false, summary: cost === null ? null diff --git a/apps/web/src/state/usage.ts b/apps/web/src/state/usage.ts index 617ac93e4b7c..f61bb9060384 100644 --- a/apps/web/src/state/usage.ts +++ b/apps/web/src/state/usage.ts @@ -13,7 +13,7 @@ import { type UsageSummary, type UsageSummaryInput, } from "@t3tools/contracts"; -import { refreshUsage } from "@t3tools/client-runtime/state/usage"; +import { needsCursorKeychainAccess, refreshUsage } from "@t3tools/client-runtime/state/usage"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { useCallback, useMemo } from "react"; @@ -29,6 +29,7 @@ export interface EnvironmentUsageStatus { readonly isPending: boolean; readonly error: string | null; readonly summary: UsageSummary | null; + readonly needsCursorKeychainAccess: boolean; } /** @@ -46,12 +47,17 @@ const usageByWindowAtom = Atom.family((windowKey: string) => const statuses: EnvironmentUsageStatus[] = []; for (const [environmentId, presentation] of presentations) { const result = get(serverEnvironment.usageSummary({ environmentId, input })); + const summary = Option.getOrNull(AsyncResult.value(result)); statuses.push({ environmentId, label: presentation.entry.target.label, isPending: result.waiting, error: result._tag === "Failure" ? "This environment could not report usage." : null, - summary: Option.getOrNull(AsyncResult.value(result)), + summary, + needsCursorKeychainAccess: needsCursorKeychainAccess( + summary, + get(serverEnvironment.providersValueAtom(environmentId)), + ), }); } return statuses; diff --git a/packages/client-runtime/src/state/usage.test.ts b/packages/client-runtime/src/state/usage.test.ts index 75977d20de74..55fe46306ddd 100644 --- a/packages/client-runtime/src/state/usage.test.ts +++ b/packages/client-runtime/src/state/usage.test.ts @@ -1,7 +1,10 @@ import { EnvironmentId, + ProviderDriverKind, + ProviderInstanceId, UsageDay, USAGE_CONTRACT_VERSION, + type ServerProvider, type UsageSummary, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; @@ -10,7 +13,7 @@ import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import type { EnvironmentPresentation } from "../connection/presentation.ts"; import { EnvironmentRpcUnavailableError } from "../rpc/client.ts"; -import { refreshUsage, refreshUsageLimits } from "./usage.ts"; +import { needsCursorKeychainAccess, refreshUsage, refreshUsageLimits } from "./usage.ts"; const input = { sinceDay: UsageDay.make("2026-09-05"), @@ -238,3 +241,48 @@ describe("limits refresh cooldown", () => { } }); }); + +describe("needsCursorKeychainAccess", () => { + const cursorPrompt: UsageSummary = { + ...summary, + sources: [ + { + fingerprint: { + hostId: "host", + provider: "cursor", + resolvedHomePath: "/Users/me/.cursor/auth.json", + volumeId: "volume", + }, + status: "ok", + scannedFiles: 0, + skippedFiles: 0, + malformedRecords: 0, + distinctSessions: 0, + message: "Cursor account usage is off on this environment.", + action: "enableCursorKeychain", + }, + ], + }; + const cursor = (status: ServerProvider["status"]): ServerProvider => ({ + instanceId: ProviderInstanceId.make("cursor"), + driver: ProviderDriverKind.make("cursor"), + enabled: status !== "disabled", + installed: status === "ready", + version: null, + status, + auth: { status: "unknown" }, + checkedAt: "2026-09-05T12:00:00.000Z", + models: [], + slashCommands: [], + skills: [], + }); + + it("offers access only when Cursor is ready on that environment", () => { + expect(needsCursorKeychainAccess(cursorPrompt, [cursor("ready")])).toBe(true); + expect(needsCursorKeychainAccess(cursorPrompt, [cursor("error")])).toBe(false); + expect(needsCursorKeychainAccess(cursorPrompt, [cursor("disabled")])).toBe(false); + expect(needsCursorKeychainAccess(cursorPrompt, [])).toBe(false); + expect(needsCursorKeychainAccess(cursorPrompt, null)).toBe(false); + expect(needsCursorKeychainAccess(summary, [cursor("ready")])).toBe(false); + }); +}); diff --git a/packages/client-runtime/src/state/usage.ts b/packages/client-runtime/src/state/usage.ts index 11cfd26fbe6c..dc959c0fd8f0 100644 --- a/packages/client-runtime/src/state/usage.ts +++ b/packages/client-runtime/src/state/usage.ts @@ -1,4 +1,9 @@ -import type { EnvironmentId, UsageSummaryInput } from "@t3tools/contracts"; +import type { + EnvironmentId, + ServerProvider, + UsageSummary, + UsageSummaryInput, +} from "@t3tools/contracts"; import * as Schema from "effect/Schema"; import type { AtomRegistry } from "effect/unstable/reactivity"; @@ -9,6 +14,18 @@ import type { createServerEnvironmentAtoms } from "./server.ts"; const isEnvironmentRpcUnavailable = Schema.is(EnvironmentRpcUnavailableError); +/** Offer the Cursor Keychain prompt only where a working Cursor provider could use it. */ +export function needsCursorKeychainAccess( + summary: UsageSummary | null, + providers: readonly ServerProvider[] | null, +): boolean { + return ( + summary?.sources.some((source) => source.action === "enableCursorKeychain") === true && + providers?.some((provider) => provider.driver === "cursor" && provider.status === "ready") === + true + ); +} + const limitsRefreshAfter = new Map(); const limitsRefreshes = new Map>();