Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/desktop/electron/main/builtin-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/electron/main/ipc/session-ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -606,6 +606,7 @@ export function registerSessionIpc({
modelId?: string;
thinkingLevel?: SessionThinkingLevel;
permissionMode?: "inherit" | "ask" | "accept-edits" | "auto";
fastMode?: boolean;
},
) => {
rejectNativeMutation(id, "configuration");
Expand Down
9 changes: 7 additions & 2 deletions apps/desktop/electron/main/remote/remote-backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SessionSummary, "mode" | "providerId" | "modelId" | "thinkingLevel" | "permissionMode">
Pick<SessionSummary, "mode" | "providerId" | "modelId" | "thinkingLevel" | "permissionMode" | "fastMode">
>;
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", {
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/electron/main/runtime/session-launch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/components/Composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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}
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/components/icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ import {
Workflow,
Wrench,
X,
Zap,
type LucideIcon,
type LucideProps,
} from "lucide-react";
Expand Down Expand Up @@ -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);
Expand Down
31 changes: 31 additions & 0 deletions apps/desktop/src/components/settings/ModelSelectionPanes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
bindingForCustomModel,
bindingForCustomModelInfo,
bindingFromModelInfo,
fastModeBillingOn,
formatTokenCount,
modelMatchesFilter,
nativeWebSearchSupportedOn,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1032,6 +1041,28 @@ export function ModelSelectionPanes({
<IconHelp size={13} />
</Tooltip>
</span>
<span className="provider-chosen-delegation">
<label className="provider-chosen-capability">
<input
type="checkbox"
checked={binding.fastModeAllowed === true}
disabled={!fastModeBilling}
onChange={(event) =>
updateBinding(binding.id, {
fastModeAllowed: event.target.checked || undefined,
})
}
/>
<span>{t("settings.fastModeAllowed")}</span>
</label>
<Tooltip
className="provider-chosen-delegation-help"
label={t(fastModeHint)}
ariaLabel={t(fastModeHint)}
>
<IconHelp size={13} />
</Tooltip>
</span>
</div>
</div>
</div>
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/components/settings/provider-copy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } : {}),
})),
};
}
15 changes: 12 additions & 3 deletions apps/desktop/src/features/chat/composer/ComposerModelPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
Expand All @@ -29,6 +31,7 @@ export function ComposerModelPicker({
modelLabel,
thinkingLabel,
thinkingLevel,
fastMode = false,
selectedProviderId,
selectedModelId,
controlsBlocked,
Expand All @@ -54,6 +57,7 @@ export function ComposerModelPicker({
commitThinkingLevel,
onMenuKeyDown,
} = controller;
const fastSuffix = fastMode ? ` · ${t("chat.fastModeBadge")}` : "";

return (
<AnchoredMenu
Expand All @@ -72,8 +76,8 @@ export function ComposerModelPicker({
ref={ref}
type="button"
className={`icon-btn composer-model-thinking-chip ${open ? "active" : ""}`}
tooltip={`${modelLabel} · ${t("chat.reasoningLevel")}: ${thinkingLabel}`}
ariaLabel={`${t("chat.model")}: ${modelLabel}. ${t("chat.reasoningLevel")}: ${thinkingLabel}`}
tooltip={`${modelLabel} · ${t("chat.reasoningLevel")}: ${thinkingLabel}${fastSuffix}`}
ariaLabel={`${t("chat.model")}: ${modelLabel}. ${t("chat.reasoningLevel")}: ${thinkingLabel}${fastSuffix}`}
aria-haspopup="menu"
aria-expanded={open}
disabled={controlsBlocked}
Expand All @@ -94,6 +98,11 @@ export function ComposerModelPicker({
{thinkingLevel !== "off" ? (
<span className="composer-model-thinking-level">{thinkingLabel}</span>
) : null}
{fastMode ? (
<span className="composer-model-thinking-fast" aria-hidden="true">
<IconZap size={12} />
</span>
) : null}
<IconChevronDown size={12} aria-hidden="true" className="composer-model-thinking-chevron" />
</TooltipButton>
)}
Expand Down
4 changes: 4 additions & 0 deletions apps/desktop/src/features/chat/composer/ComposerToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ export type ComposerToolbarProps = {
modelMenu: ModelMenuController;
modelLabel: string;
thinkingLabel: string;
/** The selected model asks for OpenAI's priority tier (ADR 0334). */
modelFastMode?: boolean;
contextUsage: ContextUsage | null;
value: string;
modelReady: boolean;
Expand Down Expand Up @@ -92,6 +94,7 @@ export function ComposerToolbar({
modelMenu,
modelLabel,
thinkingLabel,
modelFastMode = false,
contextUsage,
value,
modelReady,
Expand Down Expand Up @@ -189,6 +192,7 @@ export function ComposerToolbar({
controller={modelMenu}
modelLabel={modelLabel}
thinkingLabel={thinkingLabel}
fastMode={modelFastMode}
thinkingLevel={thinkingLevel}
selectedProviderId={providerId}
selectedModelId={modelId}
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -783,7 +783,7 @@ export const api = {
configureSession: (
id: string,
config: Pick<SessionSummary, "mode" | "providerId" | "modelId"> &
Partial<Pick<SessionSummary, "thinkingLevel" | "permissionMode">>,
Partial<Pick<SessionSummary, "thinkingLevel" | "permissionMode" | "fastMode">>,
) =>
invoke<{ session: SessionSummary }>(
IPC.invoke.sessionConfigure,
Expand Down
4 changes: 4 additions & 0 deletions apps/desktop/src/lib/commands.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import i18n from "i18next";
import { api } from "./api";
import { useAppStore } from "../stores/app-store";
import { toggleSessionFastMode } from "./fast-mode-command";
import { trustedExtensionCommandName, type Mode } from "@pi-desktop/shared";

/**
Expand All @@ -17,6 +18,9 @@ export async function runPaletteCommand(commandId: string): Promise<void> {
case "builtin.agent.compact":
await store.compactContext();
break;
case "builtin.session.fast":
await toggleSessionFastMode();
break;
case "builtin.mode.agent":
case "builtin.mode.plan":
case "builtin.mode.goal": {
Expand Down
78 changes: 78 additions & 0 deletions apps/desktop/src/lib/fast-mode-command.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import i18n from "i18next";
import { fastModeBillingOn, type ProviderPublic } from "@pi-desktop/shared";
import { useAppStore } from "../stores/app-store";
import { sameComposerModelId } from "./composer-models";
import { isRemoteSession } from "./remote-session-safety";
import { inheritedSessionModelBinding } from "./session-model";

/**
* The `/fast` builtin (ADR 0334): a per-session switch for OpenAI's priority
* service tier. Model settings decide which models may use it.
*/

type FastModeProvider = Pick<ProviderPublic, "apiStyle" | "models"> | undefined | null;

function fastModeAllowed(provider: FastModeProvider, modelId: string | undefined): boolean {
return !!modelId && provider?.models.some(
(binding) => sameComposerModelId(binding.id, modelId) && binding.fastModeAllowed === true,
) === true;
}

/** Whether the Composer should mark the selected model as running in Fast mode. */
export function sessionFastModeActive(
provider: FastModeProvider,
modelId: string | undefined,
target: { fastMode?: boolean } | null | undefined,
): boolean {
return target?.fastMode === true && !!fastModeBillingOn(provider?.apiStyle) &&
fastModeAllowed(provider, modelId);
}

export async function toggleSessionFastMode(): Promise<void> {
const store = useAppStore.getState();
const activeSession = store.sessions.find((session) => session.id === store.activeSessionId);
if (store.activeSessionId && !activeSession) return;
if (activeSession && isRemoteSession(activeSession)) {
store.showToast(i18n.t("chat.fastModeRemote"), { variant: "warning" });
return;
}
const target = activeSession ?? store.draftConfiguration;
const enabled = target?.fastMode !== true;
const selected = inheritedSessionModelBinding({
draft: target,
settings: store.settings,
providers: store.providers,
recentModels: store.recentModels,
});
const provider = store.providers.find((candidate) => candidate.id === selected.providerId);
const billing = fastModeBillingOn(provider?.apiStyle);
// Without a configured model the switch does nothing, so `/fast` only
// points to the setting; a stale stored switch stays inert at launch.
if (!billing) {
store.showToast(i18n.t("chat.fastModeUnsupported"), { variant: "warning" });
return;
}
if (!fastModeAllowed(provider, selected.modelId)) {
store.showToast(i18n.t("chat.fastModeNotAllowed"), { variant: "warning" });
return;
}

if (activeSession) {
await store.configureActiveSession({
mode: activeSession.mode,
providerId: activeSession.providerId,
modelId: activeSession.modelId,
thinkingLevel: activeSession.thinkingLevel,
fastMode: enabled,
});
// A pending plan review or a read-only session leaves the switch as is.
const after = useAppStore.getState().sessions.find((session) => session.id === activeSession.id);
if ((after?.fastMode === true) !== enabled) return;
} else {
useAppStore.setState((state) => ({
draftConfiguration: { ...state.draftConfiguration, fastMode: enabled },
}));
}
const message = !enabled ? "chat.fastModeOff" : billing === "usage" ? "chat.fastModeOnUsage" : "chat.fastModeOn";
store.showToast(i18n.t(message), { variant: "info" });
}
1 change: 1 addition & 0 deletions apps/desktop/src/lib/session-thinking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export type OptimisticSessionConfiguration = {
thinkingLevel?: SessionThinkingLevel;
mode?: unknown;
permissionMode?: unknown;
fastMode?: boolean;
};

export type ThinkingProviderLike = {
Expand Down
12 changes: 9 additions & 3 deletions apps/desktop/src/stores/app-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,13 +104,18 @@ export type RefreshSessionsOptions = {
revealImportedProjects?: boolean;
};

/** Toolbar selections retained on the unpersisted new-task draft. */
/**
* Toolbar selections retained on the unpersisted new-task draft. An absent
* field keeps the default the new session would otherwise start with.
*/
export type DraftSessionConfiguration = {
mode: Mode;
thinkingLevel: SessionThinkingLevel;
mode?: Mode;
thinkingLevel?: SessionThinkingLevel;
providerId?: string;
modelId?: string;
permissionMode?: PermissionMode;
/** Session `/fast` switch carried into the created session (ADR 0334). */
fastMode?: boolean;
};


Expand Down Expand Up @@ -228,6 +233,7 @@ export type AppState = {
modelId?: string;
thinkingLevel: SessionThinkingLevel;
permissionMode?: PermissionMode;
fastMode?: boolean;
}) => Promise<void>;
/** Returns true once accepted unless concurrent smart Stop restores it. */
sendPrompt: (
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/stores/runtime/session-coordination.ts
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,7 @@ export function createSessionCoordination({
mode: draftConfig?.mode ?? normalizeMode(settings?.defaultMode),
thinkingLevel: draftConfig?.thinkingLevel ?? defaultThinkingLevel,
permissionMode: draftConfig?.permissionMode,
...(draftConfig?.fastMode ? { fastMode: true } : {}),
providerId: inherited.providerId,
modelId: inherited.modelId,
projectPath: projectPath ?? undefined,
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/stores/runtime/session-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export type SessionConfiguration = Pick<
SessionSummary,
"mode" | "providerId" | "modelId" | "thinkingLevel"
> &
Partial<Pick<SessionSummary, "permissionMode">>;
Partial<Pick<SessionSummary, "permissionMode" | "fastMode">>;

export type SessionSelection = { id: string; intent: number };

Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/stores/slices/session-slice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -790,6 +790,7 @@ export function createSessionSlice({
modelId: config.modelId ?? state.draftConfiguration?.modelId,
permissionMode:
config.permissionMode ?? state.draftConfiguration?.permissionMode,
fastMode: config.fastMode ?? state.draftConfiguration?.fastMode,
},
}));
return;
Expand Down
Loading
Loading