From 4b5f378b0b65243fcc99e131a5821917dac43a06 Mon Sep 17 00:00:00 2001
From: yappologistic
Date: Fri, 27 Mar 2026 00:23:33 -0600
Subject: [PATCH 1/6] Add unified usage dashboard
---
.docs/provider-settings.md | 10 +
README.md | 3 +-
.../Layers/ProviderRuntimeIngestion.test.ts | 7 +-
.../Layers/ProviderRuntimeIngestion.ts | 5 +
apps/web/src/components/ChatView.browser.tsx | 83 +++
apps/web/src/components/ChatView.tsx | 65 +-
.../web/src/components/PiProvider.browser.tsx | 2 +
.../components/chat/ProviderModelPicker.tsx | 41 +-
.../components/chat/UsageDashboardDialog.tsx | 573 ++++++++++++++++++
apps/web/src/lib/contextWindow.ts | 38 +-
apps/web/src/lib/usageDashboard.test.ts | 123 ++++
apps/web/src/lib/usageDashboard.ts | 257 ++++++++
12 files changed, 1143 insertions(+), 64 deletions(-)
create mode 100644 apps/web/src/components/chat/UsageDashboardDialog.tsx
create mode 100644 apps/web/src/lib/usageDashboard.test.ts
create mode 100644 apps/web/src/lib/usageDashboard.ts
diff --git a/.docs/provider-settings.md b/.docs/provider-settings.md
index 5b908871a34..26fb89b1665 100644
--- a/.docs/provider-settings.md
+++ b/.docs/provider-settings.md
@@ -161,6 +161,16 @@ Codex also has a per-turn `Fast Mode` toggle in the composer controls. This is s
CUT3 hides the "token context left" UI for OpenRouter-routed models because the routed model can change and the remaining-context display is not reliable enough there.
+### Usage dashboard
+
+The composer context ring is now a full `Usage dashboard` trigger instead of only a passive status badge.
+
+- Click the context ring to open a dialog with the current selection's documented/live context window, token breakdown, latest matching runtime snapshot metadata, and latest reported spend when the provider exposes it.
+- The model picker footer also includes a `Usage` shortcut so you can open the same dashboard while browsing providers/models.
+- If the latest stored runtime snapshot belongs to a different provider/model than the current selection, CUT3 calls that out explicitly instead of silently showing stale numbers.
+- GitHub Copilot selections also include current premium-request quota information in the dashboard.
+- Cost reporting depends on the provider runtime. CUT3 shows the latest reported USD amount when the provider supplies it, otherwise the spend card stays unavailable instead of guessing.
+
### Image attachments
The composer also supports lightweight image input:
diff --git a/README.md b/README.md
index aea1646b06a..15041d918c7 100644
--- a/README.md
+++ b/README.md
@@ -110,11 +110,12 @@ Open Settings in the app to configure provider-specific behavior on the current
- **Provider overrides**: set custom binary paths for Codex, Copilot, OpenCode, or Kimi, plus an optional Codex home path, a shared OpenRouter API key, and a Kimi API key. Pi is embedded through CUT3's Node dependency instead of a separate binary override; CUT3 reads Pi auth/models config from `~/.pi/agent`, keeps Pi packages, AGENTS files, system prompts, extensions, skills, prompt templates, and themes disabled so workspace instructions still come only from CUT3, and now surfaces authenticated Pi provider/model ids directly in the picker and `/model` suggestions instead of only showing a static `pi/default` placeholder. OpenCode account authentication still happens outside CUT3 through `opencode auth login` and `opencode auth logout`, while MCP server auth/debug remains server-specific through commands like `opencode mcp auth ` and `opencode mcp debug `. The OpenCode settings panel inspects the resolved OpenCode config paths plus `opencode auth list`, `opencode mcp list`, and `opencode mcp auth list` so CUT3 can show current credentials, provider-specific MCP status (including disabled and auth-gated entries), and copyable recovery commands. Kimi CLI authentication can use either `kimi login` or the in-shell `/login` flow when you are not using an API key, and new OpenCode sessions now inherit that shared OpenRouter key as `OPENROUTER_API_KEY` when the OpenCode provider config expects it.
- **OpenRouter free models**: review the current OpenRouter entries that are explicitly free-locked and compatible with CUT3's native tool-calling path (`tools` plus `tool_choice`), keep the built-in `openrouter/free` router handy, and pin any listed model into the picker.
- **Custom model slugs**: save extra model ids for GitHub Copilot, OpenCode, Kimi, Pi provider/model ids such as `github-copilot/claude-sonnet-4.5`, custom Codex models, or current OpenRouter `:free` slugs so they appear in the model picker and `/model` suggestions.
-- **Picker controls**: the chat composer now uses a searchable grouped model picker with direct `Connect provider` and `Manage models` actions.
+- **Picker controls**: the chat composer now uses a searchable grouped model picker with direct `Usage`, `Connect provider`, and `Manage models` actions.
- **Model visibility**: hide or restore discovered and saved models without deleting them; hidden models are removed from both the picker and `/model` suggestions until you show them again.
- **Thread defaults**: choose whether new draft threads start in `Local` or `New worktree`, and set thread sharing to `Manual`, `Auto` (create a share link after a new server-backed thread settles), or `Disabled` for new links.
- **Codex service tier**: choose `Automatic`, `Fast`, or `Flex` as the default service tier for new Codex turns.
- **Per-turn controls**: the composer exposes provider-aware reasoning controls where CUT3 has a provider-specific contract today. Codex and GitHub Copilot expose provider-specific reasoning levels, Codex also supports a per-turn `Fast Mode` toggle, and Pi now surfaces its live model reasoning capability plus Pi thinking levels (`off`, `minimal`, `low`, `medium`, `high`, `xhigh`) for reasoning-capable Pi models while still preserving Pi defaults until you choose an override.
+- **Usage dashboard**: click the composer context ring or the picker `Usage` action to open a unified usage dashboard for the current selection, including documented/live context limits, token breakdowns from the latest matching runtime snapshot, latest reported spend when the provider exposes it, and GitHub Copilot quota details.
- **Response visibility**: choose whether assistant messages stream token-by-token and whether tool/work-log entries stay visible in the main timeline.
- **Permission policies**: save persistent app-wide or project-scoped approval rules with `allow`, `ask`, or `deny` actions, request-kind filters, request-type/detail matching, and Build/Plan/Review presets.
diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts
index 34148c42557..a4c536af2cd 100644
--- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts
+++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts
@@ -274,17 +274,19 @@ describe("ProviderRuntimeIngestion", () => {
modelUsage: {
inputTokens: 321,
},
+ totalCostUsd: 0.42,
},
});
const thread = await waitForThread(harness.engine, (entry) => {
const tokenUsage = entry.session?.tokenUsage as
- | { kind?: string; usage?: { inputTokens?: number } }
+ | { kind?: string; usage?: { inputTokens?: number }; totalCostUsd?: number }
| undefined;
return (
entry.session?.status === "ready" &&
tokenUsage?.kind === "turn" &&
- tokenUsage.usage?.inputTokens === 321
+ tokenUsage.usage?.inputTokens === 321 &&
+ tokenUsage.totalCostUsd === 0.42
);
});
@@ -299,6 +301,7 @@ describe("ProviderRuntimeIngestion", () => {
modelUsage: {
inputTokens: 321,
},
+ totalCostUsd: 0.42,
});
});
diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts
index a103b64e11b..03555690c03 100644
--- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts
+++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts
@@ -137,6 +137,7 @@ function createSessionTokenUsageSnapshot(input: {
model: string;
usage?: unknown;
modelUsage?: Readonly>;
+ totalCostUsd?: number;
}) {
return {
provider: input.event.provider,
@@ -145,6 +146,7 @@ function createSessionTokenUsageSnapshot(input: {
model: input.model,
...(input.usage !== undefined ? { usage: normalizeRuntimeTokenUsage(input.usage) } : {}),
...(input.modelUsage !== undefined ? { modelUsage: input.modelUsage } : {}),
+ ...(typeof input.totalCostUsd === "number" ? { totalCostUsd: input.totalCostUsd } : {}),
};
}
@@ -940,6 +942,9 @@ const make = Effect.gen(function* () {
>,
}
: {}),
+ ...(typeof turnCompletedPayload.totalCostUsd === "number"
+ ? { totalCostUsd: turnCompletedPayload.totalCostUsd }
+ : {}),
})
: undefined;
const nextActiveTurnId =
diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx
index 20486011eec..565ef16ced9 100644
--- a/apps/web/src/components/ChatView.browser.tsx
+++ b/apps/web/src/components/ChatView.browser.tsx
@@ -570,6 +570,23 @@ function resolveWsRpc(body: WsRequestEnvelope["body"]): unknown {
if (tag === WS_METHODS.serverGetOpenCodeState) {
return fixture.openCodeState ?? createUnavailableOpenCodeState();
}
+ if (tag === WS_METHODS.serverGetCopilotUsage) {
+ return {
+ status: "available",
+ source: "copilot_internal_user",
+ fetchedAt: NOW_ISO,
+ login: "octocat",
+ plan: "individual",
+ entitlement: 500,
+ remaining: 320,
+ used: 180,
+ percentRemaining: 64,
+ overagePermitted: true,
+ overageCount: 0,
+ unlimited: false,
+ resetAt: isoAt(86_400),
+ };
+ }
if (tag === WS_METHODS.gitListBranches) {
return {
isRepo: true,
@@ -1629,6 +1646,72 @@ describe("ChatView timeline estimator parity (full app)", () => {
}
});
+ it("opens the usage dashboard with spend and Copilot quota details", async () => {
+ const snapshot = withActiveThreadProvider(
+ createSnapshotForTargetUser({
+ targetMessageId: "msg-user-usage-dashboard" as MessageId,
+ targetText: "usage dashboard target",
+ }),
+ "copilot",
+ );
+ const snapshotWithUsage = {
+ ...snapshot,
+ threads: snapshot.threads.map((thread) => {
+ if (thread.id !== THREAD_ID || !thread.session) {
+ return thread;
+ }
+ return Object.assign({}, thread, {
+ model: "claude-sonnet-4.5",
+ session: {
+ ...thread.session,
+ providerName: "copilot",
+ tokenUsage: {
+ provider: "copilot",
+ kind: "turn",
+ observedAt: NOW_ISO,
+ model: "claude-sonnet-4.5",
+ totalCostUsd: 0.42,
+ usage: {
+ inputTokens: 1_200,
+ outputTokens: 340,
+ thoughtTokens: 56,
+ },
+ },
+ },
+ });
+ }),
+ } satisfies OrchestrationReadModel;
+
+ const mounted = await mountChatView({
+ viewport: DEFAULT_VIEWPORT,
+ snapshot: snapshotWithUsage,
+ configureFixture: (nextFixture) => {
+ nextFixture.serverConfig = {
+ ...nextFixture.serverConfig,
+ providers: [createReadyProviderStatus("codex"), createReadyProviderStatus("copilot")],
+ };
+ },
+ });
+
+ try {
+ const contextStatus = await waitForComposerControl("context-status");
+ contextStatus.click();
+
+ const dashboard = await waitForElement(
+ () => document.querySelector("[data-usage-dashboard='true']"),
+ "Unable to find the usage dashboard dialog.",
+ );
+
+ expect(normalizeTextContent(document.body.textContent)).toContain("Usage dashboard");
+ expect(normalizeTextContent(dashboard.textContent)).toContain("$0.4200");
+ expect(normalizeTextContent(dashboard.textContent)).toContain("320 / 500");
+ expect(normalizeTextContent(dashboard.textContent)).toContain("GitHub Copilot billing");
+ expect(normalizeTextContent(dashboard.textContent)).toContain("1,596 tokens");
+ } finally {
+ await mounted.cleanup();
+ }
+ });
+
it("shows a pointer cursor for the running stop button", async () => {
const mounted = await mountChatView({
viewport: DEFAULT_VIEWPORT,
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
index 863d35d80b2..8f6c82f1b19 100644
--- a/apps/web/src/components/ChatView.tsx
+++ b/apps/web/src/components/ChatView.tsx
@@ -82,7 +82,11 @@ import {
serverOpenCodeStateQueryOptions,
serverQueryKeys,
} from "~/lib/serverReactQuery";
-import { describeContextWindowState, shouldHideContextWindowForModel } from "~/lib/contextWindow";
+import {
+ describeContextWindowState,
+ getDocumentedContextWindowOverride,
+ shouldHideContextWindowForModel,
+} from "~/lib/contextWindow";
import {
isCut3CompatibleOpenRouterModelOption,
isOpenRouterGuaranteedFreeSlug,
@@ -258,6 +262,7 @@ import { ThreadTasksPanel } from "./chat/ThreadTasksPanel";
import { ThreadExportDialog } from "./chat/ThreadExportDialog";
import { ThreadShareDialog } from "./chat/ThreadShareDialog";
import { ComposerSkillPicker } from "./chat/ComposerSkillPicker";
+import { UsageDashboardDialog } from "./chat/UsageDashboardDialog";
import {
commandForProjectScript,
nextProjectScriptId,
@@ -981,6 +986,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
useState(null);
const [isProviderSetupDialogOpen, setIsProviderSetupDialogOpen] = useState(false);
const [isManageModelsDialogOpen, setIsManageModelsDialogOpen] = useState(false);
+ const [isUsageDashboardOpen, setIsUsageDashboardOpen] = useState(false);
const [isRefreshingProviderSetupState, setIsRefreshingProviderSetupState] = useState(false);
const [isOpenRouterApiKeyDialogOpen, setIsOpenRouterApiKeyDialogOpen] = useState(false);
const [openRouterApiKeyDraft, setOpenRouterApiKeyDraft] = useState("");
@@ -6872,6 +6878,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
hasHiddenModels={hasHiddenPickerModels}
onOpenProviderSetup={openProviderSetupDialog}
onOpenManageModels={openManageModelsDialog}
+ onOpenUsageDashboard={() => setIsUsageDashboardOpen(true)}
onProviderModelChange={onProviderModelSelectFromPicker}
/>
@@ -6886,10 +6893,12 @@ export default function ChatView({ threadId }: ChatViewProps) {
setIsUsageDashboardOpen(true)}
/>
{isComposerFooterCompact ? (
@@ -7491,6 +7500,16 @@ export default function ChatView({ threadId }: ChatViewProps) {
)}
+
+
;
-}) {
- if (input.provider !== "opencode") {
- return {};
- }
-
- const normalizedModel = normalizeModelSlug(input.model, "opencode");
- if (!normalizedModel) {
- return {};
- }
-
- const documentedTotalTokens = input.opencodeContextLengthsBySlug?.get(normalizedModel) ?? null;
- if (documentedTotalTokens === null) {
- return {};
- }
-
- return {
- documentedTotalTokens,
- documentedNote: OPENCODE_MODELS_DEV_CONTEXT_NOTE,
- };
-}
-
function formatContextUsagePercent(percentUsed: number): string {
if (!Number.isFinite(percentUsed) || percentUsed <= 0) {
return "0%";
@@ -8652,11 +8643,13 @@ function getContextIndicatorLabel(input: {
}
const ComposerContextWindowStatus = memo(function ComposerContextWindowStatus(props: {
+ language: AppLanguage;
provider: ProviderKind;
model: string | null | undefined;
tokenUsage?: unknown;
opencodeContextLengthsBySlug?: ReadonlyMap;
compact?: boolean;
+ onOpenUsageDashboard: () => void;
}) {
if (shouldHideContextWindowForModel(props.provider, props.model)) {
return null;
@@ -8716,13 +8709,16 @@ const ComposerContextWindowStatus = memo(function ComposerContextWindowStatus(pr
diff --git a/apps/web/src/components/PiProvider.browser.tsx b/apps/web/src/components/PiProvider.browser.tsx
index 32c2b4e3c3c..408604a1850 100644
--- a/apps/web/src/components/PiProvider.browser.tsx
+++ b/apps/web/src/components/PiProvider.browser.tsx
@@ -142,6 +142,7 @@ describe("Pi provider GUI", () => {
hasHiddenModels={false}
onOpenProviderSetup={() => undefined}
onOpenManageModels={() => undefined}
+ onOpenUsageDashboard={() => undefined}
onProviderModelChange={onProviderModelChange}
/>
,
@@ -235,6 +236,7 @@ describe("Pi provider GUI", () => {
hasHiddenModels={false}
onOpenProviderSetup={() => undefined}
onOpenManageModels={() => undefined}
+ onOpenUsageDashboard={() => undefined}
onProviderModelChange={onProviderModelChange}
/>
,
diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx
index 09430282536..c628969f7f4 100644
--- a/apps/web/src/components/chat/ProviderModelPicker.tsx
+++ b/apps/web/src/components/chat/ProviderModelPicker.tsx
@@ -1,5 +1,5 @@
import { type ModelSlug, type ProviderKind, type ServerCopilotUsage } from "@t3tools/contracts";
-import { getModelDisplayName, normalizeModelSlug } from "@t3tools/shared/model";
+import { getModelDisplayName } from "@t3tools/shared/model";
import { formatGitHubCopilotPlan } from "@t3tools/shared/copilotPlan";
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
@@ -23,6 +23,7 @@ import {
import { getModelPickerOptionDisplayParts } from "../../lib/modelPickerOptionDisplay";
import {
describeContextWindowState,
+ getDocumentedContextWindowOverride,
shouldHideContextWindowForModel,
} from "../../lib/contextWindow";
import { serverCopilotUsageQueryOptions } from "../../lib/serverReactQuery";
@@ -30,6 +31,7 @@ import { type AppServiceTier, shouldShowFastTierIcon } from "../../appSettings";
import { getAppLanguageDetails, type AppLanguage } from "../../appLanguage";
import {
+ BarChart3Icon,
BotIcon,
ChevronDownIcon,
CheckIcon,
@@ -115,31 +117,6 @@ function formatCopilotQuotaDate(iso: string, language: AppLanguage): string {
// Context window summary
// ---------------------------------------------------------------------------
-const OPENCODE_MODELS_DEV_CONTEXT_NOTE =
- "OpenCode uses provider/model metadata from models.dev and reads limit.context when the selected model publishes it.";
-
-function getDocumentedContextWindowOverride(input: {
- provider: ProviderKind;
- model: string | null | undefined;
- opencodeContextLengthsBySlug?: ReadonlyMap;
-}) {
- if (input.provider !== "opencode") {
- return {};
- }
- const normalizedModel = normalizeModelSlug(input.model, "opencode");
- if (!normalizedModel) {
- return {};
- }
- const documentedTotalTokens = input.opencodeContextLengthsBySlug?.get(normalizedModel) ?? null;
- if (documentedTotalTokens === null) {
- return {};
- }
- return {
- documentedTotalTokens,
- documentedNote: OPENCODE_MODELS_DEV_CONTEXT_NOTE,
- };
-}
-
function renderProviderContextWindowSummary(input: {
provider: ProviderKind;
model: string | null | undefined;
@@ -449,6 +426,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
disabled?: boolean;
onOpenProviderSetup: () => void;
onOpenManageModels: () => void;
+ onOpenUsageDashboard: () => void;
onProviderModelChange: (provider: AvailableProviderPickerKind, model: ModelSlug) => void;
}) {
const [isOpen, setIsOpen] = useState(false);
@@ -836,6 +814,17 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
{props.hasHiddenModels ? copy.hiddenModelsHint : copy.pickModelHint}
+
)}
-
-
-
Select a thread or create a new one to get started.
-
+
+
);
@@ -6876,6 +7052,20 @@ export default function ChatView({ threadId }: ChatViewProps) {
opencodeContextLengthsBySlug={openCodeContextLengthsBySlug}
serviceTierSetting={selectedServiceTierSetting}
hasHiddenModels={hasHiddenPickerModels}
+ favoriteModelsByProvider={{
+ codex: providerFavoriteModelSettings.favoriteCodexModels,
+ copilot: providerFavoriteModelSettings.favoriteCopilotModels,
+ opencode: providerFavoriteModelSettings.favoriteOpencodeModels,
+ kimi: providerFavoriteModelSettings.favoriteKimiModels,
+ pi: providerFavoriteModelSettings.favoritePiModels,
+ }}
+ recentModelsByProvider={{
+ codex: providerRecentModelSettings.recentCodexModels,
+ copilot: providerRecentModelSettings.recentCopilotModels,
+ opencode: providerRecentModelSettings.recentOpencodeModels,
+ kimi: providerRecentModelSettings.recentKimiModels,
+ pi: providerRecentModelSettings.recentPiModels,
+ }}
onOpenProviderSetup={openProviderSetupDialog}
onOpenManageModels={openManageModelsDialog}
onOpenUsageDashboard={() => setIsUsageDashboardOpen(true)}
@@ -7518,26 +7708,36 @@ export default function ChatView({ threadId }: ChatViewProps) {
openCodeState={openCodeStateQuery.data ?? null}
hasOpenRouterApiKey={settings.openRouterApiKey.trim().length > 0}
hasKimiApiKey={settings.kimiApiKey.trim().length > 0}
+ codexBinaryPath={settings.codexBinaryPath}
+ copilotBinaryPath={settings.copilotBinaryPath}
+ opencodeBinaryPath={settings.opencodeBinaryPath}
+ kimiBinaryPath={settings.kimiBinaryPath}
isRefreshing={isRefreshingProviderSetupState}
onRefresh={refreshProviderSetupState}
onOpenOpenRouterKeyDialog={openOpenRouterApiKeyDialogFromProviderSetup}
onOpenKimiKeyDialog={openKimiApiKeyDialogFromProviderSetup}
onOpenManageModels={openManageModelsFromProviderSetup}
+ onOpenSettings={() => {
+ setIsProviderSetupDialogOpen(false);
+ void navigate({ to: "/settings" });
+ }}
/>
void;
onOpenOpenRouterKeyDialog: () => void;
onOpenKimiKeyDialog: () => void;
onOpenManageModels: () => void;
+ onOpenSettings: () => void;
}) {
const chatCopy = getChatSurfaceCopy(props.language);
+ const copy =
+ props.language === "fa"
+ ? {
+ title: "آماده سازی ارائه دهنده",
+ description:
+ "وضعیت runtime های محلی را بررسی کنید، کلیدها را اضافه کنید، و قدم بعدی هر ارائه دهنده را بدون خروج از چت ببینید.",
+ snapshotTitle: "نمای آماده سازی ارائه دهنده",
+ snapshotDescription:
+ "CUT3 وضعیت runtime های محلی را می خواند. احراز هویت OpenCode، Codex، Copilot، Kimi، و Pi همچنان در ابزارهای خود آنها مدیریت می شود.",
+ ready: "آماده",
+ attention: "نیاز به توجه",
+ unavailable: "ناموجود",
+ refresh: "نوسازی وضعیت",
+ addKey: "افزودن کلید",
+ updateKey: "به روز رسانی کلید",
+ copied: "کپی شد",
+ copyLogin: "کپی ورود",
+ copyLaunch: "کپی اجرای CLI",
+ manageModels: "مدیریت مدل ها",
+ settings: "تنظیمات",
+ done: "انجام شد",
+ notAvailableYet: "هنوز در دسترس نیست",
+ credentials: (count: number) => `${count} اعتبار`,
+ models: (count: number) => `${count} مدل`,
+ mcpServers: (count: number) => `${count} سرور MCP`,
+ }
+ : {
+ title: "Provider readiness",
+ description:
+ "Check local runtime health, add keys, and see the next step for each provider without leaving chat.",
+ snapshotTitle: "Provider readiness snapshot",
+ snapshotDescription:
+ "CUT3 inspects your local runtimes here. Authentication for OpenCode, Codex, Copilot, Kimi, and Pi still lives in their own CLIs and config files.",
+ ready: "Ready",
+ attention: "Needs attention",
+ unavailable: "Unavailable",
+ refresh: "Refresh status",
+ addKey: "Add key",
+ updateKey: "Update key",
+ copied: "Copied",
+ copyLogin: "Copy login",
+ copyLaunch: "Copy CLI launch",
+ manageModels: "Manage models",
+ settings: "Settings",
+ done: "Done",
+ notAvailableYet: "Not available yet",
+ credentials: (count: number) => `${count} credential${count === 1 ? "" : "s"}`,
+ models: (count: number) => `${count} model${count === 1 ? "" : "s"}`,
+ mcpServers: (count: number) => `${count} MCP server${count === 1 ? "" : "s"}`,
+ };
const openCodeCredentialCount = props.openCodeState?.credentials.length ?? 0;
const openCodeModelCount = props.openCodeState?.models.length ?? 0;
+ const openCodeMcpServerCount = props.openCodeState?.mcpServers.length ?? 0;
+ const [lastCopiedCommandId, setLastCopiedCommandId] = useState(null);
+ const { copyToClipboard, isCopied } = useCopyToClipboard<{ id: string }>({
+ onCopy: ({ id }) => setLastCopiedCommandId(id),
+ });
+
+ const formatCommandBinary = useCallback((binaryPath: string, fallback: string) => {
+ const trimmed = binaryPath.trim();
+ if (!trimmed) {
+ return fallback;
+ }
+ return `'${trimmed.replaceAll("'", "'\\''")}'`;
+ }, []);
+
+ const codexCommand = formatCommandBinary(props.codexBinaryPath, "codex");
+ const copilotCommand = formatCommandBinary(props.copilotBinaryPath, "copilot");
+ const opencodeCommand = formatCommandBinary(props.opencodeBinaryPath, "opencode");
+ const kimiCommand = formatCommandBinary(props.kimiBinaryPath, "kimi");
+
+ const renderCopyCommandButton = useCallback(
+ (input: { id: string; label: string; command: string }) => {
+ const copied = isCopied && lastCopiedCommandId === input.id;
+ return (
+ copyToClipboard(input.command, { id: input.id })}
+ >
+ {copied ? copy.copied : input.label}
+
+ );
+ },
+ [copy.copied, copyToClipboard, isCopied, lastCopiedCommandId],
+ );
+
+ const readinessSummary = useMemo(() => {
+ return [...AVAILABLE_PROVIDER_OPTIONS].reduce(
+ (counts, option) => {
+ if (option.value === "openrouter") {
+ if (props.hasOpenRouterApiKey) {
+ counts.ready += 1;
+ } else {
+ counts.attention += 1;
+ }
+ return counts;
+ }
+
+ const backingProvider = getProviderPickerBackingProvider(option.value);
+ if (!backingProvider) {
+ return counts;
+ }
+ const status = findProviderStatus(props.providerStatuses, backingProvider);
+ if (!status || !status.available || status.status === "error") {
+ counts.unavailable += 1;
+ return counts;
+ }
+ if (status.status === "ready" && status.authStatus !== "unauthenticated") {
+ counts.ready += 1;
+ return counts;
+ }
+ counts.attention += 1;
+ return counts;
+ },
+ { ready: 0, attention: 0, unavailable: 0 },
+ );
+ }, [props.hasOpenRouterApiKey, props.providerStatuses]);
return (