feat: enable/disable providers from settings

Adds a per-provider toggle in Settings → Providers, persisted via daemon
config, so users can turn off providers they don't have installed (or
don't want to use) without removing them from the registry.

Disabled providers stay visible everywhere — settings, `paseo provider
ls`, MCP `list_providers`, model/mode lists — marked as disabled rather
than silently filtered. Availability probes are skipped for them, and
attempts to launch an agent or fetch models/modes from a disabled
provider return a clear "Provider '<id>' is disabled" error.

Schema additions (`enabled` on `ProviderSnapshotEntry`, MCP provider
summary) are backward-compatible: optional with a default of true so
older clients continue to parse new daemon messages.

CLI: `paseo provider ls` gains an ENABLED column; JSON output includes
the new field.
This commit is contained in:
Mohamed Boudra
2026-04-25 14:00:38 +07:00
parent de42858213
commit 4b3603604a
31 changed files with 1920 additions and 216 deletions
@@ -0,0 +1,163 @@
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { JSDOM } from "jsdom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Switch } from "./switch";
const { theme } = vi.hoisted(() => ({
theme: {
opacity: { 50: 0.5 },
colors: {
surface3: "#333",
accent: "#0a84ff",
palette: { white: "#fff" },
},
},
}));
vi.mock("react-native-reanimated", () => ({
default: {
View: "div",
},
Easing: {
ease: "ease",
inOut: (value: unknown) => value,
},
interpolateColor: (value: number, _input: number[], output: string[]) =>
value >= 1 ? output[1] : output[0],
useAnimatedStyle: (factory: () => unknown) => factory(),
useDerivedValue: (factory: () => unknown) => ({ value: factory() }),
withTiming: (value: unknown) => value,
}));
vi.mock("react-native", () => ({
Pressable: ({
accessibilityLabel,
accessibilityRole,
accessibilityState,
children,
disabled,
onPress,
testID,
}: {
accessibilityLabel?: string;
accessibilityRole?: string;
accessibilityState?: { checked?: boolean; disabled?: boolean };
children: React.ReactNode;
disabled?: boolean;
onPress?: (event: { stopPropagation: () => void }) => void;
testID?: string;
}) =>
React.createElement(
"button",
{
"aria-checked": accessibilityState?.checked,
"aria-disabled": accessibilityState?.disabled,
"aria-label": accessibilityLabel,
"data-disabled": disabled,
"data-testid": testID,
onClick: () => onPress?.({ stopPropagation: vi.fn() }),
role: accessibilityRole,
type: "button",
},
children,
),
}));
vi.mock("react-native-unistyles", () => ({
StyleSheet: {
create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory),
},
useUnistyles: () => ({ theme }),
}));
describe("Switch", () => {
let root: Root | null = null;
let container: HTMLElement | null = null;
beforeEach(() => {
const dom = new JSDOM("<!doctype html><html><body></body></html>");
vi.stubGlobal("React", React);
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.stubGlobal("window", dom.window);
vi.stubGlobal("document", dom.window.document);
vi.stubGlobal("HTMLElement", dom.window.HTMLElement);
vi.stubGlobal("Node", dom.window.Node);
vi.stubGlobal("navigator", dom.window.navigator);
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
if (root) {
act(() => {
root?.unmount();
});
}
root = null;
container = null;
vi.unstubAllGlobals();
});
function renderSwitch(props: React.ComponentProps<typeof Switch>): HTMLElement {
act(() => {
root?.render(<Switch {...props} />);
});
const switchElement = container?.querySelector('[role="switch"]') as HTMLElement | null;
if (!switchElement) {
throw new Error("Expected switch element to render");
}
return switchElement;
}
function pressSwitch(switchElement: HTMLElement): void {
act(() => {
switchElement.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
});
}
it("renders with accessibility role switch", () => {
const switchElement = renderSwitch({
value: false,
accessibilityLabel: "Enable Claude",
testID: "provider-switch",
});
expect(switchElement.getAttribute("role")).toBe("switch");
expect(switchElement.getAttribute("aria-label")).toBe("Enable Claude");
});
it("calls onValueChange with the toggled value when pressed", () => {
const onValueChange = vi.fn();
const switchElement = renderSwitch({ value: false, onValueChange });
pressSwitch(switchElement);
expect(onValueChange).toHaveBeenCalledTimes(1);
expect(onValueChange).toHaveBeenCalledWith(true);
});
it("does not call onValueChange when disabled", () => {
const onValueChange = vi.fn();
const switchElement = renderSwitch({ value: false, onValueChange, disabled: true });
pressSwitch(switchElement);
expect(onValueChange).not.toHaveBeenCalled();
});
it("reflects checked accessibility state from value", () => {
const switchElement = renderSwitch({ value: true });
expect(switchElement.getAttribute("aria-checked")).toBe("true");
act(() => {
root?.render(<Switch value={false} />);
});
expect(switchElement.getAttribute("aria-checked")).toBe("false");
});
});
+133
View File
@@ -0,0 +1,133 @@
import { useCallback, useMemo } from "react";
import {
Pressable,
type GestureResponderEvent,
type StyleProp,
type ViewStyle,
} from "react-native";
import Animated, {
Easing,
interpolateColor,
useAnimatedStyle,
useDerivedValue,
withTiming,
} from "react-native-reanimated";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
type SwitchSize = "sm" | "md";
interface SwitchProps {
value: boolean;
onValueChange?: (value: boolean) => void;
disabled?: boolean;
size?: SwitchSize;
accessibilityLabel?: string;
testID?: string;
style?: StyleProp<ViewStyle>;
}
const SIZES: Record<SwitchSize, { track: { width: number; height: number }; thumb: number }> = {
md: { track: { width: 44, height: 26 }, thumb: 22 },
sm: { track: { width: 34, height: 20 }, thumb: 16 },
};
const TIMING = { duration: 180, easing: Easing.inOut(Easing.ease) };
export function Switch({
value,
onValueChange,
disabled = false,
size = "md",
accessibilityLabel,
testID,
style,
}: SwitchProps) {
const { theme } = useUnistyles();
const { track, thumb } = SIZES[size];
const padding = (track.height - thumb) / 2;
const thumbTravel = track.width - thumb - padding * 2;
const progress = useDerivedValue(() => withTiming(value ? 1 : 0, TIMING));
const trackAnimatedStyle = useAnimatedStyle(() => ({
backgroundColor: interpolateColor(
progress.value,
[0, 1],
[theme.colors.surface3, theme.colors.accent],
),
}));
const thumbAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ translateX: progress.value * thumbTravel }],
}));
const handlePress = useCallback(
(event: GestureResponderEvent) => {
event.stopPropagation();
if (disabled) return;
onValueChange?.(!value);
},
[disabled, onValueChange, value],
);
const accessibilityState = useMemo(() => ({ checked: value, disabled }), [value, disabled]);
const pressableStyle = useMemo(
() => [disabled ? styles.disabled : null, style],
[disabled, style],
);
const trackStyle = useMemo(
() => [
styles.track,
{
width: track.width,
height: track.height,
borderRadius: track.height / 2,
padding,
},
trackAnimatedStyle,
],
[track.width, track.height, padding, trackAnimatedStyle],
);
const thumbStyle = useMemo(
() => [
styles.thumb,
{ width: thumb, height: thumb, borderRadius: thumb / 2 },
thumbAnimatedStyle,
],
[thumb, thumbAnimatedStyle],
);
return (
<Pressable
onPress={handlePress}
disabled={disabled}
hitSlop={8}
accessibilityRole="switch"
accessibilityState={accessibilityState}
accessibilityLabel={accessibilityLabel}
testID={testID}
style={pressableStyle}
>
<Animated.View style={trackStyle}>
<Animated.View style={thumbStyle} />
</Animated.View>
</Pressable>
);
}
const styles = StyleSheet.create((theme) => ({
track: {
justifyContent: "center",
},
thumb: {
backgroundColor: theme.colors.palette.white,
shadowColor: "rgba(0, 0, 0, 0.25)",
shadowOffset: { width: 0, height: 1 },
shadowRadius: 2,
shadowOpacity: 1,
elevation: 2,
},
disabled: {
opacity: theme.opacity[50],
},
}));