Skip to content

Commit dc8571c

Browse files
authored
feat(claude): add Claude profile support (#155)
- add Claude profile management in settings and show profile entries in the sidebar, menus, and usage views - persist profile instance config and update shared settings cleanup when profiles are added or removed - scope Claude launches, auth, context extraction, and usage collection to each profile's config dir
1 parent afe9d60 commit dc8571c

45 files changed

Lines changed: 1518 additions & 210 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

src/main/ipc/localHandlers.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,10 +140,23 @@ export function createLocalIpcHandlers(
140140
// Preserve supervisor-managed fields so the renderer's persist cycle
141141
// doesn't clobber writes made out-of-band by the supervisor.
142142
const onDisk = readSharedSettingsFile(settingsPath);
143+
const rendererManagedInstances = Object.fromEntries(
144+
Object.entries(settings.agentInstances).filter(
145+
([, instance]) => instance.driver !== "acp-generic",
146+
),
147+
);
148+
const supervisorManagedInstances = Object.fromEntries(
149+
Object.entries(onDisk.agentInstances).filter(
150+
([, instance]) => instance.driver === "acp-generic",
151+
),
152+
);
143153
writeSharedSettingsFile(settingsPath, {
144154
...settings,
145155
acpRegistryInstalledAgents: onDisk.acpRegistryInstalledAgents,
146-
agentInstances: onDisk.agentInstances,
156+
agentInstances: {
157+
...rendererManagedInstances,
158+
...supervisorManagedInstances,
159+
},
147160
agentHookSupport: onDisk.agentHookSupport,
148161
});
149162
options.updatePowerSaveBlocker();

src/renderer/components/common/ProviderModelMenu/ProviderModelMenu.test.tsx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -389,6 +389,30 @@ describe("ProviderModelMenu", () => {
389389
});
390390
});
391391

392+
it("shows Claude profile models under the profile subprovider row", async () => {
393+
const provider = makeNamedProvider("claude:work", "Claude Work", 2);
394+
provider.capabilities.subProviders = [{ id: "claude-profile", label: "Work" }];
395+
provider.capabilities.modelSubProvider = {
396+
"model-1": "claude-profile",
397+
"model-2": "claude-profile",
398+
};
399+
400+
render(
401+
<ProviderModelMenu
402+
providers={[provider]}
403+
currentAgentKind="claude:work"
404+
currentModel="model-1"
405+
onChange={vi.fn<(next: { agentKind: string; model: string }) => void>()}
406+
/>,
407+
);
408+
409+
fireEvent.click(screen.getByRole("button", { name: "Select model" }));
410+
const listbox = await screen.findByRole("listbox", { name: "Models" });
411+
412+
expect(within(listbox).getByText("Work")).toBeInTheDocument();
413+
expect(within(listbox).getByText("Model 2")).toBeInTheDocument();
414+
});
415+
392416
it("resets the window when a long list shrinks so rows do not render blank", async () => {
393417
const { rerender } = render(
394418
<ProviderModelMenu

src/renderer/components/common/ProviderModelMenu/parts/buildItems.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
import type { AgentCapability, AgentStatus, ThreadPresentationMode } from "@/shared/contracts";
1+
import {
2+
baseAgentKind,
3+
type AgentCapability,
4+
type AgentStatus,
5+
type ThreadPresentationMode,
6+
} from "@/shared/contracts";
27
import { deriveSubProvider, listSubProviderOrder } from "./deriveSubProvider";
38
import {
49
formatShortcutFallbackLabel,
@@ -86,7 +91,7 @@ function makeProviderSortKey(userOrder: readonly string[] | undefined): (kind: s
8691
const trimmed = userOrder?.filter((k) => k.length > 0) ?? [];
8792
if (trimmed.length === 0) {
8893
return (kind) => {
89-
const idx = PROVIDER_ORDER.indexOf(kind);
94+
const idx = PROVIDER_ORDER.indexOf(baseAgentKind(kind));
9095
return idx < 0 ? PROVIDER_ORDER.length : idx;
9196
};
9297
}
@@ -98,7 +103,7 @@ function makeProviderSortKey(userOrder: readonly string[] | undefined): (kind: s
98103
return (kind) => {
99104
const fromUser = userIndex.get(kind);
100105
if (fromUser !== undefined) return fromUser;
101-
const fromDefault = PROVIDER_ORDER.indexOf(kind);
106+
const fromDefault = PROVIDER_ORDER.indexOf(baseAgentKind(kind));
102107
return userTailBase + (fromDefault < 0 ? PROVIDER_ORDER.length : fromDefault);
103108
};
104109
}

src/renderer/components/composer/browserMcpScope.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { ThreadPresentationMode } from "@/shared/contracts";
1+
import { baseAgentKind, type ThreadPresentationMode } from "@/shared/contracts";
22

33
/**
44
* How a given (agentKind, presentationMode) pair gates Browser MCP per-thread.
@@ -20,12 +20,13 @@ export function getBrowserMcpScope(
2020
agentKind: string,
2121
presentationMode: ThreadPresentationMode,
2222
): BrowserMcpScope {
23+
const baseKind = baseAgentKind(agentKind);
2324
if (presentationMode === "gui") {
24-
if (agentKind === "claude") return "always";
25-
if (agentKind === "opencode" || agentKind === "antigravity" || agentKind === "commandcode")
25+
if (baseKind === "claude") return "always";
26+
if (baseKind === "opencode" || baseKind === "antigravity" || baseKind === "commandcode")
2627
return "none";
2728
return "launch";
2829
}
29-
if (agentKind === "codex") return "launch";
30+
if (baseKind === "codex") return "launch";
3031
return "none";
3132
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { render, screen } from "@testing-library/react";
2+
import { describe, expect, it } from "vitest";
3+
import { ProviderIcon } from "./ProviderIcon";
4+
import "./claude";
5+
6+
describe("ProviderIcon", () => {
7+
it("uses the Claude profile label for the profile badge initial", () => {
8+
render(<ProviderIcon kind="claude:home" fallbackLabel="Claude Home" />);
9+
10+
expect(screen.getByText("H")).toBeInTheDocument();
11+
expect(screen.queryByText("C")).not.toBeInTheDocument();
12+
});
13+
});

src/renderer/components/providers/ProviderIcon.tsx

Lines changed: 39 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { CSSProperties, ReactNode } from "react";
2+
import { baseAgentKind } from "@/shared/contracts";
23
import type { StatusTone } from "./statusTone";
34
import {
45
getUtilityTaskCandidates,
@@ -74,6 +75,22 @@ function fallbackInitial(label: string | undefined): string {
7475
return (raw.match(/[A-Za-z0-9]/)?.[0] ?? "?").toUpperCase();
7576
}
7677

78+
function claudeProfileBadgeLabel(kind: string, fallbackLabel: string | undefined): string {
79+
const profileId = kind.slice("claude:".length);
80+
const label = fallbackLabel?.trim();
81+
if (!label) return profileId;
82+
const profileLabel = label.replace(/^claude\s+/i, "").trim();
83+
return profileLabel || profileId;
84+
}
85+
86+
/** Registry lookup that falls back to the base kind for instance-scoped kinds. */
87+
function lookupByKind<T>(registry: Map<string, T>, kind: string): T | undefined {
88+
const exact = registry.get(kind);
89+
if (exact !== undefined) return exact;
90+
const baseKind = baseAgentKind(kind);
91+
return baseKind !== kind ? registry.get(baseKind) : undefined;
92+
}
93+
7794
function GenericProviderIcon(props: { label?: string; tone: StatusTone; className?: string }) {
7895
return (
7996
<span
@@ -103,7 +120,7 @@ export function ProviderIcon(props: {
103120
*/
104121
pending?: boolean | undefined;
105122
}) {
106-
const Icon = ICON_REGISTRY.get(props.kind);
123+
const Icon = lookupByKind(ICON_REGISTRY, props.kind);
107124
const tone = props.tone ?? "inactive";
108125
if (!Icon) {
109126
if (props.icon) {
@@ -126,7 +143,20 @@ export function ProviderIcon(props: {
126143
/>
127144
);
128145
}
129-
return <Icon tone={tone} {...(props.className ? { className: props.className } : {})} />;
146+
const rendered = (
147+
<Icon tone={tone} {...(props.className ? { className: props.className } : {})} />
148+
);
149+
if (props.kind.startsWith("claude:")) {
150+
return (
151+
<span className={`relative inline-flex ${props.className ?? ""}`}>
152+
{rendered}
153+
<span className="absolute -bottom-0.5 -right-0.5 flex size-2.5 items-center justify-center rounded-full border border-background bg-surface text-[6px] font-semibold leading-none text-foreground">
154+
{fallbackInitial(claudeProfileBadgeLabel(props.kind, props.fallbackLabel))}
155+
</span>
156+
</span>
157+
);
158+
}
159+
return rendered;
130160
}
131161

132162
// --- Provider label registry ---
@@ -202,12 +232,7 @@ export function registerComposerControls(kind: string, registration: ComposerCon
202232
}
203233

204234
export function getComposerControls(kind: string): ComposerControlsFactory | undefined {
205-
const separatorIndex = kind.indexOf(":");
206-
const registration =
207-
COMPOSER_CONTROLS_REGISTRY.get(kind) ??
208-
(separatorIndex > 0
209-
? COMPOSER_CONTROLS_REGISTRY.get(kind.slice(0, separatorIndex))
210-
: undefined);
235+
const registration = lookupByKind(COMPOSER_CONTROLS_REGISTRY, kind);
211236
if (!registration) return undefined;
212237
if (typeof registration === "function") return registration;
213238
return (input) => {
@@ -252,7 +277,7 @@ export function registerGuiSlashCommands(kind: string, registration: GuiSlashCom
252277
}
253278

254279
export function getGuiSlashCommands(kind: string): GuiSlashCommandRegistration | undefined {
255-
return GUI_SLASH_COMMAND_REGISTRY.get(kind);
280+
return lookupByKind(GUI_SLASH_COMMAND_REGISTRY, kind);
256281
}
257282

258283
// --- Config normalizer registry ---
@@ -276,7 +301,7 @@ export function registerConfigNormalizer(kind: string, normalizer: ConfigNormali
276301
}
277302

278303
export function getConfigNormalizer(kind: string): ConfigNormalizer | undefined {
279-
return CONFIG_NORMALIZER_REGISTRY.get(kind);
304+
return lookupByKind(CONFIG_NORMALIZER_REGISTRY, kind);
280305
}
281306

282307
// --- Trigger word registry ---
@@ -303,10 +328,7 @@ export function getTriggerWords(
303328
model: string | undefined,
304329
): readonly TriggerWordDef[] {
305330
if (!kind) return [];
306-
const separatorIndex = kind.indexOf(":");
307-
const matcher =
308-
TRIGGER_WORD_REGISTRY.get(kind) ??
309-
(separatorIndex > 0 ? TRIGGER_WORD_REGISTRY.get(kind.slice(0, separatorIndex)) : undefined);
331+
const matcher = lookupByKind(TRIGGER_WORD_REGISTRY, kind);
310332
return matcher ? matcher(model) : [];
311333
}
312334

@@ -321,7 +343,7 @@ export function registerCommitGenDefaults(kind: string, defaults: CommitGenDefau
321343
}
322344

323345
export function getCommitGenDefaults(kind: string): CommitGenDefaults | undefined {
324-
return COMMIT_GEN_REGISTRY.get(kind);
346+
return lookupByKind(COMMIT_GEN_REGISTRY, kind);
325347
}
326348

327349
export function getCommitGenDefaultsHint(): string | undefined {
@@ -339,7 +361,7 @@ export function registerTitleGenDefaults(kind: string, defaults: TitleGenDefault
339361
}
340362

341363
export function getTitleGenDefaults(kind: string): TitleGenDefaults | undefined {
342-
return TITLE_GEN_REGISTRY.get(kind);
364+
return lookupByKind(TITLE_GEN_REGISTRY, kind);
343365
}
344366

345367
export function getTitleGenDefaultsHint(): string | undefined {
@@ -357,7 +379,7 @@ export function registerConflictResolverDefaults(kind: string, defaults: Conflic
357379
}
358380

359381
export function getConflictResolverDefaults(kind: string): ConflictResolverDefaults | undefined {
360-
return CONFLICT_RESOLVER_REGISTRY.get(kind);
382+
return lookupByKind(CONFLICT_RESOLVER_REGISTRY, kind);
361383
}
362384

363385
export function getConflictResolverDefaultsHint(): string | undefined {

src/renderer/components/providers/ProviderUsageRail.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ export function ProviderUsageRail(props: { orientation?: "row" | "column" }) {
140140
const showInSidebar = useSharedSettings((s) => s.usage.showInSidebar);
141141
const disabledProviders = useSharedSettings((s) => s.usage.disabledProviders);
142142
const providerOrder = useSharedSettings((s) => s.usage.providerOrder);
143+
const agentInstances = useSharedSettings((s) => s.agentInstances);
143144
const setUsageSetting = useSharedSettings((s) => s.setUsageSetting);
144145

145146
useEffect(() => {
@@ -166,7 +167,7 @@ export function ProviderUsageRail(props: { orientation?: "row" | "column" }) {
166167
KeyboardSensor,
167168
];
168169

169-
const providers = resolveDisplayedProviders(providerOrder, disabledProviders);
170+
const providers = resolveDisplayedProviders(providerOrder, disabledProviders, agentInstances);
170171

171172
if (!showInSidebar || providers.length === 0) return null;
172173

src/renderer/components/providers/claude/index.test.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,4 +68,15 @@ describe("Claude composer controls", () => {
6868
"bypassPermissions",
6969
);
7070
});
71+
72+
it("uses the Claude composer controls for profile-backed Claude providers", () => {
73+
const controls = getComposerControls("claude:work")?.({
74+
capabilities,
75+
config: { model: "claude-fable-5" },
76+
isDisabled: false,
77+
onConfigChange: () => undefined,
78+
});
79+
80+
expect(controls?.some(isPermissionControl)).toBe(true);
81+
});
7182
});
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { describe, expect, it } from "vitest";
2+
import type { UsageWindow } from "@lightcode/agents-usage";
3+
import type { AgentInstanceConfigMap } from "@/shared/contracts";
4+
import {
5+
pickUsageRings,
6+
resolveDisplayedProviders,
7+
usageProvidersForAgentInstances,
8+
} from "./usageProviders";
9+
10+
const agentInstances: AgentInstanceConfigMap = {
11+
work: {
12+
id: "work",
13+
driver: "claude",
14+
displayName: "Work",
15+
config: { configDir: "~/.lightcode/claude-profiles/work" },
16+
},
17+
home: {
18+
id: "home",
19+
driver: "claude",
20+
displayName: "Home",
21+
config: { configDir: "~/.lightcode/claude-profiles/home" },
22+
},
23+
disabled: {
24+
id: "disabled",
25+
driver: "claude",
26+
displayName: "Disabled",
27+
enabled: false,
28+
config: { configDir: "~/.lightcode/claude-profiles/disabled" },
29+
},
30+
};
31+
32+
describe("usageProviders", () => {
33+
it("adds Claude profile providers after the base Claude provider", () => {
34+
const providers = usageProvidersForAgentInstances(agentInstances);
35+
const claudeIndex = providers.findIndex((provider) => provider.id === "claude");
36+
37+
expect(providers.slice(claudeIndex, claudeIndex + 3).map((provider) => provider.id)).toEqual([
38+
"claude",
39+
"claude:home",
40+
"claude:work",
41+
]);
42+
expect(providers.find((provider) => provider.id === "claude:home")?.label).toBe("Claude Home");
43+
});
44+
45+
it("orders, disables, and rings Claude profiles like Claude", () => {
46+
const providers = resolveDisplayedProviders(
47+
["claude:work", "claude"],
48+
["claude:home"],
49+
agentInstances,
50+
);
51+
expect(providers.slice(0, 2).map((provider) => provider.id)).toEqual(["claude:work", "claude"]);
52+
expect(providers.some((provider) => provider.id === "claude:home")).toBe(false);
53+
54+
const windows: UsageWindow[] = [
55+
{ id: "weekly", label: "Weekly", usedPercent: 20, unit: "percent" },
56+
{ id: "session-5h", label: "Session", usedPercent: 60, unit: "percent" },
57+
];
58+
expect(pickUsageRings("claude:work", windows)).toEqual({
59+
outer: windows[1],
60+
inner: windows[0],
61+
});
62+
});
63+
});

0 commit comments

Comments
 (0)