| author | |
| committer | |
| log | 94c42162b443072ca59a0733c02ee54229a61fef |
| tree | 7543a044f0a4f8c9a318ced4d5cbc63154c58426 |
| parent | 999161ef844a7a38c8a6d08195c1240759169832 |
| signature | Signed by PGP key B5690EEEBB952194 |
7 files changed, 88 insertions(+), 10 deletions(-)
apps/mobile/src/features/usage/UsageRouteScreen.tsx+2-2| ... | ... | @@ -101,8 +101,8 @@ export function UsageRouteScreen() { |
| 101 | 101 | ); |
| 102 | 102 | const isFocused = useIsFocused(); |
| 103 | 103 | const limits = useRefreshLimits(selectedEnvironmentIds, isFocused && tab === "limits"); |
| 104 | const cursorAccessEnvironments = selectedEnvironments.filter((environment) => | |
| 105 | environment.summary?.sources.some((source) => source.action === "enableCursorKeychain"), | |
| 104 | const cursorAccessEnvironments = selectedEnvironments.filter( | |
| 105 | (environment) => environment.needsCursorKeychainAccess, | |
| 106 | 106 | ); |
| 107 | 107 | const refreshAfterCursorEnable = () => { |
| 108 | 108 | void refresh(); |
apps/mobile/src/state/usage.ts+8-2| ... | ... | @@ -16,7 +16,7 @@ import { |
| 16 | 16 | type UsageSummary, |
| 17 | 17 | type UsageSummaryInput, |
| 18 | 18 | } from "@t3tools/contracts"; |
| 19 | import { refreshUsage } from "@t3tools/client-runtime/state/usage"; | |
| 19 | import { needsCursorKeychainAccess, refreshUsage } from "@t3tools/client-runtime/state/usage"; | |
| 20 | 20 | import { mergeUsage, type EnvironmentUsage, type MergedUsage } from "@t3tools/shared/usageMerge"; |
| 21 | 21 | import * as Option from "effect/Option"; |
| 22 | 22 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; |
| ... | ... | @@ -33,6 +33,7 @@ export interface EnvironmentUsageStatus { |
| 33 | 33 | readonly isConnected: boolean; |
| 34 | 34 | readonly error: string | null; |
| 35 | 35 | readonly summary: UsageSummary | null; |
| 36 | readonly needsCursorKeychainAccess: boolean; | |
| 36 | 37 | } |
| 37 | 38 | |
| 38 | 39 | /** |
| ... | ... | @@ -50,13 +51,18 @@ const usageByWindowAtom = Atom.family((windowKey: string) => |
| 50 | 51 | const statuses: EnvironmentUsageStatus[] = []; |
| 51 | 52 | for (const [environmentId, presentation] of presentations) { |
| 52 | 53 | const result = get(serverEnvironment.usageSummary({ environmentId, input })); |
| 54 | const summary = Option.getOrNull(AsyncResult.value(result)); | |
| 53 | 55 | statuses.push({ |
| 54 | 56 | environmentId, |
| 55 | 57 | label: presentation.entry.target.label, |
| 56 | 58 | isPending: result.waiting, |
| 57 | 59 | isConnected: presentation.connection.phase === "connected", |
| 58 | 60 | error: result._tag === "Failure" ? "This environment could not report usage." : null, |
| 59 | summary: Option.getOrNull(AsyncResult.value(result)), | |
| 61 | summary, | |
| 62 | needsCursorKeychainAccess: needsCursorKeychainAccess( | |
| 63 | summary, | |
| 64 | get(serverEnvironment.providersValueAtom(environmentId)), | |
| 65 | ), | |
| 60 | 66 | }); |
| 61 | 67 | } |
| 62 | 68 | return statuses; |
apps/web/src/components/usage/UsagePage.tsx+2-2| ... | ... | @@ -121,8 +121,8 @@ export function UsagePage() { |
| 121 | 121 | selectedEnvironmentIds, |
| 122 | 122 | ); |
| 123 | 123 | const presentations = useAtomValue(environmentPresentations.presentationsAtom); |
| 124 | const cursorAccessEnvironments = selectedEnvironments.filter((environment) => | |
| 125 | environment.summary?.sources.some((source) => source.action === "enableCursorKeychain"), | |
| 124 | const cursorAccessEnvironments = selectedEnvironments.filter( | |
| 125 | (environment) => environment.needsCursorKeychainAccess, | |
| 126 | 126 | ); |
| 127 | 127 | const sourceMessages = [ |
| 128 | 128 | ...new Set( |
apps/web/src/state/usage.test.tsx+1| ... | ... | @@ -23,6 +23,7 @@ function environment(id: string, cost: number | null, hostId = id): EnvironmentU |
| 23 | 23 | label: id, |
| 24 | 24 | isPending: cost === null, |
| 25 | 25 | error: null, |
| 26 | needsCursorKeychainAccess: false, | |
| 26 | 27 | summary: |
| 27 | 28 | cost === null |
| 28 | 29 | ? null |
apps/web/src/state/usage.ts+8-2| ... | ... | @@ -13,7 +13,7 @@ import { |
| 13 | 13 | type UsageSummary, |
| 14 | 14 | type UsageSummaryInput, |
| 15 | 15 | } from "@t3tools/contracts"; |
| 16 | import { refreshUsage } from "@t3tools/client-runtime/state/usage"; | |
| 16 | import { needsCursorKeychainAccess, refreshUsage } from "@t3tools/client-runtime/state/usage"; | |
| 17 | 17 | import * as Option from "effect/Option"; |
| 18 | 18 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; |
| 19 | 19 | import { useCallback, useMemo } from "react"; |
| ... | ... | @@ -29,6 +29,7 @@ export interface EnvironmentUsageStatus { |
| 29 | 29 | readonly isPending: boolean; |
| 30 | 30 | readonly error: string | null; |
| 31 | 31 | readonly summary: UsageSummary | null; |
| 32 | readonly needsCursorKeychainAccess: boolean; | |
| 32 | 33 | } |
| 33 | 34 | |
| 34 | 35 | /** |
| ... | ... | @@ -46,12 +47,17 @@ const usageByWindowAtom = Atom.family((windowKey: string) => |
| 46 | 47 | const statuses: EnvironmentUsageStatus[] = []; |
| 47 | 48 | for (const [environmentId, presentation] of presentations) { |
| 48 | 49 | const result = get(serverEnvironment.usageSummary({ environmentId, input })); |
| 50 | const summary = Option.getOrNull(AsyncResult.value(result)); | |
| 49 | 51 | statuses.push({ |
| 50 | 52 | environmentId, |
| 51 | 53 | label: presentation.entry.target.label, |
| 52 | 54 | isPending: result.waiting, |
| 53 | 55 | error: result._tag === "Failure" ? "This environment could not report usage." : null, |
| 54 | summary: Option.getOrNull(AsyncResult.value(result)), | |
| 56 | summary, | |
| 57 | needsCursorKeychainAccess: needsCursorKeychainAccess( | |
| 58 | summary, | |
| 59 | get(serverEnvironment.providersValueAtom(environmentId)), | |
| 60 | ), | |
| 55 | 61 | }); |
| 56 | 62 | } |
| 57 | 63 | return statuses; |
packages/client-runtime/src/state/usage.test.ts+49-1| ... | ... | @@ -1,7 +1,10 @@ |
| 1 | 1 | import { |
| 2 | 2 | EnvironmentId, |
| 3 | ProviderDriverKind, | |
| 4 | ProviderInstanceId, | |
| 3 | 5 | UsageDay, |
| 4 | 6 | USAGE_CONTRACT_VERSION, |
| 7 | type ServerProvider, | |
| 5 | 8 | type UsageSummary, |
| 6 | 9 | } from "@t3tools/contracts"; |
| 7 | 10 | import * as Effect from "effect/Effect"; |
| ... | ... | @@ -10,7 +13,7 @@ import { afterEach, describe, expect, it, vi } from "vite-plus/test"; |
| 10 | 13 | |
| 11 | 14 | import type { EnvironmentPresentation } from "../connection/presentation.ts"; |
| 12 | 15 | import { EnvironmentRpcUnavailableError } from "../rpc/client.ts"; |
| 13 | import { refreshUsage, refreshUsageLimits } from "./usage.ts"; | |
| 16 | import { needsCursorKeychainAccess, refreshUsage, refreshUsageLimits } from "./usage.ts"; | |
| 14 | 17 | |
| 15 | 18 | const input = { |
| 16 | 19 | sinceDay: UsageDay.make("2026-09-05"), |
| ... | ... | @@ -238,3 +241,48 @@ describe("limits refresh cooldown", () => { |
| 238 | 241 | } |
| 239 | 242 | }); |
| 240 | 243 | }); |
| 244 | ||
| 245 | describe("needsCursorKeychainAccess", () => { | |
| 246 | const cursorPrompt: UsageSummary = { | |
| 247 | ...summary, | |
| 248 | sources: [ | |
| 249 | { | |
| 250 | fingerprint: { | |
| 251 | hostId: "host", | |
| 252 | provider: "cursor", | |
| 253 | resolvedHomePath: "/Users/me/.cursor/auth.json", | |
| 254 | volumeId: "volume", | |
| 255 | }, | |
| 256 | status: "ok", | |
| 257 | scannedFiles: 0, | |
| 258 | skippedFiles: 0, | |
| 259 | malformedRecords: 0, | |
| 260 | distinctSessions: 0, | |
| 261 | message: "Cursor account usage is off on this environment.", | |
| 262 | action: "enableCursorKeychain", | |
| 263 | }, | |
| 264 | ], | |
| 265 | }; | |
| 266 | const cursor = (status: ServerProvider["status"]): ServerProvider => ({ | |
| 267 | instanceId: ProviderInstanceId.make("cursor"), | |
| 268 | driver: ProviderDriverKind.make("cursor"), | |
| 269 | enabled: status !== "disabled", | |
| 270 | installed: status === "ready", | |
| 271 | version: null, | |
| 272 | status, | |
| 273 | auth: { status: "unknown" }, | |
| 274 | checkedAt: "2026-09-05T12:00:00.000Z", | |
| 275 | models: [], | |
| 276 | slashCommands: [], | |
| 277 | skills: [], | |
| 278 | }); | |
| 279 | ||
| 280 | it("offers access only when Cursor is ready on that environment", () => { | |
| 281 | expect(needsCursorKeychainAccess(cursorPrompt, [cursor("ready")])).toBe(true); | |
| 282 | expect(needsCursorKeychainAccess(cursorPrompt, [cursor("error")])).toBe(false); | |
| 283 | expect(needsCursorKeychainAccess(cursorPrompt, [cursor("disabled")])).toBe(false); | |
| 284 | expect(needsCursorKeychainAccess(cursorPrompt, [])).toBe(false); | |
| 285 | expect(needsCursorKeychainAccess(cursorPrompt, null)).toBe(false); | |
| 286 | expect(needsCursorKeychainAccess(summary, [cursor("ready")])).toBe(false); | |
| 287 | }); | |
| 288 | }); |
packages/client-runtime/src/state/usage.ts+18-1| ... | ... | @@ -1,4 +1,9 @@ |
| 1 | import type { EnvironmentId, UsageSummaryInput } from "@t3tools/contracts"; | |
| 1 | import type { | |
| 2 | EnvironmentId, | |
| 3 | ServerProvider, | |
| 4 | UsageSummary, | |
| 5 | UsageSummaryInput, | |
| 6 | } from "@t3tools/contracts"; | |
| 2 | 7 | import * as Schema from "effect/Schema"; |
| 3 | 8 | import type { AtomRegistry } from "effect/unstable/reactivity"; |
| 4 | 9 | |
| ... | ... | @@ -9,6 +14,18 @@ import type { createServerEnvironmentAtoms } from "./server.ts"; |
| 9 | 14 | |
| 10 | 15 | const isEnvironmentRpcUnavailable = Schema.is(EnvironmentRpcUnavailableError); |
| 11 | 16 | |
| 17 | /** Offer the Cursor Keychain prompt only where a working Cursor provider could use it. */ | |
| 18 | export function needsCursorKeychainAccess( | |
| 19 | summary: UsageSummary | null, | |
| 20 | providers: readonly ServerProvider[] | null, | |
| 21 | ): boolean { | |
| 22 | return ( | |
| 23 | summary?.sources.some((source) => source.action === "enableCursorKeychain") === true && | |
| 24 | providers?.some((provider) => provider.driver === "cursor" && provider.status === "ready") === | |
| 25 | true | |
| 26 | ); | |
| 27 | } | |
| 28 | ||
| 12 | 29 | const limitsRefreshAfter = new Map<EnvironmentId, number>(); |
| 13 | 30 | const limitsRefreshes = new Map<EnvironmentId, Promise<unknown>>(); |
| 14 | 31 |