diff --git a/apps/desktop/electron/main/builtin-commands.ts b/apps/desktop/electron/main/builtin-commands.ts index 28ca880c80..e84cf36a3a 100644 --- a/apps/desktop/electron/main/builtin-commands.ts +++ b/apps/desktop/electron/main/builtin-commands.ts @@ -15,6 +15,7 @@ export const BUILTIN_COMMANDS: BuiltinCommandDef[] = [ { id: "builtin.mode.agent", title: "Switch to Agent mode", category: "Session", keywords: ["mode", "agent"], source: "builtin", slash: "agent-mode" }, { id: "builtin.mode.plan", title: "Switch to Plan mode", category: "Session", keywords: ["mode", "plan", "planning"], source: "builtin", slash: "plan-mode" }, { id: "builtin.mode.goal", title: "Switch to Goal mode", category: "Session", keywords: ["mode", "goal", "objective", "autonomous"], source: "builtin", slash: "goal-mode" }, + { id: "builtin.session.fast", title: "Toggle Fast mode", category: "Session", keywords: ["fast", "speed", "priority", "service tier"], source: "builtin", slash: "fast" }, ]; /** Palette-shaped items (no slash field leaks into the palette contract). */ diff --git a/apps/desktop/electron/main/ipc/session-ipc.ts b/apps/desktop/electron/main/ipc/session-ipc.ts index 31cc924cd7..aed1b50998 100644 --- a/apps/desktop/electron/main/ipc/session-ipc.ts +++ b/apps/desktop/electron/main/ipc/session-ipc.ts @@ -606,6 +606,7 @@ export function registerSessionIpc({ modelId?: string; thinkingLevel?: SessionThinkingLevel; permissionMode?: "inherit" | "ask" | "accept-edits" | "auto"; + fastMode?: boolean; }, ) => { rejectNativeMutation(id, "configuration"); diff --git a/apps/desktop/electron/main/remote/remote-backend.ts b/apps/desktop/electron/main/remote/remote-backend.ts index 0fc5d91c75..dd17b5029c 100644 --- a/apps/desktop/electron/main/remote/remote-backend.ts +++ b/apps/desktop/electron/main/remote/remote-backend.ts @@ -269,9 +269,14 @@ export function createRemoteBackend(options: RemoteBackendOptions): RemoteBacken case IPC.invoke.sessionConfigure: { const remoteSessionId = remoteIdFor(args); const config = (args[1] ?? {}) as Partial< - Pick + Pick >; - if (config.providerId !== undefined || config.modelId !== undefined || config.thinkingLevel !== undefined) { + if ( + config.providerId !== undefined || + config.modelId !== undefined || + config.thinkingLevel !== undefined || + config.fastMode !== undefined + ) { throw capabilityUnavailable("remote sessions use the Host model configuration"); } const { session } = await client.request<{ session: RacpSession }>("session/configure", { diff --git a/apps/desktop/electron/main/runtime/session-launch.ts b/apps/desktop/electron/main/runtime/session-launch.ts index 3461c2e709..9c0210c270 100644 --- a/apps/desktop/electron/main/runtime/session-launch.ts +++ b/apps/desktop/electron/main/runtime/session-launch.ts @@ -706,6 +706,9 @@ export function createSessionLaunchRuntime({ ...(overrides.turnId ? { turnId: overrides.turnId } : {}), thinkingLevel, infiniteProviderRetry: settings.infiniteProviderRetry === true, + // The session `/fast` switch only reaches a model the user allowed it + // for in model settings (ADR 0334). + fastMode: session.fastMode === true && storedModel?.fastModeAllowed === true, ...(jevApiKey ? { jevApiKey } : {}), commandShell, scratchDir: join(dataDir, "scratch", sessionId), diff --git a/apps/desktop/src/components/Composer.tsx b/apps/desktop/src/components/Composer.tsx index ccb3358170..4c9e3ff85b 100644 --- a/apps/desktop/src/components/Composer.tsx +++ b/apps/desktop/src/components/Composer.tsx @@ -25,6 +25,7 @@ import { isActivePlanExecution } from "../lib/plan-mode-state"; import { headAsk, queuedAskCount } from "../lib/pending-asks"; import type { QueuedPrompt } from "../lib/queued-prompts"; import { composerModelDisplayName, sameComposerModelId } from "../lib/composer-models"; +import { sessionFastModeActive } from "../lib/fast-mode-command"; import { resolveComposerThinkingProvider, } from "../lib/session-thinking"; @@ -631,6 +632,7 @@ export function Composer({ modelMenu={modelMenu} modelLabel={modelLabel} thinkingLabel={thinkingLabel} + modelFastMode={sessionFastModeActive(provider, modelId, activeSession ?? draftConfiguration)} contextUsage={composerContextUsage ?? null} value={value} modelReady={modelReady} diff --git a/apps/desktop/src/components/icons.tsx b/apps/desktop/src/components/icons.tsx index 13f39d92c9..08337551f8 100644 --- a/apps/desktop/src/components/icons.tsx +++ b/apps/desktop/src/components/icons.tsx @@ -101,6 +101,7 @@ import { Workflow, Wrench, X, + Zap, type LucideIcon, type LucideProps, } from "lucide-react"; @@ -192,6 +193,7 @@ export const IconDatabase = icon(Database); export const IconCheck = icon(Check); export const IconBell = icon(Bell); export const IconBot = icon(Bot); +export const IconZap = icon(Zap); export const IconCheckCheck = icon(CheckCheck); export const IconShield = icon(Shield); export const IconChevronDown = icon(ChevronDown); diff --git a/apps/desktop/src/components/settings/ModelSelectionPanes.tsx b/apps/desktop/src/components/settings/ModelSelectionPanes.tsx index 668a838b26..d1b742c718 100644 --- a/apps/desktop/src/components/settings/ModelSelectionPanes.tsx +++ b/apps/desktop/src/components/settings/ModelSelectionPanes.tsx @@ -16,6 +16,7 @@ import { bindingForCustomModel, bindingForCustomModelInfo, bindingFromModelInfo, + fastModeBillingOn, formatTokenCount, modelMatchesFilter, nativeWebSearchSupportedOn, @@ -307,6 +308,14 @@ export function ModelSelectionPanes({ // Use the same published endpoint routing as the runtime. A disabled control // means this connection is not integrated, not that the vendor cannot search. const nativeWebSearchWireCapable = nativeWebSearchSupportedOn(apiStyle, lookupContext?.baseUrl); + // Fast mode rides the Responses `service_tier` field; the hint names what + // the priority tier costs on this wire (ADR 0334). + const fastModeBilling = fastModeBillingOn(apiStyle); + const fastModeHint = fastModeBilling === "subscription" + ? "settings.fastModeAllowedSubscriptionHint" + : fastModeBilling === "usage" + ? "settings.fastModeAllowedUsageHint" + : "settings.fastModeAllowedUnsupported"; /** * The chosen list narrows with the discovered list's rule plus the binding's @@ -1032,6 +1041,28 @@ export function ModelSelectionPanes({ + + + + + + diff --git a/apps/desktop/src/components/settings/provider-copy.ts b/apps/desktop/src/components/settings/provider-copy.ts index 54b06748bd..eb3805547a 100644 --- a/apps/desktop/src/components/settings/provider-copy.ts +++ b/apps/desktop/src/components/settings/provider-copy.ts @@ -47,6 +47,7 @@ export function copyProviderConfiguration(provider: ProviderPublic, name: string ...(model.supportsDocuments !== undefined ? { supportsDocuments: model.supportsDocuments } : {}), ...(model.availableForSubagents !== undefined ? { availableForSubagents: model.availableForSubagents } : {}), ...(model.nativeWebSearch !== undefined ? { nativeWebSearch: model.nativeWebSearch } : {}), + ...(model.fastModeAllowed !== undefined ? { fastModeAllowed: model.fastModeAllowed } : {}), })), }; } diff --git a/apps/desktop/src/features/chat/composer/ComposerModelPicker.tsx b/apps/desktop/src/features/chat/composer/ComposerModelPicker.tsx index 05a89f8158..28e0a69566 100644 --- a/apps/desktop/src/features/chat/composer/ComposerModelPicker.tsx +++ b/apps/desktop/src/features/chat/composer/ComposerModelPicker.tsx @@ -2,7 +2,7 @@ import type { TFunction } from "i18next"; import type { ReactNode } from "react"; import { ComposerModelList } from "./ComposerModelList"; import { AnchoredMenu } from "../../../components/settings/AnchoredMenu"; -import { IconBot, IconChevronDown } from "../../../components/icons"; +import { IconBot, IconChevronDown, IconZap } from "../../../components/icons"; import { TooltipButton } from "../../../components/ui"; import type { useComposerModelMenu } from "./hooks/useComposerModelMenu"; import { ThinkingLevelSlider } from "./ThinkingLevelSlider"; @@ -15,6 +15,8 @@ export type ComposerModelPickerProps = { modelLabel: string; thinkingLabel: string; thinkingLevel: string; + /** The selected model asks for OpenAI's priority tier (ADR 0334). */ + fastMode?: boolean; selectedProviderId?: string; selectedModelId?: string; controlsBlocked: boolean; @@ -29,6 +31,7 @@ export function ComposerModelPicker({ modelLabel, thinkingLabel, thinkingLevel, + fastMode = false, selectedProviderId, selectedModelId, controlsBlocked, @@ -54,6 +57,7 @@ export function ComposerModelPicker({ commitThinkingLevel, onMenuKeyDown, } = controller; + const fastSuffix = fastMode ? ` · ${t("chat.fastModeBadge")}` : ""; return ( {thinkingLabel} ) : null} + {fastMode ? ( + + ) : null}