201 lines
6.6 KiB
TypeScript
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 },
|
|
});
|