Switch reasoning, mode, plan, and fast from the Command Center (#2274)
* Switch reasoning, mode, plan, and fast from the Command Center Extend the Command Center (Cmd-K) with selectors for a focused agent's Thinking (reasoning effort), access Mode, Plan mode, and Fast mode, mirroring the model switcher. Typing surfaces tiered "<Setting> › <Value>" breadcrumb rows with icons and a check on the current value, for both a running agent and a new draft tab. Rows only appear while typing, so the default palette is unchanged. - Thinking and Mode are multi-option selects; Plan mode and Fast mode are On/Off toggles. - Plan mode is dual-natured: a permission mode for Claude/Copilot/OpenCode (excluded from the Mode list, given its own toggle) and the plan_mode feature for Codex. Fast mode drives the fast_mode feature (model-gated). - All logic lives in command-center/agent-setting-contributions.ts as pure, unit-tested builders, with a small icon helper and a hook that registers the running-agent selectors; the draft registers from its composer tab. No changes to the command palette component and no protocol changes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Unify Command Center agent controls * refactor: clarify agent control ownership * refactor(app): tighten command center agent controls --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
Mohamed Boudra
parent
1ddca07d57
commit
35f985baff
@@ -0,0 +1,400 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AgentFeature, AgentMode, AgentSelectOption } from "@getpaseo/protocol/agent-types";
|
||||
import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection";
|
||||
import type { CommandCenterContribution } from "./contributions";
|
||||
import {
|
||||
buildAgentControlContributions,
|
||||
type AgentControlContributionSource,
|
||||
} from "./agent-control-contributions";
|
||||
|
||||
function TestIcon() {
|
||||
return null;
|
||||
}
|
||||
|
||||
const LABELS: AgentControlContributionSource["labels"] = {
|
||||
model: "Model",
|
||||
thinking: "Thinking",
|
||||
mode: "Mode",
|
||||
planMode: "Plan mode",
|
||||
fast: "Fast",
|
||||
on: "On",
|
||||
off: "Off",
|
||||
modelKeywords: "model switch",
|
||||
thinkingKeywords: "reasoning effort",
|
||||
modeKeywords: "access permission",
|
||||
planKeywords: "plan planning",
|
||||
fastKeywords: "fast speed",
|
||||
};
|
||||
|
||||
const ICONS: AgentControlContributionSource["icons"] = {
|
||||
provider: () => TestIcon,
|
||||
thinking: TestIcon,
|
||||
planMode: TestIcon,
|
||||
mode: () => TestIcon,
|
||||
feature: () => TestIcon,
|
||||
};
|
||||
|
||||
function provider(input: {
|
||||
id: string;
|
||||
label: string;
|
||||
state?: "models" | "error";
|
||||
}): ProviderSelectorProvider {
|
||||
if (input.state === "error") {
|
||||
return {
|
||||
id: input.id,
|
||||
label: input.label,
|
||||
modelSelection: { kind: "error", message: "Unavailable" },
|
||||
};
|
||||
}
|
||||
return {
|
||||
id: input.id,
|
||||
label: input.label,
|
||||
modelSelection: {
|
||||
kind: "models",
|
||||
rows: [
|
||||
{
|
||||
favoriteKey: `${input.id}:shared`,
|
||||
provider: input.id,
|
||||
providerLabel: input.label,
|
||||
modelId: "shared",
|
||||
modelLabel: `${input.label} model`,
|
||||
description: undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function toggleFeature(id: string, value = false, icon?: string): AgentFeature {
|
||||
return { type: "toggle", id, label: id, value, icon };
|
||||
}
|
||||
|
||||
function makeSource(
|
||||
overrides: {
|
||||
provider?: string | null;
|
||||
models?: Partial<AgentControlContributionSource["models"]>;
|
||||
thinking?: Partial<AgentControlContributionSource["thinking"]>;
|
||||
modes?: Partial<NonNullable<AgentControlContributionSource["modes"]>>;
|
||||
features?: Partial<AgentControlContributionSource["features"]>;
|
||||
} = {},
|
||||
): AgentControlContributionSource {
|
||||
return {
|
||||
serverId: "host",
|
||||
ownerKey: "agent",
|
||||
provider: overrides.provider === undefined ? "claude" : overrides.provider,
|
||||
labels: LABELS,
|
||||
icons: ICONS,
|
||||
models: {
|
||||
providers: [],
|
||||
selectedProvider: null,
|
||||
selectedModelId: null,
|
||||
select: () => undefined,
|
||||
...overrides.models,
|
||||
},
|
||||
thinking: {
|
||||
options: [],
|
||||
selectedId: null,
|
||||
select: () => undefined,
|
||||
...overrides.thinking,
|
||||
},
|
||||
modes: {
|
||||
options: [],
|
||||
selectedId: null,
|
||||
defaultModeId: null,
|
||||
select: () => undefined,
|
||||
...overrides.modes,
|
||||
},
|
||||
features: {
|
||||
list: [],
|
||||
set: () => undefined,
|
||||
...overrides.features,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function selected(contribution: CommandCenterContribution): boolean {
|
||||
return contribution.presentation.kind === "choice" ? contribution.presentation.selected : false;
|
||||
}
|
||||
|
||||
const THINKING_OPTIONS: AgentSelectOption[] = [
|
||||
{ id: "low", label: "Low" },
|
||||
{ id: "high", label: "High" },
|
||||
{ id: "xhigh", label: "Extra High" },
|
||||
];
|
||||
|
||||
const CLAUDE_MODES: AgentMode[] = [
|
||||
{ id: "plan", label: "Plan Mode" },
|
||||
{ id: "default", label: "Always Ask" },
|
||||
{ id: "acceptEdits", label: "Accept File Edits" },
|
||||
];
|
||||
|
||||
describe("Command Center agent-control contributions", () => {
|
||||
it("omits mode-derived choices when no mode control is available", () => {
|
||||
const contributions = buildAgentControlContributions({
|
||||
...makeSource({
|
||||
models: { providers: [provider({ id: "claude", label: "Claude" })] },
|
||||
features: { list: [toggleFeature("plan_mode", true)] },
|
||||
}),
|
||||
modes: undefined,
|
||||
});
|
||||
|
||||
const groups = contributions.map((contribution) => contribution.group);
|
||||
expect(groups).toContain("models");
|
||||
expect(groups).toContain("plan-mode");
|
||||
expect(groups).not.toContain("modes");
|
||||
});
|
||||
|
||||
it("builds unique cross-provider model choices and skips unavailable or empty models", () => {
|
||||
const selections: string[] = [];
|
||||
const contributions = buildAgentControlContributions(
|
||||
makeSource({
|
||||
provider: null,
|
||||
models: {
|
||||
providers: [
|
||||
provider({ id: "claude", label: "Claude" }),
|
||||
provider({ id: "codex", label: "Codex" }),
|
||||
provider({ id: "unavailable", label: "Unavailable", state: "error" }),
|
||||
{
|
||||
id: "empty",
|
||||
label: "Empty",
|
||||
modelSelection: {
|
||||
kind: "models",
|
||||
rows: [
|
||||
{
|
||||
favoriteKey: "empty:",
|
||||
provider: "empty",
|
||||
providerLabel: "Empty",
|
||||
modelId: "",
|
||||
modelLabel: "Default",
|
||||
description: undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
selectedProvider: "codex",
|
||||
selectedModelId: "shared",
|
||||
select: (selectedProvider, modelId) => {
|
||||
selections.push(`${selectedProvider}:${modelId}`);
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(
|
||||
contributions.map((contribution) => ({
|
||||
id: contribution.id,
|
||||
selected: selected(contribution),
|
||||
})),
|
||||
).toEqual([
|
||||
{ id: "models:claude:shared", selected: false },
|
||||
{ id: "models:codex:shared", selected: true },
|
||||
]);
|
||||
expect(contributions[0].presentation).toMatchObject({
|
||||
path: ["Model", "Claude", "Claude model"],
|
||||
testId: "command-center-model-host:claude:shared",
|
||||
});
|
||||
|
||||
contributions[0].run();
|
||||
contributions[1].run();
|
||||
expect(selections).toEqual(["claude:shared"]);
|
||||
});
|
||||
|
||||
it("publishes thinking choices only when selection is meaningful", () => {
|
||||
const selections: string[] = [];
|
||||
const contributions = buildAgentControlContributions(
|
||||
makeSource({
|
||||
thinking: {
|
||||
options: THINKING_OPTIONS,
|
||||
selectedId: "high",
|
||||
select: (id) => {
|
||||
selections.push(id);
|
||||
},
|
||||
},
|
||||
}),
|
||||
).filter((contribution) => contribution.group === "thinking");
|
||||
|
||||
expect(contributions.map((contribution) => contribution.id)).toEqual([
|
||||
"thinking:low",
|
||||
"thinking:high",
|
||||
"thinking:xhigh",
|
||||
]);
|
||||
expect(contributions.map(selected)).toEqual([false, true, false]);
|
||||
for (const contribution of contributions) contribution.run();
|
||||
expect(selections).toEqual(["low", "xhigh"]);
|
||||
|
||||
const singleOption = buildAgentControlContributions(
|
||||
makeSource({
|
||||
thinking: {
|
||||
options: [{ id: "high", label: "High" }],
|
||||
selectedId: "high",
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(singleOption.filter((contribution) => contribution.group === "thinking")).toEqual([]);
|
||||
});
|
||||
|
||||
it("formats access modes, excludes planning modes, and marks the current mode", () => {
|
||||
const selections: string[] = [];
|
||||
const contributions = buildAgentControlContributions(
|
||||
makeSource({
|
||||
modes: {
|
||||
options: CLAUDE_MODES,
|
||||
selectedId: "default",
|
||||
defaultModeId: "default",
|
||||
select: (id) => {
|
||||
selections.push(id);
|
||||
},
|
||||
},
|
||||
}),
|
||||
).filter((contribution) => contribution.group === "modes");
|
||||
|
||||
expect(
|
||||
contributions.map((contribution) => ({
|
||||
id: contribution.id,
|
||||
path:
|
||||
contribution.presentation.kind === "choice" ? contribution.presentation.path : undefined,
|
||||
selected: selected(contribution),
|
||||
})),
|
||||
).toEqual([
|
||||
{ id: "modes:default", path: ["Mode", "Always ask"], selected: true },
|
||||
{ id: "modes:acceptEdits", path: ["Mode", "Accept file edits"], selected: false },
|
||||
]);
|
||||
contributions[1].run();
|
||||
expect(selections).toEqual(["acceptEdits"]);
|
||||
});
|
||||
|
||||
it("toggles mode-based Plan through the plan mode and provider default", () => {
|
||||
const selections: string[] = [];
|
||||
const contributions = buildAgentControlContributions(
|
||||
makeSource({
|
||||
modes: {
|
||||
options: CLAUDE_MODES,
|
||||
selectedId: "plan",
|
||||
defaultModeId: "default",
|
||||
select: (id) => {
|
||||
selections.push(id);
|
||||
},
|
||||
},
|
||||
}),
|
||||
).filter((contribution) => contribution.group === "plan-mode");
|
||||
|
||||
expect(
|
||||
contributions.map((contribution) => ({
|
||||
id: contribution.id,
|
||||
selected: selected(contribution),
|
||||
})),
|
||||
).toEqual([
|
||||
{ id: "plan-mode:on", selected: true },
|
||||
{ id: "plan-mode:off", selected: false },
|
||||
]);
|
||||
contributions[1].run();
|
||||
expect(selections).toEqual(["default"]);
|
||||
});
|
||||
|
||||
it("does not use a planning provider default as the Plan off target", () => {
|
||||
const selections: string[] = [];
|
||||
const contributions = buildAgentControlContributions(
|
||||
makeSource({
|
||||
modes: {
|
||||
options: [
|
||||
{ id: "research", label: "Research", colorTier: "planning" },
|
||||
{ id: "default", label: "Default", colorTier: "safe" },
|
||||
],
|
||||
selectedId: "research",
|
||||
defaultModeId: "research",
|
||||
select: (id) => {
|
||||
selections.push(id);
|
||||
},
|
||||
},
|
||||
}),
|
||||
).filter((contribution) => contribution.group === "plan-mode");
|
||||
|
||||
contributions[1].run();
|
||||
expect(selections).toEqual(["default"]);
|
||||
});
|
||||
|
||||
it("prefers the plan_mode feature and reads its canonical value", () => {
|
||||
const calls: [string, unknown][] = [];
|
||||
const contributions = buildAgentControlContributions(
|
||||
makeSource({
|
||||
modes: {
|
||||
options: CLAUDE_MODES,
|
||||
selectedId: "default",
|
||||
defaultModeId: "default",
|
||||
},
|
||||
features: {
|
||||
list: [toggleFeature("plan_mode", true, "list-todo")],
|
||||
set: (id, value) => {
|
||||
calls.push([id, value]);
|
||||
},
|
||||
},
|
||||
}),
|
||||
).filter((contribution) => contribution.group === "plan-mode");
|
||||
|
||||
expect(contributions.map(selected)).toEqual([true, false]);
|
||||
contributions[1].run();
|
||||
expect(calls).toEqual([["plan_mode", false]]);
|
||||
});
|
||||
|
||||
it("publishes Fast only for an available toggle and ignores unrelated features", () => {
|
||||
const calls: [string, unknown][] = [];
|
||||
const contributions = buildAgentControlContributions(
|
||||
makeSource({
|
||||
features: {
|
||||
list: [toggleFeature("fast_mode", false, "zap"), toggleFeature("auto_accept", true)],
|
||||
set: (id, value) => {
|
||||
calls.push([id, value]);
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
const fast = contributions.filter((contribution) => contribution.group === "fast-mode");
|
||||
|
||||
expect(fast.map(selected)).toEqual([false, true]);
|
||||
expect(contributions.some((contribution) => contribution.id.includes("auto_accept"))).toBe(
|
||||
false,
|
||||
);
|
||||
fast[0].run();
|
||||
expect(calls).toEqual([["fast_mode", true]]);
|
||||
|
||||
const unavailable = buildAgentControlContributions(makeSource());
|
||||
expect(unavailable.filter((contribution) => contribution.group === "fast-mode")).toEqual([]);
|
||||
});
|
||||
|
||||
it("centralizes query-only group ordering between built-in result sections", () => {
|
||||
const contributions = buildAgentControlContributions(
|
||||
makeSource({
|
||||
models: {
|
||||
providers: [provider({ id: "claude", label: "Claude" })],
|
||||
selectedProvider: "claude",
|
||||
selectedModelId: "shared",
|
||||
},
|
||||
thinking: { options: THINKING_OPTIONS, selectedId: "high" },
|
||||
modes: {
|
||||
options: CLAUDE_MODES,
|
||||
selectedId: "default",
|
||||
defaultModeId: "default",
|
||||
},
|
||||
features: { list: [toggleFeature("fast_mode")] },
|
||||
}),
|
||||
);
|
||||
const ranks = new Map<string, number>();
|
||||
for (const contribution of contributions) {
|
||||
ranks.set(contribution.group, contribution.groupRank);
|
||||
expect(contribution.visibility).toBe("query");
|
||||
}
|
||||
|
||||
expect([...ranks.entries()]).toEqual([
|
||||
["models", 1],
|
||||
["thinking", 1.1],
|
||||
["modes", 1.2],
|
||||
["plan-mode", 1.3],
|
||||
["fast-mode", 1.4],
|
||||
]);
|
||||
for (const rank of ranks.values()) expect(rank).toBeLessThan(2);
|
||||
expect(new Set(contributions.map((contribution) => contribution.id)).size).toBe(
|
||||
contributions.length,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,319 @@
|
||||
import type {
|
||||
AgentFeature,
|
||||
AgentMode,
|
||||
AgentProvider,
|
||||
AgentSelectOption,
|
||||
} from "@getpaseo/protocol/agent-types";
|
||||
import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection";
|
||||
import { formatAgentModeLabel, formatThinkingOptionLabel } from "@/agent-controls/labels";
|
||||
import {
|
||||
FAST_MODE_FEATURE_ID,
|
||||
isPlanningAgentMode,
|
||||
PLAN_MODE_FEATURE_ID,
|
||||
resolveNonPlanningModeId,
|
||||
} from "@/agent-controls/policy";
|
||||
import type { CommandCenterContribution, CommandCenterIcon } from "./contributions";
|
||||
|
||||
const GROUP_RANK = {
|
||||
models: 1,
|
||||
thinking: 1.1,
|
||||
modes: 1.2,
|
||||
planMode: 1.3,
|
||||
fastMode: 1.4,
|
||||
} as const;
|
||||
|
||||
export interface AgentControlContributionLabels {
|
||||
model: string;
|
||||
thinking: string;
|
||||
mode: string;
|
||||
planMode: string;
|
||||
fast: string;
|
||||
on: string;
|
||||
off: string;
|
||||
modelKeywords: string;
|
||||
thinkingKeywords: string;
|
||||
modeKeywords: string;
|
||||
planKeywords: string;
|
||||
fastKeywords: string;
|
||||
}
|
||||
|
||||
export function buildAgentControlContributionLabels(
|
||||
t: (key: string) => string,
|
||||
): AgentControlContributionLabels {
|
||||
return {
|
||||
model: t("shell.commandCenter.modelGroupLabel"),
|
||||
thinking: t("shell.commandCenter.thinkingGroupLabel"),
|
||||
mode: t("shell.commandCenter.modeGroupLabel"),
|
||||
planMode: t("shell.commandCenter.planModeGroupLabel"),
|
||||
fast: t("shell.commandCenter.fastModeGroupLabel"),
|
||||
on: t("shell.commandCenter.settingOn"),
|
||||
off: t("shell.commandCenter.settingOff"),
|
||||
modelKeywords: t("shell.commandCenter.modelSearchKeywords"),
|
||||
thinkingKeywords: t("shell.commandCenter.thinkingSearchKeywords"),
|
||||
modeKeywords: t("shell.commandCenter.modeSearchKeywords"),
|
||||
planKeywords: t("shell.commandCenter.planModeSearchKeywords"),
|
||||
fastKeywords: t("shell.commandCenter.fastModeSearchKeywords"),
|
||||
};
|
||||
}
|
||||
|
||||
export interface AgentControlContributionIcons {
|
||||
provider(provider: AgentProvider): CommandCenterIcon;
|
||||
thinking: CommandCenterIcon;
|
||||
planMode: CommandCenterIcon;
|
||||
mode(modeId: string): CommandCenterIcon;
|
||||
feature(feature: AgentFeature): CommandCenterIcon;
|
||||
}
|
||||
|
||||
interface CommandCenterChoice {
|
||||
id: string;
|
||||
path: readonly string[];
|
||||
keywords?: readonly string[];
|
||||
icon?: CommandCenterIcon;
|
||||
selected: boolean;
|
||||
testId?: string;
|
||||
select(): void | Promise<void>;
|
||||
}
|
||||
|
||||
interface CommandCenterChoiceGroup {
|
||||
id: string;
|
||||
rank: number;
|
||||
label: string;
|
||||
keywords: readonly string[];
|
||||
choices: readonly CommandCenterChoice[];
|
||||
}
|
||||
|
||||
export interface AgentControlContributionSource {
|
||||
serverId: string;
|
||||
ownerKey: string;
|
||||
provider: string | null;
|
||||
labels: AgentControlContributionLabels;
|
||||
icons: AgentControlContributionIcons;
|
||||
models: {
|
||||
providers: readonly ProviderSelectorProvider[];
|
||||
selectedProvider: string | null;
|
||||
selectedModelId: string | null;
|
||||
select(provider: AgentProvider, modelId: string): void | Promise<void>;
|
||||
};
|
||||
thinking: {
|
||||
options: readonly AgentSelectOption[];
|
||||
selectedId: string | null;
|
||||
select(optionId: string): void | Promise<void>;
|
||||
};
|
||||
modes?: {
|
||||
options: readonly AgentMode[];
|
||||
selectedId: string | null;
|
||||
defaultModeId: string | null;
|
||||
select(modeId: string): void | Promise<void>;
|
||||
};
|
||||
features: {
|
||||
list: readonly AgentFeature[];
|
||||
set(featureId: string, value: unknown): void | Promise<void>;
|
||||
};
|
||||
}
|
||||
|
||||
function buildContributions(
|
||||
groups: readonly CommandCenterChoiceGroup[],
|
||||
): CommandCenterContribution[] {
|
||||
const contributions: CommandCenterContribution[] = [];
|
||||
for (const group of groups) {
|
||||
for (const [rank, choice] of group.choices.entries()) {
|
||||
contributions.push({
|
||||
id: `${group.id}:${choice.id}`,
|
||||
group: group.id,
|
||||
groupRank: group.rank,
|
||||
rank,
|
||||
keywords: [...group.keywords, ...(choice.keywords ?? [])],
|
||||
visibility: "query",
|
||||
run: () => {
|
||||
if (!choice.selected) return choice.select();
|
||||
},
|
||||
presentation: {
|
||||
kind: "choice",
|
||||
path: [group.label, ...choice.path],
|
||||
icon: choice.icon,
|
||||
selected: choice.selected,
|
||||
testId: choice.testId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
return contributions;
|
||||
}
|
||||
|
||||
function buildModelGroup(source: AgentControlContributionSource): CommandCenterChoiceGroup {
|
||||
const choices: CommandCenterChoice[] = [];
|
||||
for (const provider of source.models.providers) {
|
||||
if (provider.modelSelection.kind !== "models") continue;
|
||||
const agentProvider = provider.id;
|
||||
const icon = source.icons.provider(agentProvider);
|
||||
for (const model of provider.modelSelection.rows) {
|
||||
if (!model.modelId) continue;
|
||||
const modelId = model.modelId;
|
||||
choices.push({
|
||||
id: `${provider.id}:${modelId}`,
|
||||
path: [provider.label, model.modelLabel],
|
||||
keywords: [modelId],
|
||||
icon,
|
||||
selected:
|
||||
source.models.selectedProvider === provider.id &&
|
||||
source.models.selectedModelId === modelId,
|
||||
testId: `command-center-model-${source.serverId}:${provider.id}:${modelId}`,
|
||||
select: () => source.models.select(agentProvider, modelId),
|
||||
});
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: "models",
|
||||
rank: GROUP_RANK.models,
|
||||
label: source.labels.model,
|
||||
keywords: [source.labels.modelKeywords],
|
||||
choices,
|
||||
};
|
||||
}
|
||||
|
||||
function buildThinkingGroup(source: AgentControlContributionSource): CommandCenterChoiceGroup {
|
||||
const choices =
|
||||
source.thinking.options.length > 1
|
||||
? source.thinking.options.map(
|
||||
(option): CommandCenterChoice => ({
|
||||
id: option.id,
|
||||
path: [formatThinkingOptionLabel(option)],
|
||||
icon: source.icons.thinking,
|
||||
selected: option.id === source.thinking.selectedId,
|
||||
testId: `command-center-thinking-${source.serverId}:${source.ownerKey}:${option.id}`,
|
||||
select: () => source.thinking.select(option.id),
|
||||
}),
|
||||
)
|
||||
: [];
|
||||
return {
|
||||
id: "thinking",
|
||||
rank: GROUP_RANK.thinking,
|
||||
label: source.labels.thinking,
|
||||
keywords: [source.labels.thinkingKeywords],
|
||||
choices,
|
||||
};
|
||||
}
|
||||
|
||||
function buildModeGroup(source: AgentControlContributionSource): CommandCenterChoiceGroup {
|
||||
const modes = source.modes;
|
||||
const choices = modes
|
||||
? modes.options
|
||||
.filter((mode) => !isPlanningAgentMode(mode))
|
||||
.map(
|
||||
(mode): CommandCenterChoice => ({
|
||||
id: mode.id,
|
||||
path: [formatAgentModeLabel(mode)],
|
||||
icon: source.icons.mode(mode.id),
|
||||
selected: mode.id === modes.selectedId,
|
||||
testId: `command-center-mode-${source.serverId}:${source.ownerKey}:${mode.id}`,
|
||||
select: () => modes.select(mode.id),
|
||||
}),
|
||||
)
|
||||
: [];
|
||||
return {
|
||||
id: "modes",
|
||||
rank: GROUP_RANK.modes,
|
||||
label: source.labels.mode,
|
||||
keywords: [source.labels.modeKeywords],
|
||||
choices,
|
||||
};
|
||||
}
|
||||
|
||||
function toggleChoices(input: {
|
||||
source: AgentControlContributionSource;
|
||||
key: "plan" | "fast";
|
||||
icon: CommandCenterIcon;
|
||||
isOn: boolean;
|
||||
turnOn(): void | Promise<void>;
|
||||
turnOff(): void | Promise<void>;
|
||||
}): CommandCenterChoice[] {
|
||||
const states = [
|
||||
{ id: "on", label: input.source.labels.on, selected: input.isOn, select: input.turnOn },
|
||||
{ id: "off", label: input.source.labels.off, selected: !input.isOn, select: input.turnOff },
|
||||
];
|
||||
return states.map((state) => ({
|
||||
id: state.id,
|
||||
path: [state.label],
|
||||
icon: input.icon,
|
||||
selected: state.selected,
|
||||
testId: `command-center-${input.key}-${input.source.serverId}:${input.source.ownerKey}:${state.id}`,
|
||||
select: state.select,
|
||||
}));
|
||||
}
|
||||
|
||||
function buildPlanModeGroup(source: AgentControlContributionSource): CommandCenterChoiceGroup {
|
||||
const planFeature = source.features.list.find(
|
||||
(feature) => feature.id === PLAN_MODE_FEATURE_ID && feature.type === "toggle",
|
||||
);
|
||||
let choices: CommandCenterChoice[] = [];
|
||||
if (planFeature?.type === "toggle") {
|
||||
choices = toggleChoices({
|
||||
source,
|
||||
key: "plan",
|
||||
icon: source.icons.feature(planFeature),
|
||||
isOn: planFeature.value,
|
||||
turnOn: () => source.features.set(PLAN_MODE_FEATURE_ID, true),
|
||||
turnOff: () => source.features.set(PLAN_MODE_FEATURE_ID, false),
|
||||
});
|
||||
} else if (source.modes) {
|
||||
const modes = source.modes;
|
||||
const planMode = modes.options.find(isPlanningAgentMode);
|
||||
const offModeId = resolveNonPlanningModeId(modes.options, modes.defaultModeId);
|
||||
if (planMode && offModeId) {
|
||||
choices = toggleChoices({
|
||||
source,
|
||||
key: "plan",
|
||||
icon: source.icons.planMode,
|
||||
isOn: modes.selectedId === planMode.id,
|
||||
turnOn: () => modes.select(planMode.id),
|
||||
turnOff: () => modes.select(offModeId),
|
||||
});
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: "plan-mode",
|
||||
rank: GROUP_RANK.planMode,
|
||||
label: source.labels.planMode,
|
||||
keywords: [source.labels.planKeywords],
|
||||
choices,
|
||||
};
|
||||
}
|
||||
|
||||
function buildFastModeGroup(source: AgentControlContributionSource): CommandCenterChoiceGroup {
|
||||
const fastFeature = source.features.list.find(
|
||||
(feature) => feature.id === FAST_MODE_FEATURE_ID && feature.type === "toggle",
|
||||
);
|
||||
const choices =
|
||||
fastFeature?.type === "toggle"
|
||||
? toggleChoices({
|
||||
source,
|
||||
key: "fast",
|
||||
icon: source.icons.feature(fastFeature),
|
||||
isOn: fastFeature.value,
|
||||
turnOn: () => source.features.set(FAST_MODE_FEATURE_ID, true),
|
||||
turnOff: () => source.features.set(FAST_MODE_FEATURE_ID, false),
|
||||
})
|
||||
: [];
|
||||
return {
|
||||
id: "fast-mode",
|
||||
rank: GROUP_RANK.fastMode,
|
||||
label: source.labels.fast,
|
||||
keywords: [source.labels.fastKeywords],
|
||||
choices,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildAgentControlContributions(
|
||||
source: AgentControlContributionSource,
|
||||
): CommandCenterContribution[] {
|
||||
const groups = [buildModelGroup(source)];
|
||||
if (source.provider) {
|
||||
groups.push(
|
||||
buildThinkingGroup(source),
|
||||
buildModeGroup(source),
|
||||
buildPlanModeGroup(source),
|
||||
buildFastModeGroup(source),
|
||||
);
|
||||
}
|
||||
return buildContributions(groups);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type {
|
||||
AgentFeature,
|
||||
AgentMode,
|
||||
AgentProvider,
|
||||
AgentSelectOption,
|
||||
} from "@getpaseo/protocol/agent-types";
|
||||
import type { AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
|
||||
import {
|
||||
getAgentFeatureIcon,
|
||||
getAgentModeIcon,
|
||||
PlanModeIcon,
|
||||
ThinkingIcon,
|
||||
} from "@/agent-controls/icons";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection";
|
||||
import {
|
||||
buildAgentControlContributions,
|
||||
buildAgentControlContributionLabels,
|
||||
type AgentControlContributionSource,
|
||||
} from "./agent-control-contributions";
|
||||
import { getCommandCenterIcon } from "./icon";
|
||||
import { useCommandCenterActions } from "./provider";
|
||||
|
||||
export interface AgentControlCommandCenterSource {
|
||||
serverId: string;
|
||||
ownerKey: string;
|
||||
provider: string | null | undefined;
|
||||
providerDefinitions: AgentProviderDefinition[];
|
||||
models: {
|
||||
providers: readonly ProviderSelectorProvider[];
|
||||
selectedProvider: string | null | undefined;
|
||||
selectedModelId: string | null | undefined;
|
||||
select(provider: AgentProvider, modelId: string): void | Promise<void>;
|
||||
};
|
||||
thinking: {
|
||||
options: readonly AgentSelectOption[] | null | undefined;
|
||||
selectedId: string | null | undefined;
|
||||
select(optionId: string): void | Promise<void>;
|
||||
};
|
||||
modes?: {
|
||||
options: readonly AgentMode[];
|
||||
selectedId: string | null | undefined;
|
||||
select(modeId: string): void | Promise<void>;
|
||||
};
|
||||
features: {
|
||||
list: readonly AgentFeature[] | undefined;
|
||||
set?: (featureId: string, value: unknown) => void | Promise<void>;
|
||||
};
|
||||
}
|
||||
|
||||
function ignoreAgentFeatureChange(): void {
|
||||
return;
|
||||
}
|
||||
|
||||
function resolveDefaultModeId(
|
||||
provider: string | null | undefined,
|
||||
providerDefinitions: AgentProviderDefinition[],
|
||||
): string | null {
|
||||
return (
|
||||
providerDefinitions.find((definition) => definition.id === provider)?.defaultModeId ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function buildModeContributionSource(
|
||||
options: readonly AgentMode[] | undefined,
|
||||
selectedId: string | null | undefined,
|
||||
select: ((modeId: string) => void | Promise<void>) | undefined,
|
||||
defaultModeId: string | null,
|
||||
): Pick<AgentControlContributionSource, "modes"> {
|
||||
if (!options || !select) return {};
|
||||
return {
|
||||
modes: {
|
||||
options,
|
||||
selectedId: selectedId ?? null,
|
||||
defaultModeId,
|
||||
select,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function useAgentControlCommandCenterActions(input: {
|
||||
sourceId: string;
|
||||
enabled: boolean;
|
||||
controls: AgentControlCommandCenterSource;
|
||||
}): void {
|
||||
const { t } = useTranslation();
|
||||
const { controls } = input;
|
||||
const { features, models, modes, thinking } = controls;
|
||||
const actions = useMemo(
|
||||
() =>
|
||||
buildAgentControlContributions({
|
||||
serverId: controls.serverId,
|
||||
ownerKey: controls.ownerKey,
|
||||
provider: controls.provider ?? null,
|
||||
labels: buildAgentControlContributionLabels(t),
|
||||
icons: {
|
||||
provider: (provider) => getCommandCenterIcon(getProviderIcon(provider)),
|
||||
thinking: getCommandCenterIcon(ThinkingIcon),
|
||||
planMode: getCommandCenterIcon(PlanModeIcon),
|
||||
mode: (modeId) =>
|
||||
getCommandCenterIcon(
|
||||
getAgentModeIcon(controls.provider ?? "", modeId, controls.providerDefinitions),
|
||||
),
|
||||
feature: (feature) => getCommandCenterIcon(getAgentFeatureIcon(feature.icon)),
|
||||
},
|
||||
models: {
|
||||
providers: models.providers,
|
||||
selectedProvider: models.selectedProvider ?? null,
|
||||
selectedModelId: models.selectedModelId ?? null,
|
||||
select: models.select,
|
||||
},
|
||||
thinking: {
|
||||
options: thinking.options ?? [],
|
||||
selectedId: thinking.selectedId ?? null,
|
||||
select: thinking.select,
|
||||
},
|
||||
...buildModeContributionSource(
|
||||
modes?.options,
|
||||
modes?.selectedId,
|
||||
modes?.select,
|
||||
resolveDefaultModeId(controls.provider, controls.providerDefinitions),
|
||||
),
|
||||
features: {
|
||||
list: features.set ? (features.list ?? []) : [],
|
||||
set: features.set ?? ignoreAgentFeatureChange,
|
||||
},
|
||||
}),
|
||||
[
|
||||
controls.ownerKey,
|
||||
controls.provider,
|
||||
controls.providerDefinitions,
|
||||
controls.serverId,
|
||||
features.list,
|
||||
features.set,
|
||||
models.providers,
|
||||
models.select,
|
||||
models.selectedModelId,
|
||||
models.selectedProvider,
|
||||
modes?.options,
|
||||
modes?.select,
|
||||
modes?.selectedId,
|
||||
thinking.options,
|
||||
thinking.select,
|
||||
thinking.selectedId,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
useCommandCenterActions({
|
||||
sourceId: input.sourceId,
|
||||
enabled: input.enabled,
|
||||
actions,
|
||||
});
|
||||
}
|
||||
@@ -401,6 +401,15 @@ interface ResultRowProps {
|
||||
|
||||
const ResultRow = memo(function ResultRow({ result, active, onSelect }: ResultRowProps) {
|
||||
const press = useCallback(() => onSelect(result), [onSelect, result]);
|
||||
const choice =
|
||||
result.kind === "contribution" && result.contribution.presentation.kind === "choice"
|
||||
? result.contribution.presentation
|
||||
: null;
|
||||
const accessibilityLabel = choice?.path.join(" › ");
|
||||
const accessibilityState = useMemo(
|
||||
() => (isNative && choice ? { selected: choice.selected } : undefined),
|
||||
[choice],
|
||||
);
|
||||
const style = useCallback(
|
||||
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.row,
|
||||
@@ -415,7 +424,15 @@ const ResultRow = memo(function ResultRow({ result, active, onSelect }: ResultRo
|
||||
[active, result],
|
||||
);
|
||||
return (
|
||||
<Pressable style={style} onPress={press}>
|
||||
<Pressable
|
||||
style={style}
|
||||
onPress={press}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
accessibilityState={accessibilityState}
|
||||
aria-pressed={isWeb ? choice?.selected : undefined}
|
||||
testID={choice?.testId}
|
||||
>
|
||||
<ResultContent result={result} />
|
||||
</Pressable>
|
||||
);
|
||||
@@ -497,7 +514,7 @@ function ResultContent({ result }: { result: CommandCenterResult }) {
|
||||
);
|
||||
}
|
||||
return (
|
||||
<View style={styles.rowContent} testID={presentation.testId}>
|
||||
<View style={styles.rowContent}>
|
||||
<View style={styles.rowMain}>
|
||||
{Icon ? (
|
||||
<View style={styles.iconSlot}>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import type { AgentControlIcon } from "@/agent-controls/icons";
|
||||
import type { CommandCenterIcon, CommandCenterIconProps } from "./contributions";
|
||||
|
||||
const commandCenterIcons = new Map<AgentControlIcon, CommandCenterIcon>();
|
||||
|
||||
export function getCommandCenterIcon(Component: AgentControlIcon): CommandCenterIcon {
|
||||
const cached = commandCenterIcons.get(Component);
|
||||
if (cached) return cached;
|
||||
|
||||
const ThemedIcon = withUnistyles(Component, (theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
}));
|
||||
function CommandCenterIconAdapter({ size }: CommandCenterIconProps) {
|
||||
return <ThemedIcon size={size} />;
|
||||
}
|
||||
commandCenterIcons.set(Component, CommandCenterIconAdapter);
|
||||
return CommandCenterIconAdapter;
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection";
|
||||
import { buildModelChoiceContributions } from "./model-contributions";
|
||||
|
||||
function TestIcon() {
|
||||
return null;
|
||||
}
|
||||
|
||||
function provider(input: {
|
||||
id: string;
|
||||
label: string;
|
||||
state: "models" | "error";
|
||||
}): ProviderSelectorProvider {
|
||||
if (input.state === "error") {
|
||||
return {
|
||||
id: input.id,
|
||||
label: input.label,
|
||||
modelSelection: { kind: "error", message: "Unavailable" },
|
||||
};
|
||||
}
|
||||
return {
|
||||
id: input.id,
|
||||
label: input.label,
|
||||
modelSelection: {
|
||||
kind: "models",
|
||||
rows: [
|
||||
{
|
||||
favoriteKey: `${input.id}:model`,
|
||||
provider: input.id,
|
||||
providerLabel: input.label,
|
||||
modelId: "model",
|
||||
modelLabel: `${input.label} model`,
|
||||
description: undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("Command Center model choices", () => {
|
||||
it("publishes selectable providers and executes the draft owner's command", () => {
|
||||
const selections: string[] = [];
|
||||
const choices = buildModelChoiceContributions({
|
||||
serverId: "host",
|
||||
providers: [
|
||||
provider({ id: "claude", label: "Claude", state: "models" }),
|
||||
provider({ id: "codex", label: "Codex", state: "models" }),
|
||||
],
|
||||
selectedProvider: "codex",
|
||||
selectedModelId: "model",
|
||||
groupLabel: "Model",
|
||||
searchKeywords: "model switch",
|
||||
getIcon: () => TestIcon,
|
||||
select: (selectedProvider, modelId) => selections.push(`${selectedProvider}:${modelId}`),
|
||||
});
|
||||
|
||||
expect(
|
||||
choices.map((choice) => ({
|
||||
id: choice.id,
|
||||
selected: choice.presentation.kind === "choice" ? choice.presentation.selected : false,
|
||||
})),
|
||||
).toEqual([
|
||||
{ id: "host:claude:model", selected: false },
|
||||
{ id: "host:codex:model", selected: true },
|
||||
]);
|
||||
choices[0].run();
|
||||
choices[1].run();
|
||||
expect(selections).toEqual(["claude:model"]);
|
||||
});
|
||||
|
||||
it("does not publish unavailable providers or no-op default rows", () => {
|
||||
const choices = buildModelChoiceContributions({
|
||||
serverId: "host",
|
||||
providers: [
|
||||
provider({ id: "unavailable", label: "Unavailable", state: "error" }),
|
||||
{
|
||||
id: "empty",
|
||||
label: "Empty",
|
||||
modelSelection: {
|
||||
kind: "models",
|
||||
rows: [
|
||||
{
|
||||
favoriteKey: "empty:",
|
||||
provider: "empty",
|
||||
providerLabel: "Empty",
|
||||
modelId: "",
|
||||
modelLabel: "Default",
|
||||
description: undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
selectedProvider: null,
|
||||
selectedModelId: null,
|
||||
groupLabel: "Model",
|
||||
searchKeywords: "model switch",
|
||||
getIcon: () => TestIcon,
|
||||
select: () => undefined,
|
||||
});
|
||||
|
||||
expect(choices).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,51 +0,0 @@
|
||||
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
|
||||
import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection";
|
||||
import type { CommandCenterContribution, CommandCenterIcon } from "./contributions";
|
||||
|
||||
interface ModelChoiceInput {
|
||||
serverId: string;
|
||||
providers: readonly ProviderSelectorProvider[];
|
||||
selectedProvider: string | null;
|
||||
selectedModelId: string | null;
|
||||
groupLabel: string;
|
||||
searchKeywords: string;
|
||||
getIcon(provider: AgentProvider): CommandCenterIcon;
|
||||
select(provider: AgentProvider, modelId: string): void;
|
||||
}
|
||||
|
||||
export function buildModelChoiceContributions(
|
||||
input: ModelChoiceInput,
|
||||
): CommandCenterContribution[] {
|
||||
const contributions: CommandCenterContribution[] = [];
|
||||
let rank = 0;
|
||||
for (const provider of input.providers) {
|
||||
if (provider.modelSelection.kind !== "models") continue;
|
||||
const agentProvider = provider.id;
|
||||
const icon = input.getIcon(agentProvider);
|
||||
for (const model of provider.modelSelection.rows) {
|
||||
if (!model.modelId) continue;
|
||||
const modelId = model.modelId;
|
||||
const selected = input.selectedProvider === provider.id && input.selectedModelId === modelId;
|
||||
contributions.push({
|
||||
id: `${input.serverId}:${provider.id}:${modelId}`,
|
||||
group: "models",
|
||||
groupRank: 1,
|
||||
rank,
|
||||
keywords: [modelId, input.searchKeywords],
|
||||
visibility: "query",
|
||||
run: () => {
|
||||
if (!selected) input.select(agentProvider, modelId);
|
||||
},
|
||||
presentation: {
|
||||
kind: "choice",
|
||||
path: [input.groupLabel, provider.label, model.modelLabel],
|
||||
icon,
|
||||
selected,
|
||||
testId: `command-center-model-${input.serverId}:${provider.id}:${modelId}`,
|
||||
},
|
||||
});
|
||||
rank += 1;
|
||||
}
|
||||
}
|
||||
return contributions;
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import type { CommandCenterIcon, CommandCenterIconProps } from "./contributions";
|
||||
|
||||
const commandCenterProviderIcons = new Map<AgentProvider, CommandCenterIcon>();
|
||||
|
||||
export function getCommandCenterProviderIcon(provider: AgentProvider): CommandCenterIcon {
|
||||
const cached = commandCenterProviderIcons.get(provider);
|
||||
if (cached) return cached;
|
||||
|
||||
const ProviderIcon = withUnistyles(getProviderIcon(provider), (theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
}));
|
||||
function CommandCenterProviderIcon({ size }: CommandCenterIconProps) {
|
||||
return <ProviderIcon size={size} />;
|
||||
}
|
||||
commandCenterProviderIcons.set(provider, CommandCenterProviderIcon);
|
||||
return CommandCenterProviderIcon;
|
||||
}
|
||||
Reference in New Issue
Block a user