diff --git a/app/session/[id].tsx b/app/session/[id].tsx index 9eccc5f..8f1a328 100644 --- a/app/session/[id].tsx +++ b/app/session/[id].tsx @@ -26,6 +26,7 @@ import { StatusIndicator, SlashPopover, ModelPicker, + VariantPicker, ImageAttachments, SessionInfo, type SlashCommand, @@ -78,6 +79,7 @@ export default function SessionScreen() { const flatListRef = useRef(null) const modelSheetRef = useRef(null) + const variantSheetRef = useRef(null) const [input, setInput] = useState("") const [attachments, setAttachments] = useState([]) const [showInfo, setShowInfo] = useState(false) @@ -115,6 +117,8 @@ export default function SessionScreen() { const agent = catalog.agent || "" const model = catalog.model const setModel = catalog.setModel + const variant = catalog.variant + const setVariant = catalog.setVariant const cycleAgent = catalog.cycleAgent // Permission & question state @@ -338,7 +342,7 @@ export default function SessionScreen() { // Messages are queued server-side when the session is busy. // No need to abort - just send and it will be processed after current response. try { - await sendMessage(text, model || undefined, agent || undefined, files) + await sendMessage(text, model || undefined, agent || undefined, files, variant || undefined) } catch (err) { console.error("Send failed:", err) // Restore the user's text and attachments so their input isn't lost. @@ -456,6 +460,14 @@ export default function SessionScreen() { const agentColor = currentAgent?.color || "#8b5cf6" const modelLabel = model?.modelID ? model.modelID.split("/").pop() || model.modelID : "default" + // Variants for current model (for reasoning effort picker) + const currentModelVariants = useMemo(() => { + if (!model) return undefined + const provider = providers.find((p) => p.id === model.providerID) + const found = provider?.models.find((m) => m.id === model.modelID) + return found?.variants + }, [model, providers]) + return ( <> + + {currentModelVariants && Object.keys(currentModelVariants).length > 0 && ( + variantSheetRef.current?.expand()} + > + + + {variant ? variant.charAt(0).toUpperCase() + variant.slice(1) : "Auto"} + + + )} {/* Attachment preview */} @@ -678,6 +702,15 @@ export default function SessionScreen() { isDark={isDark} onSelect={handleModelSelect} /> + + {/* Reasoning effort (variant) picker bottom sheet */} + ) } @@ -771,6 +804,21 @@ const s = StyleSheet.create({ modelChipDark: { backgroundColor: "#1a1a1a" }, modelLabel: { fontSize: 12, color: "#666666", maxWidth: 160 }, + // Variant (reasoning effort) chip + variantChip: { + flexDirection: "row", + alignItems: "center", + gap: 4, + backgroundColor: "#f5f5f5", + borderRadius: 12, + paddingHorizontal: 10, + paddingVertical: 4, + }, + variantChipDark: { backgroundColor: "#1a1a1a" }, + variantChipActive: { backgroundColor: "#f5f3ff" }, + variantLabel: { fontSize: 12, color: "#666666" }, + variantLabelActive: { color: "#8b5cf6" }, + // Input inputContainer: { padding: 12, diff --git a/src/components/chat/VariantPicker.tsx b/src/components/chat/VariantPicker.tsx new file mode 100644 index 0000000..9e416ae --- /dev/null +++ b/src/components/chat/VariantPicker.tsx @@ -0,0 +1,107 @@ +import { View, Text, TouchableOpacity, StyleSheet } from "react-native" +import { Ionicons } from "@expo/vector-icons" +import BottomSheet, { BottomSheetBackdrop, BottomSheetFlatList } from "@gorhom/bottom-sheet" + +interface VariantOption { + id: string | null + label: string + description: string +} + +interface Props { + variants: Record | undefined + selected: string | null + isDark: boolean + onSelect: (variant: string | null) => void + sheetRef: React.RefObject +} + +const AUTO_OPTION: VariantOption = { + id: null, + label: "Auto", + description: "Use model default reasoning", +} + +const EFFORT_DESCRIPTIONS: Record = { + low: "Faster, less thorough reasoning", + medium: "Balanced reasoning and speed", + high: "Deep, thorough reasoning", +} + +export function VariantPicker({ variants, selected, isDark, onSelect, sheetRef }: Props) { + const options: VariantOption[] = [ + AUTO_OPTION, + ...Object.keys(variants || {}).map((id) => ({ + id, + label: id.charAt(0).toUpperCase() + id.slice(1), + description: EFFORT_DESCRIPTIONS[id] ?? id, + })), + ] + + const handleSelect = (id: string | null) => { + onSelect(id) + sheetRef.current?.close() + } + + return ( + ( + + )} + > + + Reasoning Effort + + item.id ?? "auto"} + renderItem={({ item }: { item: VariantOption }) => { + const active = item.id === selected + return ( + handleSelect(item.id)} + > + + {item.label} + {item.description} + + {active && } + + ) + }} + contentContainerStyle={s.content} + /> + + ) +} + +const s = StyleSheet.create({ + sheet: { backgroundColor: "#ffffff" }, + sheetDark: { backgroundColor: "#1a1a1a" }, + header: { paddingHorizontal: 16, paddingBottom: 12 }, + title: { fontSize: 18, fontWeight: "700", color: "#0a0a0a" }, + textWhite: { color: "#ffffff" }, + metaDark: { color: "#666666" }, + content: { paddingBottom: 40 }, + row: { + flexDirection: "row", + alignItems: "center", + paddingHorizontal: 16, + paddingVertical: 14, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: "#e5e5e5", + }, + rowDark: { borderBottomColor: "#2a2a2a" }, + rowSelected: { backgroundColor: "#f5f3ff" }, + rowSelectedDark: { backgroundColor: "#1f1a2e" }, + rowText: { flex: 1 }, + rowName: { fontSize: 15, fontWeight: "600", color: "#0a0a0a" }, + rowDesc: { fontSize: 12, color: "#999999", marginTop: 2 }, +}) diff --git a/src/components/chat/index.ts b/src/components/chat/index.ts index 2710ff0..4df6472 100644 --- a/src/components/chat/index.ts +++ b/src/components/chat/index.ts @@ -7,6 +7,7 @@ export { QuestionPrompt } from "./QuestionPrompt" export { StatusIndicator } from "./StatusIndicator" export { SlashPopover, type SlashCommand } from "./SlashPopover" export { ModelPicker } from "./ModelPicker" +export { VariantPicker } from "./VariantPicker" export { ImageAttachments, type Attachment } from "./ImageAttachments" export { DirectorySwitcher } from "./DirectorySwitcher" export { DirectoryBrowserSheet } from "./DirectoryBrowserSheet" diff --git a/src/lib/sdk.ts b/src/lib/sdk.ts index 9266a91..b26571c 100644 --- a/src/lib/sdk.ts +++ b/src/lib/sdk.ts @@ -418,6 +418,7 @@ export function createClient(config: ClientConfig) { cost?: { input: number; output: number } limit: { context: number; output: number } status?: "alpha" | "beta" | "deprecated" | "active" + variants?: Record } > }> diff --git a/src/stores/catalog.ts b/src/stores/catalog.ts index 59bcf35..c3943cc 100644 --- a/src/stores/catalog.ts +++ b/src/stores/catalog.ts @@ -9,6 +9,7 @@ export interface ProviderModel { reasoning: boolean attachment: boolean limit?: { context: number; output: number } + variants?: Record } export interface Provider { @@ -23,6 +24,10 @@ interface ModelSelection { modelID: string } +function sameModel(left: ModelSelection | null, right: ModelSelection | null) { + return left?.providerID === right?.providerID && left?.modelID === right?.modelID +} + interface CatalogState { agents: Agent[] commands: Command[] @@ -31,12 +36,14 @@ interface CatalogState { // Current selections agent: string // agent name, e.g. "build" model: ModelSelection | null + variant: string | null // model variant for reasoning effort (e.g. "low", "medium", "high") loaded: boolean // Actions load: () => Promise setAgent: (name: string) => void setModel: (selection: ModelSelection | null) => void + setVariant: (variant: string | null) => void cycleAgent: (direction?: 1 | -1) => void } @@ -47,6 +54,7 @@ export const useCatalog = create((set, get) => ({ defaults: {}, agent: "", model: null, + variant: null, loaded: false, load: async () => { @@ -81,6 +89,7 @@ export const useCatalog = create((set, get) => ({ reasoning: m.reasoning ?? false, attachment: m.attachment ?? false, limit: m.limit, + variants: m.variants, })), })) .filter((p) => p.models.length > 0) @@ -104,17 +113,36 @@ export const useCatalog = create((set, get) => ({ agentModel: defaultAgent?.model || null, }) - set({ agents: visible, commands, providers, defaults, agent, model, loaded: true }) + set((state) => ({ + agents: visible, + commands, + providers, + defaults, + agent, + model, + variant: sameModel(state.model, model) ? state.variant : null, + loaded: true, + })) }, setAgent: (name) => { const match = get().agents.find((a) => a.name === name) if (!match) return const model = match.model || get().model - set({ agent: name, model }) + set((state) => ({ + agent: name, + model, + variant: sameModel(state.model, model) ? state.variant : null, + })) }, - setModel: (selection) => set({ model: selection }), + setModel: (selection) => + set((state) => ({ + model: selection, + variant: sameModel(state.model, selection) ? state.variant : null, + })), + + setVariant: (variant) => set({ variant }), cycleAgent: (direction = 1) => { const { agents, agent } = get() diff --git a/src/stores/sessions.ts b/src/stores/sessions.ts index aee117c..4f9b8dd 100644 --- a/src/stores/sessions.ts +++ b/src/stores/sessions.ts @@ -45,6 +45,7 @@ interface SessionsState { model?: { providerID: string; modelID: string }, agent?: string, files?: Array<{ uri: string; mime: string; filename?: string; base64?: string }>, + variant?: string, ) => Promise abortSession: () => Promise refreshMessages: () => Promise @@ -222,7 +223,7 @@ export const useSessions = create((set, get) => ({ } }, - sendMessage: async (text, model, agent, files) => { + sendMessage: async (text, model, agent, files, variant) => { const client = clientFor(get().currentSession?.directory) const session = get().currentSession if (!client || !session) { @@ -287,7 +288,7 @@ export const useSessions = create((set, get) => ({ } // Fire and forget - SSE events will update messages/parts/status in real-time - client.session.prompt(session.id, { parts: promptParts, model, agent }).catch((err) => { + client.session.prompt(session.id, { parts: promptParts, model, agent, variant }).catch((err) => { console.error("Failed to send message:", err) // The user may have switched sessions while this send was in flight. Clear // the sending flag for the session we actually sent to (keyed by id, safe),