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
+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],
},
}));