Files
pi-mobile/apps/mobile/components/ModelSelector.tsx
T
2026-07-09 13:08:49 -05:00

201 lines
6.6 KiB
TypeScript

import { Ionicons } from "@expo/vector-icons";
import { useMemo, useState } from "react";
import {
ActivityIndicator,
FlatList,
Modal,
Pressable,
StyleSheet,
Text,
TextInput,
View,
} from "react-native";
import { colors, monoFont } from "@/lib/theme";
import type { ModelRef, ModelSummary } from "@/lib/types";
type ModelSelectorProps = {
models: ModelSummary[];
selected?: ModelRef;
loading?: boolean;
error?: string | null;
disabled?: boolean;
onSelect: (model: ModelRef) => void;
};
function labelFor(model?: ModelRef, models: ModelSummary[] = []) {
if (!model) {
return "Select model";
}
return models.find((entry) => entry.provider === model.provider && entry.id === model.id)?.id ?? model.id;
}
export function ModelSelector({
models,
selected,
loading,
error,
disabled,
onSelect,
}: ModelSelectorProps) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const filteredModels = useMemo(() => {
const needle = query.trim().toLowerCase();
if (!needle) {
return models;
}
return models.filter((model) =>
`${model.id} ${model.name ?? ""} ${model.providerDisplayName ?? model.provider}`
.toLowerCase()
.includes(needle),
);
}, [models, query]);
const selectedSummary = selected
? models.find((model) => model.provider === selected.provider && model.id === selected.id)
: undefined;
return (
<>
<Pressable
style={({ pressed }) => [styles.trigger, pressed && styles.pressed, disabled && styles.disabled]}
onPress={() => setOpen(true)}
disabled={disabled}
>
<View style={styles.triggerText}>
<Text style={styles.modelId} numberOfLines={1}>
{labelFor(selected, models)}
</Text>
<Text style={styles.provider} numberOfLines={1}>
{selectedSummary?.providerDisplayName ?? selected?.provider ?? "available models"}
</Text>
</View>
{loading ? (
<ActivityIndicator size="small" color={colors.textSecondary} />
) : (
<Ionicons name="chevron-down" size={16} color={colors.textMuted} />
)}
</Pressable>
<Modal visible={open} animationType="slide" transparent onRequestClose={() => setOpen(false)}>
<View style={styles.overlay}>
<View style={styles.sheet}>
<View style={styles.header}>
<Text style={styles.title}>Choose model</Text>
<Pressable onPress={() => setOpen(false)} hitSlop={10}>
<Ionicons name="close" size={22} color={colors.textSecondary} />
</Pressable>
</View>
<TextInput
value={query}
onChangeText={setQuery}
placeholder="Search models…"
placeholderTextColor={colors.textMuted}
style={styles.search}
autoCorrect={false}
autoCapitalize="none"
/>
{error ? <Text style={styles.error}>{error}</Text> : null}
{models.length === 0 ? (
<Text style={styles.empty}>{loading ? "Loading models…" : "No available models"}</Text>
) : (
<FlatList
data={filteredModels}
keyExtractor={(model) => `${model.provider}:${model.id}`}
keyboardShouldPersistTaps="handled"
renderItem={({ item }) => {
const isSelected =
item.provider === selected?.provider && item.id === selected?.id;
return (
<Pressable
style={({ pressed }) => [
styles.option,
pressed && styles.pressed,
isSelected && styles.selected,
]}
onPress={() => {
onSelect({ provider: item.provider, id: item.id });
setOpen(false);
}}
>
<View style={styles.optionText}>
<Text style={styles.optionId}>{item.id}</Text>
<Text style={styles.optionMeta}>
{item.providerDisplayName ?? item.provider}
{item.name && item.name !== item.id ? ` · ${item.name}` : ""}
</Text>
</View>
{isSelected ? (
<Ionicons name="checkmark" size={18} color={colors.accent} />
) : null}
</Pressable>
);
}}
ListEmptyComponent={<Text style={styles.empty}>No matching models</Text>}
/>
)}
</View>
</View>
</Modal>
</>
);
}
const styles = StyleSheet.create({
trigger: {
minHeight: 40,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
backgroundColor: colors.surfaceDeep,
borderRadius: 8,
borderWidth: 1,
borderColor: colors.border,
paddingHorizontal: 11,
paddingVertical: 7,
},
triggerText: { flex: 1, gap: 1 },
modelId: { color: colors.text, fontFamily: monoFont, fontSize: 13 },
provider: { color: colors.textMuted, fontFamily: monoFont, fontSize: 11 },
pressed: { backgroundColor: colors.surface },
disabled: { opacity: 0.45 },
overlay: { flex: 1, justifyContent: "flex-end", backgroundColor: "rgba(0, 0, 0, 0.65)" },
sheet: {
maxHeight: "78%",
backgroundColor: colors.surface,
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
borderWidth: 1,
borderColor: colors.border,
padding: 16,
gap: 12,
},
header: { flexDirection: "row", alignItems: "center", justifyContent: "space-between" },
title: { color: colors.text, fontSize: 17, fontWeight: "700" },
search: {
color: colors.text,
backgroundColor: colors.surfaceDeep,
borderWidth: 1,
borderColor: colors.border,
borderRadius: 8,
paddingHorizontal: 11,
paddingVertical: 9,
fontFamily: monoFont,
fontSize: 13,
},
option: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingVertical: 12,
paddingHorizontal: 10,
borderRadius: 8,
},
selected: { backgroundColor: "rgba(147, 197, 253, 0.12)" },
optionText: { flex: 1, gap: 2 },
optionId: { color: colors.text, fontFamily: monoFont, fontSize: 13 },
optionMeta: { color: colors.textMuted, fontFamily: monoFont, fontSize: 11 },
error: { color: colors.errorMuted, fontSize: 12 },
empty: { color: colors.textMuted, fontFamily: monoFont, fontSize: 13, paddingVertical: 20 },
});