Initial commit: OpenCode mobile app (extracted from opencode monorepo)

This commit is contained in:
Ubuntu
2026-05-17 19:52:54 +00:00
commit 1843a7c37e
84 changed files with 9303 additions and 0 deletions

110
src/components/AuthGate.tsx Normal file
View File

@@ -0,0 +1,110 @@
import { useEffect, type ReactNode } from "react"
import { View, Text, TouchableOpacity, StyleSheet, useColorScheme } from "react-native"
import { Ionicons } from "@expo/vector-icons"
import { useAuth } from "../stores/auth"
interface Props {
children: ReactNode
}
export function AuthGate({ children }: Props) {
const colorScheme = useColorScheme()
const isDark = colorScheme === "dark"
const { isAuthenticated, settings, hasBiometrics, biometricType, authenticate, error } = useAuth()
// If biometric not required, or no biometrics available, show children
if (!settings.requireBiometric || !hasBiometrics) {
return <>{children}</>
}
// If authenticated, show children
if (isAuthenticated) {
return <>{children}</>
}
// Show auth screen
const iconName =
biometricType === 1 // FINGERPRINT
? "finger-print"
: biometricType === 2 // FACIAL_RECOGNITION
? "scan"
: "lock-closed"
return (
<View style={[styles.container, isDark && styles.containerDark]}>
<View style={styles.content}>
<Ionicons name={iconName} size={64} color={isDark ? "#ffffff" : "#0a0a0a"} />
<Text style={[styles.title, isDark && styles.textDark]}>OpenCode Locked</Text>
<Text style={[styles.subtitle, isDark && styles.subtitleDark]}>Authenticate to access your sessions</Text>
{error && <Text style={styles.error}>{error}</Text>}
<TouchableOpacity style={[styles.button, isDark && styles.buttonDark]} onPress={authenticate}>
<Ionicons name={iconName} size={24} color={isDark ? "#0a0a0a" : "#ffffff"} />
<Text style={[styles.buttonText, isDark && styles.buttonTextDark]}>Unlock</Text>
</TouchableOpacity>
</View>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#ffffff",
justifyContent: "center",
alignItems: "center",
},
containerDark: {
backgroundColor: "#0a0a0a",
},
content: {
alignItems: "center",
padding: 32,
},
title: {
fontSize: 24,
fontWeight: "600",
marginTop: 24,
color: "#0a0a0a",
},
textDark: {
color: "#ffffff",
},
subtitle: {
fontSize: 16,
color: "#666666",
marginTop: 8,
textAlign: "center",
},
subtitleDark: {
color: "#888888",
},
error: {
color: "#ef4444",
marginTop: 16,
fontSize: 14,
},
button: {
flexDirection: "row",
alignItems: "center",
backgroundColor: "#0a0a0a",
paddingHorizontal: 32,
paddingVertical: 16,
borderRadius: 12,
marginTop: 32,
gap: 12,
},
buttonDark: {
backgroundColor: "#ffffff",
},
buttonText: {
color: "#ffffff",
fontSize: 18,
fontWeight: "600",
},
buttonTextDark: {
color: "#0a0a0a",
},
})

View File

@@ -0,0 +1,147 @@
import { View, Text, StyleSheet, Platform } from "react-native"
const mono = Platform.OS === "ios" ? "Menlo" : "monospace"
interface Props {
before: string
after: string
isDark: boolean
}
interface DiffLine {
type: "add" | "remove" | "context"
text: string
}
function computeDiff(before: string, after: string): DiffLine[] {
const a = before.split("\n")
const b = after.split("\n")
const lines: DiffLine[] = []
// Simple LCS-based diff
const m = a.length
const n = b.length
const dp: number[][] = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0))
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
dp[i][j] = a[i - 1] === b[j - 1] ? dp[i - 1][j - 1] + 1 : Math.max(dp[i - 1][j], dp[i][j - 1])
}
}
// Backtrack
const result: DiffLine[] = []
let i = m
let j = n
while (i > 0 || j > 0) {
if (i > 0 && j > 0 && a[i - 1] === b[j - 1]) {
result.push({ type: "context", text: a[i - 1] })
i--
j--
} else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
result.push({ type: "add", text: b[j - 1] })
j--
} else {
result.push({ type: "remove", text: a[i - 1] })
i--
}
}
result.reverse()
// Collapse long context runs (show max 3 context lines between changes)
const collapsed: DiffLine[] = []
let contextRun = 0
for (const line of result) {
if (line.type === "context") {
contextRun++
if (contextRun <= 3) {
collapsed.push(line)
} else if (contextRun === 4) {
collapsed.push({ type: "context", text: "..." })
}
} else {
contextRun = 0
collapsed.push(line)
}
}
return collapsed
}
export function DiffView({ before, after, isDark }: Props) {
const lines = computeDiff(before, after)
if (lines.length === 0) return null
return (
<View style={[s.container, isDark && s.containerDark]}>
{lines.map((line, idx) => (
<View
key={idx}
style={[
s.line,
line.type === "add" && (isDark ? s.addDark : s.add),
line.type === "remove" && (isDark ? s.removeDark : s.remove),
]}
>
<Text style={[s.prefix, isDark && s.prefixDark]}>
{line.type === "add" ? "+" : line.type === "remove" ? "-" : " "}
</Text>
<Text
style={[
s.text,
isDark && s.textDark,
line.type === "add" && s.addText,
line.type === "remove" && s.removeText,
]}
selectable
numberOfLines={1}
>
{line.text}
</Text>
</View>
))}
</View>
)
}
const s = StyleSheet.create({
container: {
borderRadius: 6,
overflow: "hidden",
backgroundColor: "#f8f8f8",
marginTop: 6,
},
containerDark: { backgroundColor: "#1a1a1a" },
line: {
flexDirection: "row",
paddingHorizontal: 8,
paddingVertical: 1,
},
add: { backgroundColor: "#dcfce7" },
addDark: { backgroundColor: "#052e16" },
remove: { backgroundColor: "#fee2e2" },
removeDark: { backgroundColor: "#2a0a0a" },
prefix: {
width: 16,
fontSize: 12,
fontFamily: mono,
color: "#999999",
lineHeight: 20,
},
prefixDark: { color: "#666666" },
text: {
flex: 1,
fontSize: 12,
fontFamily: mono,
color: "#0a0a0a",
lineHeight: 20,
},
textDark: { color: "#e5e5e5" },
addText: { color: "#16a34a" },
removeText: { color: "#dc2626" },
})

View File

@@ -0,0 +1,246 @@
import { useState, useCallback, useMemo } from "react"
import { View, Text, TouchableOpacity, StyleSheet } from "react-native"
import { Ionicons } from "@expo/vector-icons"
import BottomSheet, { BottomSheetBackdrop, BottomSheetFlatList, BottomSheetTextInput } from "@gorhom/bottom-sheet"
interface Props {
sheetRef: React.RefObject<BottomSheet | null>
current?: string
recents: string[]
serverHome: string | null
isDark: boolean
onSwitch: (directory?: string) => void
}
export function DirectorySwitcher({ sheetRef, current, recents, serverHome, isDark, onSwitch }: Props) {
const [custom, setCustom] = useState("")
const handleSelect = useCallback(
(dir?: string) => {
onSwitch(dir)
setCustom("")
sheetRef.current?.close()
},
[onSwitch, sheetRef],
)
const handleCustomSubmit = useCallback(() => {
const dir = custom.trim()
if (!dir) return
handleSelect(dir)
}, [custom, handleSelect])
// Build list: server default + recents (excluding current)
const items = useMemo(() => {
const list: Array<{ label: string; dir?: string; active: boolean }> = [
{ label: "Server Default", dir: undefined, active: !current },
]
for (const dir of recents) {
if (dir === current) continue
const short = dir.split("/").filter(Boolean).pop() || dir
list.push({ label: short, dir, active: false })
}
return list
}, [recents, current])
const shortCurrent = current ? current.split("/").filter(Boolean).pop() || current : null
return (
<BottomSheet
ref={sheetRef}
index={-1}
snapPoints={["45%", "70%"]}
enablePanDownToClose
keyboardBehavior="interactive"
keyboardBlurBehavior="restore"
android_keyboardInputMode="adjustResize"
backgroundStyle={isDark ? s.sheetDark : s.sheet}
handleIndicatorStyle={{ backgroundColor: isDark ? "#666666" : "#cccccc" }}
backdropComponent={(props) => (
<BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.5} />
)}
onChange={(idx) => {
if (idx === -1) setCustom("")
}}
>
<View style={s.header}>
<Text style={[s.title, isDark && s.white]}>Switch Project</Text>
{shortCurrent && (
<View style={s.current}>
<Ionicons name="folder" size={14} color="#8b5cf6" />
<Text style={s.currentText} numberOfLines={1}>
{shortCurrent}
</Text>
</View>
)}
</View>
{/* Custom directory input */}
<View style={s.inputWrap}>
<BottomSheetTextInput
style={[s.input, isDark && s.inputDark]}
placeholder={serverHome ? `${serverHome}/...` : "/path/to/project"}
placeholderTextColor={isDark ? "#666666" : "#999999"}
value={custom}
onChangeText={(text) => {
if (serverHome && text === "~") setCustom(serverHome)
else if (serverHome && text.startsWith("~/")) setCustom(serverHome + text.slice(1))
else setCustom(text)
}}
onSubmitEditing={handleCustomSubmit}
returnKeyType="go"
autoCapitalize="none"
autoCorrect={false}
/>
{custom.trim() && (
<TouchableOpacity style={[s.goBtn, isDark && s.goBtnDark]} onPress={handleCustomSubmit}>
<Ionicons name="arrow-forward" size={18} color={isDark ? "#0a0a0a" : "#ffffff"} />
</TouchableOpacity>
)}
</View>
{/* Quick path chips */}
{serverHome && (
<View style={s.chips}>
<TouchableOpacity style={[s.chip, isDark && s.chipDark]} onPress={() => setCustom(serverHome)}>
<Text style={[s.chipText, isDark && s.chipTextDark]}>~</Text>
</TouchableOpacity>
<TouchableOpacity style={[s.chip, isDark && s.chipDark]} onPress={() => setCustom(serverHome + "/")}>
<Text style={[s.chipText, isDark && s.chipTextDark]}>~/</Text>
</TouchableOpacity>
</View>
)}
{/* Recent directories */}
<BottomSheetFlatList
data={items}
keyExtractor={(item: (typeof items)[number], i: number) => item.dir || `default-${i}`}
renderItem={({ item }: { item: (typeof items)[number] }) => (
<TouchableOpacity
style={[s.row, isDark && s.rowDark, item.active && s.rowActive]}
onPress={() => handleSelect(item.dir)}
>
<View style={s.rowIcon}>
<Ionicons
name={item.dir ? "folder-outline" : "server-outline"}
size={20}
color={item.active ? "#8b5cf6" : isDark ? "#888888" : "#666666"}
/>
</View>
<View style={s.rowContent}>
<Text style={[s.rowLabel, isDark && s.white, item.active && s.rowLabelActive]} numberOfLines={1}>
{item.label}
</Text>
{item.dir && (
<Text style={[s.rowPath, isDark && s.dimDark]} numberOfLines={1}>
{item.dir}
</Text>
)}
{!item.dir && <Text style={[s.rowPath, isDark && s.dimDark]}>Uses server's working directory</Text>}
</View>
{item.active && <Ionicons name="checkmark-circle" size={20} color="#8b5cf6" />}
</TouchableOpacity>
)}
contentContainerStyle={s.list}
ListHeaderComponent={
items.length > 1 ? <Text style={[s.section, isDark && s.dimDark]}>Recent Projects</Text> : null
}
/>
</BottomSheet>
)
}
const s = StyleSheet.create({
sheet: { backgroundColor: "#ffffff" },
sheetDark: { backgroundColor: "#1a1a1a" },
header: { paddingHorizontal: 16, paddingBottom: 8, gap: 6 },
title: { fontSize: 18, fontWeight: "700", color: "#0a0a0a" },
white: { color: "#ffffff" },
current: {
flexDirection: "row",
alignItems: "center",
gap: 6,
},
currentText: {
fontSize: 13,
color: "#8b5cf6",
fontWeight: "500",
},
inputWrap: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 16,
paddingBottom: 8,
gap: 8,
},
chips: {
flexDirection: "row",
gap: 8,
paddingHorizontal: 16,
paddingBottom: 12,
},
chip: {
paddingHorizontal: 12,
paddingVertical: 6,
backgroundColor: "#e8e5f0",
borderRadius: 16,
},
chipDark: {
backgroundColor: "#2a2040",
},
chipText: {
fontSize: 13,
fontWeight: "600",
color: "#6d28d9",
},
chipTextDark: {
color: "#c4b5fd",
},
input: {
flex: 1,
backgroundColor: "#f5f5f5",
borderRadius: 10,
paddingHorizontal: 14,
paddingVertical: 10,
fontSize: 15,
color: "#0a0a0a",
},
inputDark: { backgroundColor: "#2a2a2a", color: "#ffffff" },
goBtn: {
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: "#0a0a0a",
justifyContent: "center",
alignItems: "center",
},
goBtnDark: { backgroundColor: "#ffffff" },
list: { paddingBottom: 40 },
section: {
fontSize: 12,
fontWeight: "700",
color: "#999999",
textTransform: "uppercase",
letterSpacing: 0.5,
paddingHorizontal: 16,
paddingTop: 4,
paddingBottom: 8,
},
dimDark: { color: "#666666" },
row: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 16,
paddingVertical: 12,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: "#e5e5e5",
gap: 12,
},
rowDark: { borderBottomColor: "#2a2a2a" },
rowActive: { backgroundColor: "#f5f3ff" },
rowIcon: { width: 28, alignItems: "center" },
rowContent: { flex: 1 },
rowLabel: { fontSize: 15, fontWeight: "500", color: "#0a0a0a" },
rowLabelActive: { color: "#8b5cf6" },
rowPath: { fontSize: 12, color: "#999999", marginTop: 1 },
})

View File

@@ -0,0 +1,82 @@
import { View, Text, Image, TouchableOpacity, StyleSheet, ScrollView } from "react-native"
import { Ionicons } from "@expo/vector-icons"
export interface Attachment {
uri: string
mime: string
filename?: string
width?: number
height?: number
base64?: string
}
interface Props {
attachments: Attachment[]
isDark: boolean
onRemove: (index: number) => void
}
export function ImageAttachments({ attachments, isDark, onRemove }: Props) {
if (attachments.length === 0) return null
return (
<View style={[s.container, isDark && s.containerDark]}>
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={s.scroll}>
{attachments.map((att, idx) => (
<View key={`${att.uri}-${idx}`} style={s.thumb}>
<Image source={{ uri: att.uri }} style={s.image} resizeMode="cover" />
<TouchableOpacity style={[s.remove, isDark && s.removeDark]} onPress={() => onRemove(idx)}>
<Ionicons name="close" size={14} color="#ffffff" />
</TouchableOpacity>
{att.filename && (
<Text style={[s.label, isDark && s.labelDark]} numberOfLines={1}>
{att.filename}
</Text>
)}
</View>
))}
</ScrollView>
</View>
)
}
const s = StyleSheet.create({
container: {
paddingHorizontal: 12,
paddingVertical: 8,
borderTopWidth: 1,
borderTopColor: "#e5e5e5",
backgroundColor: "#ffffff",
},
containerDark: { backgroundColor: "#0a0a0a", borderTopColor: "#1a1a1a" },
scroll: { gap: 8 },
thumb: { position: "relative" },
image: {
width: 72,
height: 72,
borderRadius: 8,
backgroundColor: "#f0f0f0",
},
remove: {
position: "absolute",
top: -6,
right: -6,
width: 22,
height: 22,
borderRadius: 11,
backgroundColor: "#0a0a0a",
justifyContent: "center",
alignItems: "center",
borderWidth: 2,
borderColor: "#ffffff",
},
removeDark: { backgroundColor: "#ef4444", borderColor: "#0a0a0a" },
label: {
fontSize: 10,
color: "#666666",
marginTop: 2,
maxWidth: 72,
textAlign: "center",
},
labelDark: { color: "#888888" },
})

View File

@@ -0,0 +1,162 @@
import { memo } from "react"
import { View, Text, Image, StyleSheet, ScrollView, TouchableOpacity, Dimensions } from "react-native"
import { Ionicons } from "@expo/vector-icons"
import { Markdown } from "../markdown"
import { ToolCallCard } from "./ToolCallCard"
import { ReasoningBlock } from "./ReasoningBlock"
import type { Message, Part } from "../../lib/sdk"
const SCREEN_WIDTH = Dimensions.get("window").width
function isImageMime(mime?: string): boolean {
return !!mime && mime.startsWith("image/")
}
interface Props {
message: Message
parts: Part[]
isDark: boolean
}
// TODO: Replace with streamdown-rn once React 19 types PR lands - it has
// built-in block-level memoization that eliminates re-renders for stable blocks
export const MessageBubble = memo(
function MessageBubble({ message, parts, isDark }: Props) {
const isUser = message.role === "user"
const textParts = parts.filter((p) => p.type === "text")
const reasoningParts = parts.filter((p) => p.type === "reasoning")
const toolParts = parts.filter((p) => p.type === "tool")
const fileParts = parts.filter((p) => p.type === "file" && isImageMime(p.mime))
const text = textParts.map((p) => p.text).join("\n") || ""
const reasoning = reasoningParts.map((p) => p.text).join("\n") || ""
return (
<View
style={[
s.bubble,
isUser ? s.user : s.assistant,
isUser && isDark && s.userDark,
!isUser && isDark && s.assistantDark,
]}
>
{/* Role indicator */}
<View style={s.header}>
<Ionicons
name={isUser ? "person" : "sparkles"}
size={14}
color={isUser ? (isDark ? "#ffffff" : "#0a0a0a") : "#8b5cf6"}
/>
<Text style={[s.role, isUser && s.roleUser, isDark && s.textWhite]}>{isUser ? "You" : "Assistant"}</Text>
{message.model && <Text style={[s.modelTag, isDark && s.modelTagDark]}>{message.model.modelID}</Text>}
{!isUser && message.modelID && <Text style={[s.modelTag, isDark && s.modelTagDark]}>{message.modelID}</Text>}
</View>
{/* Image attachments */}
{fileParts.length > 0 && (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={s.imageRow}
style={s.imageScroll}
>
{fileParts.map((fp) => (
<View key={fp.id} style={s.imageWrap}>
<Image source={{ uri: fp.url }} style={s.attachedImage} resizeMode="cover" />
{fp.filename && (
<Text style={[s.imageLabel, isDark && s.imageLabelDark]} numberOfLines={1}>
{fp.filename}
</Text>
)}
</View>
))}
</ScrollView>
)}
{/* Reasoning (collapsible) */}
{reasoning.length > 0 && <ReasoningBlock text={reasoning} isDark={isDark} />}
{/* Message text */}
{text.length > 0 &&
(isUser ? (
<Text style={[s.messageText, isDark && s.textWhite]} selectable>
{text}
</Text>
) : (
<View style={s.markdownWrap}>
<Markdown>{text}</Markdown>
</View>
))}
{/* Tool calls */}
{toolParts.map((tool) => (
<ToolCallCard key={tool.id} tool={tool} isDark={isDark} />
))}
{/* Tokens/cost for assistant messages */}
{!isUser && message.tokens && (
<Text style={[s.tokens, isDark && s.tokensDark]}>
{message.tokens.input + message.tokens.output} tokens
{message.cost ? ` · $${message.cost.toFixed(4)}` : ""}
</Text>
)}
</View>
)
},
(prev, next) => {
// Only re-render if message content actually changed
// This prevents completed messages from re-rendering during streaming
if (prev.message.id !== next.message.id) return false
if (prev.isDark !== next.isDark) return false
if (prev.parts.length !== next.parts.length) return false
// Compare the last part's text content - this is what changes during streaming
const prevLast = prev.parts[prev.parts.length - 1]
const nextLast = next.parts[next.parts.length - 1]
if (!prevLast && !nextLast) return true
if (!prevLast || !nextLast) return false
return prevLast.type === nextLast.type && prevLast.text === nextLast.text
},
)
const s = StyleSheet.create({
bubble: { marginBottom: 16, padding: 12, borderRadius: 12, maxWidth: "100%" },
user: { backgroundColor: "#f5f5f5", marginLeft: 32 },
userDark: { backgroundColor: "#1a1a1a" },
assistant: { backgroundColor: "#f0f0ff" },
assistantDark: { backgroundColor: "#1a1a2e" },
header: { flexDirection: "row", alignItems: "center", gap: 6, marginBottom: 8 },
role: { fontSize: 13, fontWeight: "600", color: "#666666" },
roleUser: { color: "#0a0a0a" },
textWhite: { color: "#ffffff" },
modelTag: {
fontSize: 11,
color: "#999999",
backgroundColor: "#e5e5e5",
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 4,
overflow: "hidden",
},
modelTagDark: { backgroundColor: "#2a2a2a", color: "#888888" },
messageText: { fontSize: 15, lineHeight: 22, color: "#0a0a0a" },
markdownWrap: { marginHorizontal: -4 },
tokens: { fontSize: 11, color: "#999999", marginTop: 8 },
tokensDark: { color: "#666666" },
// Images
imageScroll: { marginBottom: 8 },
imageRow: { gap: 8 },
imageWrap: { alignItems: "center" },
attachedImage: {
width: Math.min(200, SCREEN_WIDTH * 0.5),
height: Math.min(200, SCREEN_WIDTH * 0.5),
borderRadius: 8,
backgroundColor: "#e5e5e5",
},
imageLabel: { fontSize: 10, color: "#666666", marginTop: 2, maxWidth: 200 },
imageLabelDark: { color: "#888888" },
})

View File

@@ -0,0 +1,186 @@
import { useState, useCallback, useMemo, useRef } from "react"
import { View, Text, TouchableOpacity, StyleSheet } from "react-native"
import { Ionicons } from "@expo/vector-icons"
import BottomSheet, { BottomSheetBackdrop, BottomSheetSectionList, BottomSheetTextInput } from "@gorhom/bottom-sheet"
interface ModelItem {
providerID: string
providerName: string
modelID: string
modelName: string
}
interface Provider {
id: string
name: string
models: Array<{ id: string; name: string }>
}
interface Props {
providers: Provider[]
selected: { providerID: string; modelID: string } | null
isDark: boolean
onSelect: (providerID: string, modelID: string) => void
sheetRef: React.RefObject<BottomSheet | null>
}
export function ModelPicker({ providers, selected, isDark, onSelect, sheetRef }: Props) {
const [search, setSearch] = useState("")
const sections = useMemo(() => {
const list = Array.isArray(providers) ? providers : []
const q = search.toLowerCase()
const result = list
.map((p) => {
const models = (p.models || [])
.filter(
(m) =>
!q ||
m.id.toLowerCase().includes(q) ||
m.name.toLowerCase().includes(q) ||
p.name.toLowerCase().includes(q),
)
.map((m) => ({
providerID: p.id,
providerName: p.name || p.id,
modelID: m.id,
modelName: m.name || m.id,
}))
// Sort selected model to top within its group
if (selected) {
models.sort((a, b) => {
const aActive = a.providerID === selected.providerID && a.modelID === selected.modelID
const bActive = b.providerID === selected.providerID && b.modelID === selected.modelID
return aActive === bActive ? 0 : aActive ? -1 : 1
})
}
return { title: p.name || p.id, data: models }
})
.filter((s) => s.data.length > 0)
// Sort the section containing the selected model to the top
if (selected) {
result.sort((a, b) => {
const aHas = a.data.some((m) => m.providerID === selected.providerID && m.modelID === selected.modelID)
const bHas = b.data.some((m) => m.providerID === selected.providerID && m.modelID === selected.modelID)
return aHas === bHas ? 0 : aHas ? -1 : 1
})
}
return result
}, [providers, search, selected])
const handleSelect = useCallback(
(providerID: string, modelID: string) => {
onSelect(providerID, modelID)
setSearch("")
sheetRef.current?.close()
},
[onSelect, sheetRef],
)
return (
<BottomSheet
ref={sheetRef}
index={-1}
snapPoints={["50%", "80%"]}
enablePanDownToClose
keyboardBehavior="interactive"
keyboardBlurBehavior="restore"
android_keyboardInputMode="adjustResize"
backgroundStyle={isDark ? s.sheetDark : s.sheet}
handleIndicatorStyle={{ backgroundColor: isDark ? "#666666" : "#cccccc" }}
backdropComponent={(props) => (
<BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.5} />
)}
onChange={(idx) => {
if (idx === -1) setSearch("")
}}
>
<View style={s.header}>
<Text style={[s.title, isDark && s.textWhite]}>Select Model</Text>
<BottomSheetTextInput
style={[s.search, isDark && s.searchDark]}
placeholder="Search models..."
placeholderTextColor={isDark ? "#666666" : "#999999"}
value={search}
onChangeText={setSearch}
autoCorrect={false}
autoCapitalize="none"
/>
</View>
<BottomSheetSectionList
sections={sections}
keyExtractor={(item: ModelItem) => `${item.providerID}/${item.modelID}`}
renderSectionHeader={({ section }: { section: { title: string } }) => (
<View style={[s.sectionHeader, isDark && s.sectionHeaderDark]}>
<Text style={[s.sectionTitle, isDark && s.metaDark]}>{section.title}</Text>
</View>
)}
renderItem={({ item }: { item: ModelItem }) => {
const active = selected?.providerID === item.providerID && selected?.modelID === item.modelID
return (
<TouchableOpacity
style={[s.row, isDark && s.rowDark, active && (isDark ? s.rowSelectedDark : s.rowSelected)]}
onPress={() => handleSelect(item.providerID, item.modelID)}
>
<View style={s.rowText}>
<Text style={[s.rowName, isDark && s.textWhite]} numberOfLines={1}>
{item.modelName || item.modelID}
</Text>
<Text style={[s.rowProvider, isDark && s.metaDark]}>{item.providerName || item.providerID}</Text>
</View>
{active && <Ionicons name="checkmark-circle" size={20} color="#8b5cf6" />}
</TouchableOpacity>
)
}}
contentContainerStyle={s.content}
stickySectionHeadersEnabled
/>
</BottomSheet>
)
}
const s = StyleSheet.create({
sheet: { backgroundColor: "#ffffff" },
sheetDark: { backgroundColor: "#1a1a1a" },
header: { paddingHorizontal: 16, paddingBottom: 12, gap: 10 },
title: { fontSize: 18, fontWeight: "700", color: "#0a0a0a" },
textWhite: { color: "#ffffff" },
search: {
backgroundColor: "#f5f5f5",
borderRadius: 10,
paddingHorizontal: 14,
paddingVertical: 10,
fontSize: 15,
color: "#0a0a0a",
},
searchDark: { backgroundColor: "#2a2a2a", color: "#ffffff" },
content: { paddingBottom: 40 },
sectionHeader: {
backgroundColor: "#f5f5f5",
paddingHorizontal: 16,
paddingVertical: 8,
},
sectionHeaderDark: { backgroundColor: "#111111" },
sectionTitle: {
fontSize: 12,
fontWeight: "700",
color: "#999999",
textTransform: "uppercase",
letterSpacing: 0.5,
},
metaDark: { color: "#666666" },
row: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 16,
paddingVertical: 12,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: "#e5e5e5",
},
rowDark: { borderBottomColor: "#2a2a2a" },
rowSelected: { backgroundColor: "#f5f3ff" },
rowSelectedDark: { backgroundColor: "#1f1a2e" },
rowText: { flex: 1 },
rowName: { fontSize: 15, fontWeight: "500", color: "#0a0a0a" },
rowProvider: { fontSize: 12, color: "#999999", marginTop: 1 },
})

View File

@@ -0,0 +1,61 @@
import { View, Text, TouchableOpacity, StyleSheet } from "react-native"
import { Ionicons } from "@expo/vector-icons"
interface Props {
permission: { id: string; permission: string; patterns: string[] }
isDark: boolean
onReply: (reply: "once" | "always" | "reject") => void
}
export function PermissionPrompt({ permission, isDark, onReply }: Props) {
return (
<View style={[s.card, isDark && s.cardDark]}>
<View style={s.header}>
<Ionicons name="shield-outline" size={18} color="#f59e0b" />
<Text style={[s.title, isDark && s.textWhite]}>Permission Required</Text>
</View>
<Text style={[s.type, isDark && s.typeDark]}>
{permission.permission}: {permission.patterns.join(", ")}
</Text>
<View style={s.actions}>
<TouchableOpacity style={[s.btn, s.deny]} onPress={() => onReply("reject")}>
<Text style={s.denyText}>Deny</Text>
</TouchableOpacity>
<TouchableOpacity style={[s.btn, s.always, isDark && s.alwaysDark]} onPress={() => onReply("always")}>
<Text style={[s.alwaysText, isDark && s.textWhite]}>Always</Text>
</TouchableOpacity>
<TouchableOpacity style={[s.btn, s.allow, isDark && s.allowDark]} onPress={() => onReply("once")}>
<Text style={[s.allowText, isDark && s.allowTextDark]}>Allow</Text>
</TouchableOpacity>
</View>
</View>
)
}
const s = StyleSheet.create({
card: {
margin: 12,
padding: 16,
backgroundColor: "#fffbeb",
borderRadius: 12,
borderWidth: 1,
borderColor: "#fef3c7",
},
cardDark: { backgroundColor: "#1a1800", borderColor: "#333300" },
header: { flexDirection: "row", alignItems: "center", gap: 8, marginBottom: 8 },
title: { fontSize: 15, fontWeight: "600", color: "#92400e" },
textWhite: { color: "#ffffff" },
type: { fontSize: 13, color: "#78350f", marginBottom: 12 },
typeDark: { color: "#d4a574" },
actions: { flexDirection: "row", gap: 8 },
btn: { flex: 1, paddingVertical: 10, borderRadius: 8, alignItems: "center" },
deny: { backgroundColor: "#fef2f2" },
denyText: { color: "#dc2626", fontWeight: "600", fontSize: 14 },
always: { backgroundColor: "#f5f5f5" },
alwaysDark: { backgroundColor: "#2a2a2a" },
alwaysText: { color: "#0a0a0a", fontWeight: "600", fontSize: 14 },
allow: { backgroundColor: "#0a0a0a" },
allowDark: { backgroundColor: "#ffffff" },
allowText: { color: "#ffffff", fontWeight: "600", fontSize: 14 },
allowTextDark: { color: "#0a0a0a" },
})

View File

@@ -0,0 +1,190 @@
import { useState } from "react"
import { View, Text, TextInput, TouchableOpacity, StyleSheet } from "react-native"
import { Ionicons } from "@expo/vector-icons"
interface QuestionOption {
label: string
description: string
}
interface Question {
question: string
header: string
options: QuestionOption[]
multiple?: boolean
custom?: boolean
}
interface Props {
request: {
id: string
questions: Question[]
}
isDark: boolean
onReply: (answers: string[][]) => void
onReject: () => void
}
export function QuestionPrompt({ request, isDark, onReply, onReject }: Props) {
const [answers, setAnswers] = useState<string[][]>(request.questions.map(() => []))
const [custom, setCustom] = useState("")
const [showCustom, setShowCustom] = useState(false)
const [current, setCurrent] = useState(0)
const q = request.questions[current]
if (!q) return null
const toggleOption = (label: string) => {
setAnswers((prev) => {
const copy = [...prev]
const selected = copy[current] || []
if (q.multiple) {
copy[current] = selected.includes(label) ? selected.filter((a) => a !== label) : [...selected, label]
} else {
copy[current] = [label]
if (request.questions.length === 1) {
setTimeout(() => onReply(copy), 100)
}
}
return copy
})
}
const submitCustom = () => {
if (!custom.trim()) return
const copy = [...answers]
copy[current] = [custom.trim()]
setAnswers(copy)
setCustom("")
setShowCustom(false)
if (request.questions.length === 1) {
onReply(copy)
}
}
return (
<View style={[s.card, isDark && s.cardDark]}>
<View style={s.header}>
<Ionicons name="chatbubble-ellipses-outline" size={18} color="#8b5cf6" />
<Text style={[s.title, isDark && s.textWhite]}>{q.header || "Question"}</Text>
</View>
<Text style={[s.question, isDark && s.textWhite]}>{q.question}</Text>
<View style={s.options}>
{q.options.map((opt) => {
const selected = (answers[current] || []).includes(opt.label)
return (
<TouchableOpacity
key={opt.label}
style={[
s.option,
isDark && s.optionDark,
selected && s.optionSelected,
selected && isDark && s.optionSelectedDark,
]}
onPress={() => toggleOption(opt.label)}
>
<Text style={[s.optionLabel, isDark && s.textWhite, selected && s.optionLabelSelected]}>{opt.label}</Text>
{opt.description ? <Text style={[s.optionDesc, isDark && s.metaDark]}>{opt.description}</Text> : null}
</TouchableOpacity>
)
})}
{q.custom !== false &&
(showCustom ? (
<View style={s.customRow}>
<TextInput
style={[s.customInput, isDark && s.customInputDark]}
placeholder="Type your answer..."
placeholderTextColor={isDark ? "#666666" : "#999999"}
value={custom}
onChangeText={setCustom}
onSubmitEditing={submitCustom}
autoFocus
/>
<TouchableOpacity onPress={submitCustom} style={s.customSubmit}>
<Ionicons name="send" size={18} color="#8b5cf6" />
</TouchableOpacity>
</View>
) : (
<TouchableOpacity style={[s.option, isDark && s.optionDark]} onPress={() => setShowCustom(true)}>
<Text style={[s.optionLabel, { color: "#8b5cf6" }]}>Type your own answer</Text>
</TouchableOpacity>
))}
</View>
<View style={s.footer}>
<TouchableOpacity onPress={onReject}>
<Text style={[s.dismiss, isDark && s.metaDark]}>Dismiss</Text>
</TouchableOpacity>
{(request.questions.length > 1 || q.multiple) && (
<TouchableOpacity
style={[s.submitBtn, isDark && s.submitBtnDark]}
onPress={() => {
if (current < request.questions.length - 1) {
setCurrent(current + 1)
} else {
onReply(answers)
}
}}
>
<Text style={s.submitText}>{current < request.questions.length - 1 ? "Next" : "Submit"}</Text>
</TouchableOpacity>
)}
</View>
</View>
)
}
const s = StyleSheet.create({
card: {
margin: 12,
padding: 16,
backgroundColor: "#f5f3ff",
borderRadius: 12,
borderWidth: 1,
borderColor: "#ede9fe",
},
cardDark: { backgroundColor: "#1a1a2e", borderColor: "#2a2a3e" },
header: { flexDirection: "row", alignItems: "center", gap: 8, marginBottom: 8 },
title: { fontSize: 15, fontWeight: "600", color: "#6d28d9" },
textWhite: { color: "#ffffff" },
question: { fontSize: 14, lineHeight: 20, color: "#0a0a0a", marginBottom: 12 },
metaDark: { color: "#666666" },
options: { gap: 8 },
option: {
padding: 12,
borderRadius: 8,
backgroundColor: "#ffffff",
borderWidth: 1,
borderColor: "#e5e5e5",
},
optionDark: { backgroundColor: "#2a2a2a", borderColor: "#3a3a3a" },
optionSelected: { borderColor: "#8b5cf6", backgroundColor: "#f5f3ff" },
optionSelectedDark: { borderColor: "#8b5cf6", backgroundColor: "#2a1a3e" },
optionLabel: { fontSize: 14, fontWeight: "600", color: "#0a0a0a" },
optionLabelSelected: { color: "#6d28d9" },
optionDesc: { fontSize: 12, color: "#666666", marginTop: 2 },
customRow: { flexDirection: "row", gap: 8 },
customInput: {
flex: 1,
backgroundColor: "#ffffff",
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 10,
fontSize: 14,
borderWidth: 1,
borderColor: "#e5e5e5",
color: "#0a0a0a",
},
customInputDark: { backgroundColor: "#2a2a2a", borderColor: "#3a3a3a", color: "#ffffff" },
customSubmit: { justifyContent: "center", alignItems: "center", padding: 8 },
footer: { flexDirection: "row", justifyContent: "space-between", alignItems: "center", marginTop: 12 },
dismiss: { fontSize: 14, color: "#999999" },
submitBtn: { backgroundColor: "#8b5cf6", paddingHorizontal: 20, paddingVertical: 10, borderRadius: 8 },
submitBtnDark: { backgroundColor: "#7c3aed" },
submitText: { color: "#ffffff", fontWeight: "600", fontSize: 14 },
})

View File

@@ -0,0 +1,48 @@
import { useState } from "react"
import { View, Text, TouchableOpacity, StyleSheet } from "react-native"
import { Ionicons } from "@expo/vector-icons"
interface Props {
text: string
isDark: boolean
}
export function ReasoningBlock({ text, isDark }: Props) {
const [expanded, setExpanded] = useState(false)
return (
<TouchableOpacity
style={[s.block, isDark && s.blockDark]}
onPress={() => setExpanded(!expanded)}
activeOpacity={0.7}
>
<View style={s.header}>
<Ionicons name="bulb-outline" size={14} color="#f59e0b" />
<Text style={[s.label, isDark && s.labelDark]}>Thinking</Text>
<Ionicons name={expanded ? "chevron-up" : "chevron-down"} size={14} color={isDark ? "#666666" : "#999999"} />
</View>
{expanded && (
<Text style={[s.text, isDark && s.textDark]} selectable>
{text}
</Text>
)}
</TouchableOpacity>
)
}
const s = StyleSheet.create({
block: {
backgroundColor: "#fffbeb",
borderRadius: 8,
padding: 10,
marginBottom: 8,
borderWidth: 1,
borderColor: "#fef3c7",
},
blockDark: { backgroundColor: "#1a1a0a", borderColor: "#333300" },
header: { flexDirection: "row", alignItems: "center", gap: 6 },
label: { fontSize: 12, fontWeight: "600", color: "#92400e", flex: 1 },
labelDark: { color: "#f59e0b" },
text: { fontSize: 13, lineHeight: 20, color: "#78350f", marginTop: 8 },
textDark: { color: "#d4a574" },
})

View File

@@ -0,0 +1,338 @@
import { useMemo } from "react"
import { View, Text, StyleSheet, TouchableOpacity, ActivityIndicator } from "react-native"
import { Ionicons } from "@expo/vector-icons"
import type { Message, Session } from "../../lib/sdk"
import type { Provider } from "../../stores/catalog"
interface Props {
session: Session | null
messages: Message[]
providers: Provider[]
visible: boolean
isDark: boolean
hasMore: boolean
loadingAll: boolean
onLoadAll: () => void
onScrollToTop: () => void
onClose: () => void
}
function compact(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`
return String(n)
}
function formatCost(cost: number): string {
return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(cost)
}
function formatTime(ts: number): string {
const diff = Date.now() - ts
const mins = Math.floor(diff / 60_000)
if (mins < 1) return "just now"
if (mins < 60) return `${mins}m ago`
const hours = Math.floor(mins / 60)
if (hours < 24) return `${hours}h ago`
const days = Math.floor(hours / 24)
if (days < 7) return `${days}d ago`
return new Date(ts).toLocaleDateString(undefined, { month: "short", day: "numeric" })
}
export function SessionInfo({
session,
messages,
providers,
visible,
isDark,
hasMore,
loadingAll,
onLoadAll,
onScrollToTop,
onClose,
}: Props) {
// Match TUI: last assistant message tokens (context window), cumulative cost
const stats = useMemo(() => {
let cost = 0
let last: Message | null = null
for (const msg of messages) {
if (msg.role !== "assistant") continue
if (msg.cost) cost += msg.cost
if (msg.tokens && msg.tokens.output > 0) last = msg
}
// Last assistant message token breakdown (what the TUI shows)
const tokens = last?.tokens
const input = tokens?.input || 0
const output = tokens?.output || 0
const reasoning = tokens?.reasoning || 0
const cacheRead = tokens?.cache?.read || 0
const cacheWrite = tokens?.cache?.write || 0
const total = input + output + reasoning + cacheRead + cacheWrite
// Find context limit from provider catalog
let context = 0
if (last?.providerID && last?.modelID) {
const provider = providers.find((p) => p.id === last!.providerID)
const model = provider?.models.find((m) => m.id === last!.modelID)
context = model?.limit?.context || 0
}
const percent = context > 0 ? Math.round((total / context) * 100) : 0
return { cost, input, output, reasoning, cacheRead, cacheWrite, total, percent, context }
}, [messages, providers])
if (!visible) return null
const hasTokens = stats.total > 0
const hasCost = stats.cost > 0
const summary = session?.summary
const created = session?.time.created
const updated = session?.time.updated
return (
<View style={[s.container, isDark && s.containerDark]}>
{/* Top row: tokens + context % + cost — matches TUI header */}
<View style={s.row}>
<View style={s.costRow}>
<Ionicons name="stats-chart-outline" size={14} color={isDark ? "#888888" : "#666666"} />
{hasTokens && (
<Text style={[s.tokens, isDark && s.textDark]}>
{stats.total.toLocaleString()}
{stats.percent > 0 && <Text style={[s.percent, isDark && s.dimDark]}>{` ${stats.percent}%`}</Text>}
</Text>
)}
{hasCost && <Text style={[s.cost, isDark && s.dimDark]}>({formatCost(stats.cost)})</Text>}
{!hasTokens && !hasCost && <Text style={[s.cost, isDark && s.dimDark]}>No usage data yet</Text>}
</View>
<TouchableOpacity onPress={onClose} hitSlop={8}>
<Ionicons name="close" size={16} color={isDark ? "#666666" : "#999999"} />
</TouchableOpacity>
</View>
{/* Context bar */}
{stats.percent > 0 && (
<View style={[s.bar, isDark && s.barDark]}>
<View
style={[
s.barFill,
{ width: `${Math.min(stats.percent, 100)}%` },
stats.percent > 80 ? s.barWarn : stats.percent > 50 ? s.barMid : s.barOk,
]}
/>
</View>
)}
{/* Token breakdown pills */}
{hasTokens && (
<View style={s.breakdown}>
<TokenPill label="In" value={stats.input} color="#3b82f6" isDark={isDark} />
<TokenPill label="Out" value={stats.output} color="#10b981" isDark={isDark} />
{stats.reasoning > 0 && <TokenPill label="Think" value={stats.reasoning} color="#f59e0b" isDark={isDark} />}
{stats.cacheRead > 0 && <TokenPill label="Cache R" value={stats.cacheRead} color="#8b5cf6" isDark={isDark} />}
{stats.cacheWrite > 0 && (
<TokenPill label="Cache W" value={stats.cacheWrite} color="#ec4899" isDark={isDark} />
)}
</View>
)}
{/* Session metadata */}
<View style={s.meta}>
{created && <MetaItem icon="time-outline" label="Created" value={formatTime(created)} isDark={isDark} />}
{updated && updated !== created && (
<MetaItem icon="refresh-outline" label="Updated" value={formatTime(updated)} isDark={isDark} />
)}
<MetaItem
icon="chatbubbles-outline"
label="Messages"
value={String(messages.length) + (hasMore ? "+" : "")}
isDark={isDark}
/>
{summary && summary.files > 0 && (
<MetaItem
icon="code-outline"
label="Changes"
value={`${summary.files}f +${summary.additions} -${summary.deletions}`}
isDark={isDark}
/>
)}
{session?.share?.url && <MetaItem icon="share-outline" label="Shared" value="Yes" isDark={isDark} />}
</View>
{/* Navigation actions */}
<View style={s.actions}>
{hasMore && (
<TouchableOpacity style={[s.action, isDark && s.actionDark]} onPress={onLoadAll} disabled={loadingAll}>
{loadingAll ? (
<ActivityIndicator size="small" color={isDark ? "#888888" : "#666666"} />
) : (
<Ionicons name="download-outline" size={14} color={isDark ? "#888888" : "#666666"} />
)}
<Text style={[s.actionText, isDark && s.dimDark]}>{loadingAll ? "Loading..." : "Load all messages"}</Text>
</TouchableOpacity>
)}
{messages.length > 0 && (
<TouchableOpacity style={[s.action, isDark && s.actionDark]} onPress={onScrollToTop}>
<Ionicons name="arrow-up-outline" size={14} color={isDark ? "#888888" : "#666666"} />
<Text style={[s.actionText, isDark && s.dimDark]}>Jump to beginning</Text>
</TouchableOpacity>
)}
</View>
</View>
)
}
function MetaItem({ icon, label, value, isDark }: { icon: string; label: string; value: string; isDark: boolean }) {
return (
<View style={s.metaItem}>
<Ionicons name={icon as any} size={12} color={isDark ? "#555555" : "#999999"} />
<Text style={[s.metaLabel, isDark && s.dimDark]}>{label}</Text>
<Text style={[s.metaValue, isDark && s.metaValueDark]}>{value}</Text>
</View>
)
}
function TokenPill({ label, value, color, isDark }: { label: string; value: number; color: string; isDark: boolean }) {
return (
<View style={[s.pill, { borderColor: color + "40" }, isDark && { backgroundColor: color + "15" }]}>
<View style={[s.dot, { backgroundColor: color }]} />
<Text style={[s.pillLabel, isDark && s.dimDark]}>{label}</Text>
<Text style={[s.pillValue, isDark && s.textDark]}>{compact(value)}</Text>
</View>
)
}
const s = StyleSheet.create({
container: {
paddingHorizontal: 12,
paddingVertical: 8,
borderBottomWidth: 1,
borderBottomColor: "#e5e5e5",
backgroundColor: "#fafafa",
gap: 8,
},
containerDark: {
borderBottomColor: "#1a1a1a",
backgroundColor: "#111111",
},
row: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
},
costRow: {
flexDirection: "row",
alignItems: "center",
gap: 6,
},
tokens: {
fontSize: 14,
fontWeight: "700",
color: "#0a0a0a",
fontVariant: ["tabular-nums"],
},
percent: {
fontSize: 13,
fontWeight: "500",
},
cost: {
fontSize: 13,
color: "#999999",
fontVariant: ["tabular-nums"],
},
bar: {
height: 4,
borderRadius: 2,
backgroundColor: "#e5e5e5",
overflow: "hidden",
},
barDark: {
backgroundColor: "#2a2a2a",
},
barFill: {
height: "100%",
borderRadius: 2,
},
barOk: { backgroundColor: "#3b82f6" },
barMid: { backgroundColor: "#f59e0b" },
barWarn: { backgroundColor: "#ef4444" },
breakdown: {
flexDirection: "row",
flexWrap: "wrap",
gap: 6,
},
pill: {
flexDirection: "row",
alignItems: "center",
gap: 4,
borderWidth: 1,
borderRadius: 10,
paddingHorizontal: 8,
paddingVertical: 3,
backgroundColor: "#ffffff",
},
dot: {
width: 6,
height: 6,
borderRadius: 3,
},
pillLabel: {
fontSize: 11,
color: "#666666",
},
pillValue: {
fontSize: 11,
fontWeight: "600",
color: "#0a0a0a",
fontVariant: ["tabular-nums"],
},
meta: {
flexDirection: "row",
flexWrap: "wrap",
gap: 12,
},
metaItem: {
flexDirection: "row",
alignItems: "center",
gap: 4,
},
metaLabel: {
fontSize: 11,
color: "#999999",
},
metaValue: {
fontSize: 11,
fontWeight: "600",
color: "#666666",
},
metaValueDark: {
color: "#aaaaaa",
},
actions: {
flexDirection: "row",
gap: 8,
},
action: {
flexDirection: "row",
alignItems: "center",
gap: 5,
backgroundColor: "#ffffff",
borderWidth: 1,
borderColor: "#e5e5e5",
borderRadius: 8,
paddingHorizontal: 10,
paddingVertical: 6,
},
actionDark: {
backgroundColor: "#1a1a1a",
borderColor: "#2a2a2a",
},
actionText: {
fontSize: 12,
color: "#666666",
fontWeight: "500",
},
textDark: { color: "#e5e5e5" },
dimDark: { color: "#666666" },
})

View File

@@ -0,0 +1,88 @@
import { useMemo } from "react"
import { View, Text, TouchableOpacity, ScrollView, StyleSheet, Keyboard, Platform } from "react-native"
import { Ionicons } from "@expo/vector-icons"
export interface SlashCommand {
trigger: string
title: string
description?: string
icon: string
type: "builtin" | "custom"
}
interface Props {
query: string
commands: SlashCommand[]
isDark: boolean
onSelect: (cmd: SlashCommand) => void
}
export function SlashPopover({ query, commands, isDark, onSelect }: Props) {
const filtered = useMemo(() => {
const q = query.toLowerCase()
return commands.filter((c) => c.trigger.toLowerCase().startsWith(q) || c.title.toLowerCase().includes(q))
}, [query, commands])
if (filtered.length === 0) return null
return (
<View style={[s.popover, isDark && s.popoverDark]}>
<ScrollView keyboardShouldPersistTaps="always" style={s.scroll}>
{filtered.map((cmd) => (
<TouchableOpacity key={cmd.trigger} style={[s.item, isDark && s.itemDark]} onPress={() => onSelect(cmd)}>
<Ionicons
name={cmd.icon as any}
size={18}
color={cmd.type === "custom" ? "#8b5cf6" : isDark ? "#888888" : "#666666"}
/>
<View style={s.textCol}>
<Text style={[s.trigger, isDark && s.textWhite]}>/{cmd.trigger}</Text>
{cmd.description && (
<Text style={[s.desc, isDark && s.metaDark]} numberOfLines={1}>
{cmd.description}
</Text>
)}
</View>
{cmd.type === "custom" && (
<View style={[s.badge, isDark && s.badgeDark]}>
<Text style={s.badgeText}>cmd</Text>
</View>
)}
</TouchableOpacity>
))}
</ScrollView>
</View>
)
}
const s = StyleSheet.create({
popover: {
backgroundColor: "#ffffff",
borderTopWidth: 1,
borderTopColor: "#e5e5e5",
maxHeight: 220,
},
popoverDark: { backgroundColor: "#1a1a1a", borderTopColor: "#2a2a2a" },
scroll: { paddingVertical: 4 },
item: {
flexDirection: "row",
alignItems: "center",
gap: 10,
paddingHorizontal: 16,
paddingVertical: 10,
},
itemDark: {},
textCol: { flex: 1 },
trigger: { fontSize: 14, fontWeight: "600", color: "#0a0a0a" },
textWhite: { color: "#ffffff" },
desc: { fontSize: 12, color: "#999999", marginTop: 1 },
metaDark: { color: "#666666" },
badge: {
backgroundColor: "#f3e8ff",
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 4,
},
badgeDark: { backgroundColor: "#2a1a3e" },
badgeText: { fontSize: 10, color: "#8b5cf6", fontWeight: "600" },
})

View File

@@ -0,0 +1,46 @@
import { View, Text, StyleSheet, ActivityIndicator } from "react-native"
import { useEvents } from "../../stores/events"
import { useSessions } from "../../stores/sessions"
interface Props {
sessionID: string
isDark: boolean
}
export function StatusIndicator({ sessionID, isDark }: Props) {
const status = useEvents((s) => s.sessionStatus[sessionID])
const text = useEvents((s) => s.statusText[sessionID])
const optimistic = useSessions((s) => s.sending[sessionID])
// SSE status is the source of truth. The optimistic `sending` flag only
// covers the gap between the user tapping send and SSE confirming busy.
// Once SSE reports idle, the indicator hides regardless of the optimistic flag.
const sseBusy = status && status.type !== "idle"
const busy = sseBusy || (optimistic && !status)
if (!busy) return null
const label = status?.type === "retry" ? `Retrying (attempt ${status.attempt})...` : text || "Working..."
return (
<View style={[s.bar, isDark && s.barDark]}>
<ActivityIndicator size="small" color="#8b5cf6" />
<Text style={[s.text, isDark && s.textDark]}>{label}</Text>
</View>
)
}
const s = StyleSheet.create({
bar: {
flexDirection: "row",
alignItems: "center",
gap: 8,
paddingHorizontal: 16,
paddingVertical: 8,
backgroundColor: "#f5f3ff",
borderTopWidth: 1,
borderTopColor: "#e5e5e5",
},
barDark: { backgroundColor: "#1a1a2e", borderTopColor: "#2a2a2a" },
text: { fontSize: 13, color: "#6d28d9", fontWeight: "500" },
textDark: { color: "#a78bfa" },
})

View File

@@ -0,0 +1,454 @@
import { useState, useCallback } from "react"
import { View, Text, TouchableOpacity, StyleSheet, ActivityIndicator, ScrollView, Platform } from "react-native"
import { Ionicons } from "@expo/vector-icons"
import type { Part } from "../../lib/sdk"
import { DiffView } from "./DiffView"
const TOOL_ICONS: Record<string, string> = {
read: "glasses-outline",
list: "list-outline",
glob: "search-outline",
grep: "search-outline",
webfetch: "globe-outline",
edit: "code-slash-outline",
write: "create-outline",
apply_patch: "git-merge-outline",
bash: "terminal-outline",
task: "git-branch-outline",
todowrite: "checkbox-outline",
todoread: "checkbox-outline",
question: "chatbubble-ellipses-outline",
codesearch: "search-outline",
websearch: "globe-outline",
}
const mono = Platform.OS === "ios" ? "Menlo" : "monospace"
function statusColor(status: string): string {
if (status === "completed") return "#22c55e"
if (status === "error") return "#ef4444"
if (status === "running") return "#f59e0b"
return "#888888"
}
// --- Tool-specific detail renderers ---
function BashDetail({ input, output, isDark }: { input: unknown; output: unknown; isDark: boolean }) {
const cmd = typeof input === "object" && input !== null ? (input as Record<string, unknown>).command : undefined
const out = typeof output === "string" ? output : undefined
return (
<View style={s.detailSection}>
{typeof cmd === "string" && (
<View style={[s.codeBlock, isDark && s.codeBlockDark]}>
<Text style={[s.codePre, isDark && s.codePteDark]} selectable>
<Text style={s.codePrompt}>$ </Text>
{cmd}
</Text>
</View>
)}
{out !== undefined && out.length > 0 && (
<View style={[s.codeBlock, isDark && s.codeBlockDark, { marginTop: 6 }]}>
<Text style={[s.codePre, isDark && s.codePteDark]} selectable numberOfLines={80}>
{out}
</Text>
</View>
)}
</View>
)
}
function ReadDetail({ input, isDark }: { input: unknown; isDark: boolean }) {
const file = typeof input === "object" && input !== null ? (input as Record<string, unknown>).filePath : undefined
const offset = typeof input === "object" && input !== null ? (input as Record<string, unknown>).offset : undefined
const limit = typeof input === "object" && input !== null ? (input as Record<string, unknown>).limit : undefined
const range = offset || limit ? ` (${offset || 0}..${limit || "end"})` : ""
return (
<View style={s.detailSection}>
{typeof file === "string" && (
<Text style={[s.detailFile, isDark && s.detailFileDark]} selectable numberOfLines={2}>
{file}
{range}
</Text>
)}
</View>
)
}
function WriteDetail({ input, isDark }: { input: unknown; isDark: boolean }) {
const file = typeof input === "object" && input !== null ? (input as Record<string, unknown>).filePath : undefined
const content = typeof input === "object" && input !== null ? (input as Record<string, unknown>).content : undefined
return (
<View style={s.detailSection}>
{typeof file === "string" && (
<Text style={[s.detailFile, isDark && s.detailFileDark]} selectable numberOfLines={2}>
{file}
</Text>
)}
{typeof content === "string" && content.length > 0 && (
<View style={[s.codeBlock, isDark && s.codeBlockDark, { marginTop: 6 }]}>
<Text style={[s.codePre, isDark && s.codePteDark]} selectable numberOfLines={40}>
{content}
</Text>
</View>
)}
</View>
)
}
function EditDetail({ input, output, isDark }: { input: unknown; output: unknown; isDark: boolean }) {
const file = typeof input === "object" && input !== null ? (input as Record<string, unknown>).filePath : undefined
const old = typeof input === "object" && input !== null ? (input as Record<string, unknown>).oldString : undefined
const replacement =
typeof input === "object" && input !== null ? (input as Record<string, unknown>).newString : undefined
// If we have old/new strings, show as diff
if (typeof old === "string" && typeof replacement === "string") {
return (
<View style={s.detailSection}>
{typeof file === "string" && (
<Text style={[s.detailFile, isDark && s.detailFileDark]} selectable numberOfLines={2}>
{file}
</Text>
)}
<DiffView before={old} after={replacement} isDark={isDark} />
</View>
)
}
// Fallback: show raw output
const text = typeof output === "string" ? output : JSON.stringify(output, null, 2)
return (
<View style={s.detailSection}>
{typeof file === "string" && (
<Text style={[s.detailFile, isDark && s.detailFileDark]} selectable numberOfLines={2}>
{file}
</Text>
)}
{text && (
<View style={[s.codeBlock, isDark && s.codeBlockDark, { marginTop: 6 }]}>
<Text style={[s.codePre, isDark && s.codePteDark]} selectable numberOfLines={40}>
{text}
</Text>
</View>
)}
</View>
)
}
function PatchDetail({ input, isDark }: { input: unknown; isDark: boolean }) {
const patch = typeof input === "object" && input !== null ? (input as Record<string, unknown>).patch : undefined
return (
<View style={s.detailSection}>
{typeof patch === "string" && patch.length > 0 && (
<View style={[s.codeBlock, isDark && s.codeBlockDark]}>
<Text style={[s.codePre, isDark && s.codePteDark]} selectable numberOfLines={60}>
{patch}
</Text>
</View>
)}
</View>
)
}
function GlobGrepDetail({ input, output, isDark }: { input: unknown; output: unknown; isDark: boolean }) {
const pattern = typeof input === "object" && input !== null ? (input as Record<string, unknown>).pattern : undefined
const path = typeof input === "object" && input !== null ? (input as Record<string, unknown>).path : undefined
const results = typeof output === "string" ? output : undefined
return (
<View style={s.detailSection}>
{typeof pattern === "string" && (
<Text style={[s.detailMeta, isDark && s.detailMetaDark]}>
Pattern: {pattern}
{typeof path === "string" ? ` in ${path}` : ""}
</Text>
)}
{results && results.length > 0 && (
<View style={[s.codeBlock, isDark && s.codeBlockDark, { marginTop: 6 }]}>
<Text style={[s.codePre, isDark && s.codePteDark]} selectable numberOfLines={30}>
{results}
</Text>
</View>
)}
</View>
)
}
function WebfetchDetail({ input, isDark }: { input: unknown; isDark: boolean }) {
const url = typeof input === "object" && input !== null ? (input as Record<string, unknown>).url : undefined
return (
<View style={s.detailSection}>
{typeof url === "string" && (
<Text style={[s.detailFile, isDark && s.detailFileDark, { color: "#8b5cf6" }]} selectable numberOfLines={3}>
{url}
</Text>
)}
</View>
)
}
function TaskDetail({ input, isDark }: { input: unknown; isDark: boolean }) {
const description =
typeof input === "object" && input !== null ? (input as Record<string, unknown>).description : undefined
const prompt = typeof input === "object" && input !== null ? (input as Record<string, unknown>).prompt : undefined
return (
<View style={s.detailSection}>
{typeof description === "string" && <Text style={[s.detailMeta, isDark && s.detailMetaDark]}>{description}</Text>}
{typeof prompt === "string" && prompt.length > 0 && (
<View style={[s.codeBlock, isDark && s.codeBlockDark, { marginTop: 6 }]}>
<Text style={[s.codePre, isDark && s.codePteDark]} selectable numberOfLines={20}>
{prompt}
</Text>
</View>
)}
</View>
)
}
function TodoDetail({ input, isDark }: { input: unknown; isDark: boolean }) {
const todos = typeof input === "object" && input !== null ? (input as Record<string, unknown>).todos : undefined
if (!Array.isArray(todos)) return null
return (
<View style={s.detailSection}>
{todos.map((t, i) => {
const item = t as Record<string, unknown>
const done = item.status === "completed"
return (
<View key={String(item.id || i)} style={s.todoRow}>
<Ionicons
name={done ? "checkbox" : "square-outline"}
size={16}
color={done ? "#22c55e" : isDark ? "#666666" : "#999999"}
/>
<Text style={[s.todoText, isDark && s.todoTextDark, done && s.todoDone]} numberOfLines={2}>
{String(item.content || item.title || "")}
</Text>
</View>
)
})}
</View>
)
}
function GenericDetail({ input, output, isDark }: { input: unknown; output: unknown; isDark: boolean }) {
const text =
typeof output === "string"
? output
: output !== undefined && output !== null
? JSON.stringify(output, null, 2)
: typeof input === "object" && input !== null
? JSON.stringify(input, null, 2)
: undefined
if (!text || text.length === 0) return null
return (
<View style={s.detailSection}>
<View style={[s.codeBlock, isDark && s.codeBlockDark]}>
<Text style={[s.codePre, isDark && s.codePteDark]} selectable numberOfLines={30}>
{text}
</Text>
</View>
</View>
)
}
function ToolDetail({ tool, isDark }: { tool: Part; isDark: boolean }) {
const name = tool.tool || ""
const input = tool.state?.input
const output = tool.state?.output
switch (name) {
case "bash":
return <BashDetail input={input} output={output} isDark={isDark} />
case "read":
return <ReadDetail input={input} isDark={isDark} />
case "write":
return <WriteDetail input={input} isDark={isDark} />
case "edit":
return <EditDetail input={input} output={output} isDark={isDark} />
case "apply_patch":
return <PatchDetail input={input} isDark={isDark} />
case "glob":
case "grep":
case "list":
case "codesearch":
return <GlobGrepDetail input={input} output={output} isDark={isDark} />
case "webfetch":
case "websearch":
return <WebfetchDetail input={input} isDark={isDark} />
case "task":
return <TaskDetail input={input} isDark={isDark} />
case "todowrite":
return <TodoDetail input={input} isDark={isDark} />
default:
return <GenericDetail input={input} output={output} isDark={isDark} />
}
}
// --- Error display ---
function ErrorBanner({ message, isDark }: { message: string; isDark: boolean }) {
return (
<View style={[s.errorBanner, isDark && s.errorBannerDark]}>
<Ionicons name="alert-circle" size={14} color="#ef4444" />
<Text style={s.errorText} numberOfLines={3} selectable>
{message}
</Text>
</View>
)
}
// --- Duration display ---
function duration(start?: number, end?: number): string | null {
if (!start || !end) return null
const ms = end - start
if (ms < 1000) return `${ms}ms`
return `${(ms / 1000).toFixed(1)}s`
}
// --- Main component ---
interface Props {
tool: Part
isDark: boolean
}
export function ToolCallCard({ tool, isDark }: Props) {
const [expanded, setExpanded] = useState(false)
const icon = (tool.tool && TOOL_ICONS[tool.tool]) || "extension-puzzle-outline"
const status = tool.state?.status || "pending"
const color = statusColor(status)
const error = tool.state?.error?.message
const elapsed = duration(tool.state?.time?.start, tool.state?.time?.end)
const hasDetail = tool.state?.input !== undefined || tool.state?.output !== undefined || error
const toggle = useCallback(() => {
if (hasDetail) setExpanded((v) => !v)
}, [hasDetail])
return (
<TouchableOpacity
style={[
s.card,
isDark && s.cardDark,
status === "error" && s.cardError,
status === "error" && isDark && s.cardErrorDark,
]}
onPress={toggle}
activeOpacity={hasDetail ? 0.7 : 1}
>
{/* Header row */}
<View style={s.header}>
<View style={s.headerLeft}>
<Ionicons name={icon as any} size={16} color={color} />
<Text style={[s.name, isDark && s.nameDark]} numberOfLines={1}>
{tool.state?.title || tool.tool || "Tool"}
</Text>
{elapsed && <Text style={[s.elapsed, isDark && s.elapsedDark]}>{elapsed}</Text>}
</View>
<View style={s.headerRight}>
{status === "running" && <ActivityIndicator size="small" color={color} />}
{status === "completed" && <Ionicons name="checkmark-circle" size={16} color="#22c55e" />}
{status === "error" && <Ionicons name="close-circle" size={16} color="#ef4444" />}
{hasDetail && (
<Ionicons
name={expanded ? "chevron-up" : "chevron-down"}
size={16}
color={isDark ? "#666666" : "#999999"}
/>
)}
</View>
</View>
{/* Error banner */}
{error && !expanded && <ErrorBanner message={error} isDark={isDark} />}
{/* Expanded detail */}
{expanded && (
<ScrollView style={s.detailScroll} nestedScrollEnabled showsVerticalScrollIndicator={false}>
{error && <ErrorBanner message={error} isDark={isDark} />}
<ToolDetail tool={tool} isDark={isDark} />
</ScrollView>
)}
</TouchableOpacity>
)
}
const s = StyleSheet.create({
card: {
backgroundColor: "#ffffff",
padding: 10,
borderRadius: 8,
marginTop: 8,
borderWidth: 1,
borderColor: "#f0f0f0",
},
cardDark: { backgroundColor: "#2a2a2a", borderColor: "#3a3a3a" },
cardError: { borderColor: "#fecaca" },
cardErrorDark: { borderColor: "#7f1d1d" },
header: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
},
headerLeft: { flexDirection: "row", alignItems: "center", gap: 8, flex: 1 },
headerRight: { flexDirection: "row", alignItems: "center", gap: 6 },
name: { fontSize: 13, fontWeight: "500", color: "#0a0a0a", flex: 1 },
nameDark: { color: "#e5e5e5" },
elapsed: { fontSize: 11, color: "#999999" },
elapsedDark: { color: "#666666" },
// Error
errorBanner: {
flexDirection: "row",
alignItems: "flex-start",
gap: 6,
marginTop: 8,
padding: 8,
backgroundColor: "#fef2f2",
borderRadius: 6,
},
errorBannerDark: { backgroundColor: "#1a0a0a" },
errorText: { fontSize: 12, color: "#dc2626", flex: 1, lineHeight: 18 },
// Detail
detailScroll: { maxHeight: 300, marginTop: 8 },
detailSection: { gap: 4 },
detailFile: {
fontSize: 12,
fontFamily: mono,
color: "#6d28d9",
backgroundColor: "#f5f3ff",
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 4,
overflow: "hidden",
},
detailFileDark: { color: "#a78bfa", backgroundColor: "#1a1a2e" },
detailMeta: { fontSize: 12, color: "#666666", lineHeight: 18 },
detailMetaDark: { color: "#888888" },
// Code block
codeBlock: {
backgroundColor: "#f8f8f8",
borderRadius: 6,
padding: 10,
},
codeBlockDark: { backgroundColor: "#1a1a1a" },
codePre: {
fontSize: 12,
fontFamily: mono,
color: "#0a0a0a",
lineHeight: 18,
},
codePteDark: { color: "#e5e5e5" },
codePrompt: { color: "#8b5cf6", fontWeight: "700" },
// Todo
todoRow: {
flexDirection: "row",
alignItems: "flex-start",
gap: 8,
paddingVertical: 3,
},
todoText: { fontSize: 13, color: "#0a0a0a", flex: 1, lineHeight: 20 },
todoTextDark: { color: "#e5e5e5" },
todoDone: { textDecorationLine: "line-through", color: "#999999" },
})

View File

@@ -0,0 +1,12 @@
export { MessageBubble } from "./MessageBubble"
export { ToolCallCard } from "./ToolCallCard"
export { DiffView } from "./DiffView"
export { ReasoningBlock } from "./ReasoningBlock"
export { PermissionPrompt } from "./PermissionPrompt"
export { QuestionPrompt } from "./QuestionPrompt"
export { StatusIndicator } from "./StatusIndicator"
export { SlashPopover, type SlashCommand } from "./SlashPopover"
export { ModelPicker } from "./ModelPicker"
export { ImageAttachments, type Attachment } from "./ImageAttachments"
export { DirectorySwitcher } from "./DirectorySwitcher"
export { SessionInfo } from "./SessionInfo"

View File

@@ -0,0 +1,85 @@
import { useState } from "react"
import { View, Text, TouchableOpacity, StyleSheet, useColorScheme, Platform } from "react-native"
import * as Clipboard from "expo-clipboard"
interface Props {
code: string
language?: string
}
export function CodeBlock({ code, language }: Props) {
const isDark = useColorScheme() === "dark"
const [copied, setCopied] = useState(false)
const copy = async () => {
try {
await Clipboard.setStringAsync(code)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch {}
}
return (
<View style={[styles.container, isDark && styles.containerDark]}>
<View style={[styles.header, isDark && styles.headerDark]}>
<Text style={[styles.language, isDark && styles.languageDark]}>{language || "code"}</Text>
<TouchableOpacity onPress={copy} hitSlop={8}>
<Text style={[styles.copyBtn, isDark && styles.copyBtnDark]}>{copied ? "Copied!" : "Copy"}</Text>
</TouchableOpacity>
</View>
<Text style={[styles.code, isDark && styles.codeDark]} selectable>
{code}
</Text>
</View>
)
}
const styles = StyleSheet.create({
container: {
backgroundColor: "#f5f5f5",
borderRadius: 8,
marginVertical: 8,
overflow: "hidden",
},
containerDark: {
backgroundColor: "#1a1a1a",
},
header: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingHorizontal: 12,
paddingVertical: 6,
backgroundColor: "#e8e8e8",
},
headerDark: {
backgroundColor: "#2a2a2a",
},
language: {
fontSize: 11,
fontWeight: "600",
color: "#666666",
textTransform: "uppercase",
},
languageDark: {
color: "#888888",
},
copyBtn: {
fontSize: 11,
color: "#8b5cf6",
fontWeight: "600",
},
copyBtnDark: {
color: "#a78bfa",
},
code: {
fontFamily: Platform.OS === "ios" ? "Menlo" : "monospace",
fontSize: 13,
lineHeight: 20,
color: "#1a1a1a",
padding: 12,
},
codeDark: {
color: "#e5e5e5",
},
})

View File

@@ -0,0 +1,117 @@
import type { ReactNode } from "react"
import { View, Text, useColorScheme, Platform, type ViewStyle, type TextStyle } from "react-native"
import RNMarkdown, { Renderer } from "react-native-marked"
import { CodeBlock } from "./CodeBlock"
class CustomRenderer extends Renderer {
code(text: string, language?: string, containerStyle?: ViewStyle, _textStyle?: TextStyle) {
return (
<View key={this.getKey()} style={containerStyle}>
<CodeBlock code={text} language={language} />
</View>
)
}
codespan(text: string, styles?: TextStyle): ReactNode {
return (
<Text selectable key={this.getKey()} style={[styles, { fontStyle: "normal", fontWeight: "normal" }]}>
{text}
</Text>
)
}
}
const renderer = new CustomRenderer()
const mono = Platform.OS === "ios" ? "Menlo" : "monospace"
const lightTheme = {
text: { color: "#0a0a0a", fontSize: 15, lineHeight: 22 },
paragraph: { marginTop: 0, marginBottom: 8 },
heading1: { fontSize: 22, fontWeight: "700" as const, color: "#0a0a0a", marginBottom: 8, marginTop: 12 },
heading2: { fontSize: 19, fontWeight: "600" as const, color: "#0a0a0a", marginBottom: 6, marginTop: 10 },
heading3: { fontSize: 16, fontWeight: "600" as const, color: "#0a0a0a", marginBottom: 4, marginTop: 8 },
link: { color: "#8b5cf6" },
blockquote: {
backgroundColor: "transparent",
borderLeftWidth: 3,
borderLeftColor: "#d1d5db",
paddingLeft: 12,
paddingVertical: 2,
marginVertical: 4,
},
code: {
backgroundColor: "#e8e5f0",
color: "#6d28d9",
fontFamily: mono,
fontSize: 13,
paddingHorizontal: 5,
paddingVertical: 2,
borderRadius: 4,
},
codespan: {
backgroundColor: "#e8e5f0",
color: "#6d28d9",
fontFamily: mono,
fontSize: 13,
paddingHorizontal: 4,
paddingVertical: 1,
borderRadius: 3,
},
list: { marginBottom: 4 },
listItem: { marginBottom: 2 },
hr: { backgroundColor: "#e5e5e5", height: 1, marginVertical: 12 },
strong: { fontWeight: "700" as const },
em: { fontStyle: "italic" as const },
strikethrough: { textDecorationLine: "line-through" as const },
image: { borderRadius: 8 },
}
const darkTheme = {
...lightTheme,
text: { ...lightTheme.text, color: "#e5e5e5" },
heading1: { ...lightTheme.heading1, color: "#ffffff" },
heading2: { ...lightTheme.heading2, color: "#ffffff" },
heading3: { ...lightTheme.heading3, color: "#ffffff" },
link: { color: "#a78bfa" },
blockquote: {
...lightTheme.blockquote,
borderLeftColor: "#4a4a5a",
},
code: {
...lightTheme.code,
backgroundColor: "#2a2040",
color: "#c4b5fd",
},
codespan: {
...lightTheme.codespan,
backgroundColor: "#2a2040",
color: "#c4b5fd",
},
hr: { ...lightTheme.hr, backgroundColor: "#2a2a2a" },
}
interface Props {
children: string
}
export function Markdown({ children }: Props) {
const isDark = useColorScheme() === "dark"
const theme = isDark ? darkTheme : lightTheme
if (!children?.trim()) return null
return (
<RNMarkdown
value={children}
renderer={renderer}
styles={theme}
flatListProps={{
scrollEnabled: false,
initialNumToRender: 50,
style: { backgroundColor: "transparent" },
contentContainerStyle: { backgroundColor: "transparent" },
}}
/>
)
}

View File

@@ -0,0 +1,2 @@
export { Markdown } from "./Markdown"
export { CodeBlock } from "./CodeBlock"

174
src/lib/notifications.ts Normal file
View File

@@ -0,0 +1,174 @@
import * as Notifications from "expo-notifications"
import * as Device from "expo-device"
import { Platform, AppState } from "react-native"
// ---------------------------------------------------------------------------
// Categories — every notification belongs to exactly one
// ---------------------------------------------------------------------------
export const categories = ["permissions", "questions", "completed", "errors", "connection"] as const
export type Category = (typeof categories)[number]
// Per-category metadata used by both the notification module and the settings UI
export const categoryMeta: Record<Category, { label: string; description: string; icon: string }> = {
permissions: {
label: "Permission Requests",
description: "When a tool needs approval to proceed",
icon: "shield-checkmark",
},
questions: {
label: "Questions",
description: "When the assistant needs your input",
icon: "help-circle",
},
completed: {
label: "Task Completed",
description: "When a session finishes processing",
icon: "checkmark-circle",
},
errors: {
label: "Errors",
description: "When a session encounters a problem",
icon: "alert-circle",
},
connection: {
label: "Connection",
description: "When the app loses connection for an extended period",
icon: "wifi",
},
}
// Default state — permissions and questions on (they block work), the rest off
export const defaultPreferences: Record<Category, boolean> = {
permissions: true,
questions: true,
completed: false,
errors: true,
connection: true,
}
// ---------------------------------------------------------------------------
// Notification payload — what callers pass in
// ---------------------------------------------------------------------------
export interface Payload {
category: Category
title: string
body: string
sessionId: string
dedupeKey?: string
dedupeCooldownMs?: number
}
// Data embedded in the notification for tap handling
interface NotificationData {
category: Category
sessionId: string
}
// ---------------------------------------------------------------------------
// Preferences accessor — injected to avoid circular imports with the store
// ---------------------------------------------------------------------------
type PreferencesAccessor = () => Record<Category, boolean>
let preferences: PreferencesAccessor = () => defaultPreferences
const dedupeSentAt = new Map<string, number>()
export function configure(accessor: PreferencesAccessor) {
preferences = accessor
}
// ---------------------------------------------------------------------------
// Setup & permissions
// ---------------------------------------------------------------------------
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: false,
shouldShowBanner: true,
shouldShowList: true,
}),
})
export async function setup(): Promise<boolean> {
if (!Device.isDevice) return false
const { status: existing } = await Notifications.getPermissionsAsync()
if (existing === "granted") {
await ensureChannel()
return true
}
const { status } = await Notifications.requestPermissionsAsync()
if (status !== "granted") return false
await ensureChannel()
return true
}
export async function granted(): Promise<boolean> {
const { status } = await Notifications.getPermissionsAsync()
return status === "granted"
}
async function ensureChannel() {
if (Platform.OS !== "android") return
await Notifications.setNotificationChannelAsync("prompts", {
name: "Session Prompts & Updates",
importance: Notifications.AndroidImportance.HIGH,
vibrationPattern: [0, 250, 250, 250],
sound: "default",
})
}
// ---------------------------------------------------------------------------
// Send — single entry point, decides whether to fire
// ---------------------------------------------------------------------------
export async function send(payload: Payload) {
const prefs = preferences()
if (!prefs[payload.category]) return
if (AppState.currentState === "active") return
if (!(await granted())) return
if (payload.dedupeKey) {
const now = Date.now()
const lastSent = dedupeSentAt.get(payload.dedupeKey) || 0
const cooldown = payload.dedupeCooldownMs ?? 30_000
if (now - lastSent < cooldown) return
dedupeSentAt.set(payload.dedupeKey, now)
}
await Notifications.scheduleNotificationAsync({
content: {
title: payload.title,
body: payload.body,
data: {
category: payload.category,
sessionId: payload.sessionId,
} satisfies NotificationData as Record<string, unknown>,
sound: "default",
...(Platform.OS === "android" ? { channelId: "prompts" } : {}),
},
trigger: null,
})
}
// ---------------------------------------------------------------------------
// Tap handler — returns cleanup function
// ---------------------------------------------------------------------------
export function onTap(handler: (data: NotificationData) => void) {
const subscription = Notifications.addNotificationResponseReceivedListener((response) => {
const raw = response.notification.request.content.data
const data = raw as unknown as NotificationData | undefined
if (!data) return
if (data.sessionId || data.category === "connection") {
handler(data)
}
})
return () => subscription.remove()
}

434
src/lib/sdk.ts Normal file
View File

@@ -0,0 +1,434 @@
// SDK client wrapper for React Native
// We create our own lightweight client that mirrors the opencode SDK patterns
// but works in React Native environment
// expo/fetch provides WinterCG-compliant fetch with ReadableStream support for SSE
import { fetch as expoFetch } from "expo/fetch"
export interface ClientConfig {
baseUrl: string
directory?: string
auth?: {
username: string
password: string
}
}
export interface Session {
id: string
slug: string
projectID: string
directory: string
parentID?: string
title: string
version: string
share?: { url: string }
time: {
created: number
updated: number
compacting?: number
archived?: number
}
summary?: {
additions: number
deletions: number
files: number
}
}
export interface Message {
id: string
sessionID: string
role: "user" | "assistant"
parentID?: string
time: {
created: number
completed?: number
}
// User message fields
agent?: string
model?: { providerID: string; modelID: string }
// Assistant message fields
modelID?: string
providerID?: string
cost?: number
tokens?: {
input: number
output: number
reasoning?: number
cache?: { read: number; write: number }
}
error?: { message: string }
finish?: string
}
// API returns messages with parts embedded
export interface MessageWithParts {
info: Message
parts: Part[]
}
export interface Part {
id: string
sessionID?: string
messageID: string
type:
| "text"
| "reasoning"
| "tool"
| "file"
| "snapshot"
| "patch"
| "step-start"
| "step-finish"
| "subtask"
| "retry"
| "compaction"
| "agent"
// Text / reasoning part
text?: string
// Tool part
tool?: string
callID?: string
state?: {
status: "pending" | "running" | "completed" | "error"
input?: unknown
output?: unknown
title?: string
error?: { message: string }
time?: { start?: number; end?: number }
}
// Timing
time?: { start?: number; end?: number }
// File part
mime?: string
url?: string
filename?: string
}
export interface Agent {
name: string
description?: string
mode: "subagent" | "primary" | "all"
native?: boolean
hidden?: boolean
topP?: number
temperature?: number
color?: string
model?: { modelID: string; providerID: string }
prompt?: string
options: Record<string, unknown>
steps?: number
}
export interface Command {
name: string
description?: string
agent?: string
model?: string
mcp?: boolean
template: string
subtask?: boolean
hints: string[]
}
export interface Project {
id: string
name?: string
path: {
cwd: string
root: string
absolute: string
}
}
export interface Event {
type: string
properties: Record<string, unknown>
}
export interface HealthResponse {
healthy: boolean
version: string
}
const REQUEST_TIMEOUT_MS = 30_000
function createHeaders(config: ClientConfig): HeadersInit {
const headers: HeadersInit = {
"Content-Type": "application/json",
}
if (config.directory) {
const encoded = /[^\x00-\x7F]/.test(config.directory) ? encodeURIComponent(config.directory) : config.directory
headers["x-opencode-directory"] = encoded
}
if (config.auth) {
const credentials = btoa(`${config.auth.username}:${config.auth.password}`)
headers["Authorization"] = `Basic ${credentials}`
}
return headers
}
async function request<T>(config: ClientConfig, path: string, options: RequestInit = {}): Promise<T> {
const url = `${config.baseUrl}${path}`
const headers = { ...createHeaders(config), ...options.headers }
const response = await fetchWithTimeout(url, {
...options,
headers,
})
if (!response.ok) {
const error = await response.text()
throw new Error(`API Error: ${response.status} - ${error}`)
}
return response.json()
}
async function fetchWithTimeout(url: string, options: RequestInit = {}): Promise<Response> {
const parentSignal = options.signal
if (parentSignal?.aborted) throw new Error("Request aborted")
const controller = new AbortController()
let timedOut = false
const timeout = setTimeout(() => {
timedOut = true
controller.abort()
}, REQUEST_TIMEOUT_MS)
const onParentAbort = () => controller.abort()
parentSignal?.addEventListener("abort", onParentAbort)
try {
return await fetch(url, { ...options, signal: controller.signal })
} catch (error) {
if (timedOut) {
throw new Error(`Request timed out after ${REQUEST_TIMEOUT_MS}ms`)
}
throw error
} finally {
clearTimeout(timeout)
parentSignal?.removeEventListener("abort", onParentAbort)
}
}
export function createClient(config: ClientConfig) {
return {
global: {
health: () => request<HealthResponse>(config, "/global/health"),
// SSE event stream - returns async iterator
// Pass an AbortSignal to cancel the connection
async *events(signal?: AbortSignal): AsyncGenerator<Event> {
const url = `${config.baseUrl}/global/event`
const headers = createHeaders(config)
// Remove Content-Type for SSE (it's text/event-stream)
delete (headers as Record<string, string>)["Content-Type"]
// Must use expo/fetch for ReadableStream support on native
const response = await expoFetch(url, { headers, signal })
if (!response.ok || !response.body) {
throw new Error(`Failed to connect to event stream: ${response.status}`)
}
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ""
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split("\n")
buffer = lines.pop() || ""
for (const line of lines) {
if (line.startsWith("data: ")) {
const data = line.slice(6)
if (data && data !== "[DONE]") {
try {
yield JSON.parse(data)
} catch (err) {
console.warn("[SSE] Failed to parse event:", data.slice(0, 200), err)
}
}
}
}
}
} finally {
reader.releaseLock()
}
},
},
project: {
list: () => request<Project[]>(config, "/project"),
current: () => request<Project>(config, "/project/current"),
},
path: {
get: () =>
request<{ home: string; state: string; config: string; worktree: string; directory: string }>(config, "/path"),
},
session: {
list: (params?: { roots?: boolean; limit?: number; search?: string }) => {
const query = new URLSearchParams()
if (params?.roots) query.set("roots", "true")
if (params?.limit) query.set("limit", String(params.limit))
if (params?.search) query.set("search", params.search)
const qs = query.toString()
return request<Session[]>(config, `/session${qs ? `?${qs}` : ""}`)
},
get: (sessionID: string) => request<Session>(config, `/session/${sessionID}`),
create: (params?: { title?: string }) =>
request<Session>(config, "/session", {
method: "POST",
body: JSON.stringify(params || {}),
}),
delete: (sessionID: string) => request<void>(config, `/session/${sessionID}`, { method: "DELETE" }),
update: (sessionID: string, params: { title?: string; time?: { archived?: number } }) =>
request<Session>(config, `/session/${sessionID}`, {
method: "PATCH",
body: JSON.stringify(params),
}),
messages: (sessionID: string, params?: { limit?: number }) => {
const query = new URLSearchParams()
if (params?.limit) query.set("limit", String(params.limit))
const qs = query.toString()
return request<MessageWithParts[]>(config, `/session/${sessionID}/message${qs ? `?${qs}` : ""}`)
},
// Sends a message and returns the response
// Fire-and-forget async prompt - SSE events drive all real-time updates
prompt: async (
sessionID: string,
params: {
parts: Array<{ type: "text"; text: string } | { type: "file"; mime: string; url: string; filename?: string }>
model?: { providerID: string; modelID: string }
agent?: string
variant?: string
},
): Promise<void> => {
const url = `${config.baseUrl}/session/${sessionID}/prompt_async`
const headers = createHeaders(config)
const body = JSON.stringify(params)
const response = await fetchWithTimeout(url, {
method: "POST",
headers,
body,
})
if (!response.ok) {
const error = await response.text()
throw new Error(`Failed to send message: ${response.status} - ${error}`)
}
},
command: async (
sessionID: string,
params: {
command: string
arguments: string
agent?: string
model?: string
variant?: string
parts?: Array<{ type: "file"; mime: string; url: string; filename?: string }>
},
): Promise<void> => {
const url = `${config.baseUrl}/session/${sessionID}/command`
const headers = createHeaders(config)
const response = await fetchWithTimeout(url, {
method: "POST",
headers,
body: JSON.stringify({ ...params, sessionID }),
})
if (!response.ok) {
const error = await response.text()
throw new Error(`Failed to run command: ${response.status} - ${error}`)
}
},
abort: (sessionID: string) => request<boolean>(config, `/session/${sessionID}/abort`, { method: "POST" }),
diff: (sessionID: string, messageID?: string) => {
const qs = messageID ? `?messageID=${messageID}` : ""
return request<unknown[]>(config, `/session/${sessionID}/diff${qs}`)
},
},
permission: {
list: () =>
request<Array<{ id: string; sessionID: string; tool: string; input: unknown }>>(config, "/permission"),
reply: (requestID: string, reply: "once" | "always" | "reject") =>
request<boolean>(config, `/permission/${requestID}/reply`, {
method: "POST",
body: JSON.stringify({ reply }),
}),
},
question: {
list: () => request<Array<{ id: string; sessionID: string; questions: unknown[] }>>(config, "/question"),
reply: (requestID: string, answers: string[][]) =>
request<boolean>(config, `/question/${requestID}/reply`, {
method: "POST",
body: JSON.stringify({ answers }),
}),
reject: (requestID: string) =>
request<boolean>(config, `/question/${requestID}/reject`, {
method: "POST",
}),
},
agent: {
list: () => request<Agent[]>(config, "/agent"),
},
command: {
list: () => request<Command[]>(config, "/command"),
},
provider: {
list: () =>
request<{
all: Array<{
id: string
name: string
models: Record<
string,
{
id: string
name: string
attachment: boolean
reasoning: boolean
tool_call: boolean
cost?: { input: number; output: number }
limit: { context: number; output: number }
status?: "alpha" | "beta" | "deprecated" | "active"
}
>
}>
default: Record<string, string>
connected: string[]
}>(config, "/provider"),
},
config: {
get: () => request<unknown>(config, "/config"),
},
}
}
export type Client = ReturnType<typeof createClient>

80
src/lib/speech.ts Normal file
View File

@@ -0,0 +1,80 @@
import { useState, useCallback, useRef } from "react"
import { ExpoSpeechRecognitionModule, useSpeechRecognitionEvent } from "expo-speech-recognition"
interface SpeechState {
listening: boolean
transcript: string
error: string | null
}
interface SpeechActions {
start: () => Promise<void>
stop: () => void
cancel: () => void
}
export function useSpeech(onResult: (text: string) => void): SpeechState & SpeechActions {
const [listening, setListening] = useState(false)
const [transcript, setTranscript] = useState("")
const [error, setError] = useState<string | null>(null)
const pending = useRef("")
useSpeechRecognitionEvent("start", () => {
setListening(true)
setError(null)
setTranscript("")
pending.current = ""
})
useSpeechRecognitionEvent("end", () => {
setListening(false)
// Deliver final transcript
if (pending.current.trim()) {
onResult(pending.current.trim())
}
setTranscript("")
pending.current = ""
})
useSpeechRecognitionEvent("result", (event) => {
const text = event.results[0]?.transcript || ""
pending.current = text
setTranscript(text)
})
useSpeechRecognitionEvent("error", (event) => {
// "no-speech" is not really an error — user just didn't say anything
if (event.error === "no-speech") {
setListening(false)
return
}
setError(event.message || event.error)
setListening(false)
})
const start = useCallback(async () => {
const result = await ExpoSpeechRecognitionModule.requestPermissionsAsync()
if (!result.granted) {
setError("Microphone permission denied")
return
}
ExpoSpeechRecognitionModule.start({
lang: "en-US",
interimResults: true,
continuous: true,
})
}, [])
const stop = useCallback(() => {
ExpoSpeechRecognitionModule.stop()
}, [])
const cancel = useCallback(() => {
pending.current = ""
ExpoSpeechRecognitionModule.abort()
setListening(false)
setTranscript("")
}, [])
return { listening, transcript, error, start, stop, cancel }
}

32
src/lib/types.ts Normal file
View File

@@ -0,0 +1,32 @@
// Connection types for multiple server support
export type ConnectionType = "local" | "tunnel" | "cloud"
export interface ServerConnection {
id: string
name: string
type: ConnectionType
url: string
// For auth
username?: string
// Password stored separately in SecureStore
// Directory to use for this connection
directory?: string
// When last successfully connected
lastConnected?: number
// Is this the active connection?
active?: boolean
}
export interface AppSettings {
// Require biometric auth to open app
requireBiometric: boolean
// Require biometric to send messages
requireBiometricForMessages: boolean
// Theme preference
theme: "light" | "dark" | "system"
// Show notifications for task completion
notifications: boolean
}
// Re-export SDK types we'll use frequently
export type { Session, Message, Part, Project, Event, HealthResponse } from "./sdk"

135
src/stores/auth.ts Normal file
View File

@@ -0,0 +1,135 @@
import { create } from "zustand"
import * as LocalAuthentication from "expo-local-authentication"
import * as SecureStore from "expo-secure-store"
const AUTH_SETTINGS_KEY = "opencode_auth_settings"
interface AuthSettings {
requireBiometric: boolean
requireBiometricForMessages: boolean
}
interface AuthState {
isAuthenticated: boolean
isLoading: boolean
hasBiometrics: boolean
biometricType: LocalAuthentication.AuthenticationType | null
settings: AuthSettings
error: string | null
// Actions
initialize: () => Promise<void>
authenticate: () => Promise<boolean>
authenticateForMessage: () => Promise<boolean>
updateSettings: (settings: Partial<AuthSettings>) => Promise<void>
lock: () => void
}
const DEFAULT_SETTINGS: AuthSettings = {
requireBiometric: false,
requireBiometricForMessages: false,
}
export const useAuth = create<AuthState>((set, get) => ({
isAuthenticated: false,
isLoading: true,
hasBiometrics: false,
biometricType: null,
settings: DEFAULT_SETTINGS,
error: null,
initialize: async () => {
try {
set({ isLoading: true, error: null })
// Check biometric availability
const hasHardware = await LocalAuthentication.hasHardwareAsync()
const isEnrolled = await LocalAuthentication.isEnrolledAsync()
const hasBiometrics = hasHardware && isEnrolled
let biometricType: LocalAuthentication.AuthenticationType | null = null
if (hasBiometrics) {
const types = await LocalAuthentication.supportedAuthenticationTypesAsync()
biometricType = types[0] || null
}
// Load settings
const stored = await SecureStore.getItemAsync(AUTH_SETTINGS_KEY)
const settings: AuthSettings = stored ? JSON.parse(stored) : DEFAULT_SETTINGS
// If biometric is not required, auto-authenticate
const isAuthenticated = !settings.requireBiometric
set({
hasBiometrics,
biometricType,
settings,
isAuthenticated,
isLoading: false,
})
} catch (error) {
set({
error: "Failed to initialize authentication",
isLoading: false,
isAuthenticated: true, // Fail open for usability
})
}
},
authenticate: async () => {
const { settings, hasBiometrics } = get()
if (!settings.requireBiometric || !hasBiometrics) {
set({ isAuthenticated: true })
return true
}
try {
const result = await LocalAuthentication.authenticateAsync({
promptMessage: "Authenticate to access OpenCode",
fallbackLabel: "Use passcode",
disableDeviceFallback: false,
})
if (result.success) {
set({ isAuthenticated: true, error: null })
return true
}
set({ error: result.error || "Authentication failed" })
return false
} catch (error) {
set({ error: "Authentication error" })
return false
}
},
authenticateForMessage: async () => {
const { settings, hasBiometrics, isAuthenticated } = get()
if (!isAuthenticated) return false
if (!settings.requireBiometricForMessages || !hasBiometrics) return true
try {
const result = await LocalAuthentication.authenticateAsync({
promptMessage: "Authenticate to send message",
fallbackLabel: "Use passcode",
disableDeviceFallback: false,
})
return result.success
} catch {
return false
}
},
updateSettings: async (updates) => {
const settings = { ...get().settings, ...updates }
await SecureStore.setItemAsync(AUTH_SETTINGS_KEY, JSON.stringify(settings))
set({ settings })
},
lock: () => {
set({ isAuthenticated: false })
},
}))

139
src/stores/catalog.ts Normal file
View File

@@ -0,0 +1,139 @@
import { create } from "zustand"
import { useConnections } from "./connections"
import type { Agent, Command } from "../lib/sdk"
export interface ProviderModel {
id: string
name: string
reasoning: boolean
attachment: boolean
limit?: { context: number; output: number }
}
export interface Provider {
id: string
name: string
connected: boolean
models: ProviderModel[]
}
interface ModelSelection {
providerID: string
modelID: string
}
interface CatalogState {
agents: Agent[]
commands: Command[]
providers: Provider[]
defaults: Record<string, string>
// Current selections
agent: string // agent name, e.g. "build"
model: ModelSelection | null
loaded: boolean
// Actions
load: () => Promise<void>
setAgent: (name: string) => void
setModel: (selection: ModelSelection | null) => void
cycleAgent: (direction?: 1 | -1) => void
}
export const useCatalog = create<CatalogState>((set, get) => ({
agents: [],
commands: [],
providers: [],
defaults: {},
agent: "",
model: null,
loaded: false,
load: async () => {
const client = useConnections.getState().client
if (!client) return
const [agentResult, commandResult, providerResult] = await Promise.all([
client.agent.list().catch(() => [] as Agent[]),
client.command.list().catch(() => [] as Command[]),
client.provider.list().catch(() => null),
])
const agents = Array.isArray(agentResult) ? agentResult : []
const commands = Array.isArray(commandResult) ? commandResult : []
// Parse provider response: { all: [...], default: {...}, connected: [...] }
const raw = providerResult
const connected = new Set(Array.isArray(raw?.connected) ? raw.connected : [])
const defaults = raw?.default || {}
const providers: Provider[] = Array.isArray(raw?.all)
? raw.all
.filter((p) => connected.has(p.id))
.map((p) => ({
id: p.id,
name: p.name || p.id,
connected: connected.has(p.id),
models: Object.values(p.models || {})
.filter((m) => m.status !== "deprecated")
.map((m) => ({
id: m.id,
name: m.name || m.id,
reasoning: m.reasoning ?? false,
attachment: m.attachment ?? false,
limit: m.limit,
})),
}))
.filter((p) => p.models.length > 0)
: []
console.log(
"[catalog] loaded:",
agents.length,
"agents,",
commands.length,
"commands,",
providers.length,
"providers (" + providers.reduce((n, p) => n + p.models.length, 0) + " models)",
)
// Filter out hidden agents
const visible = agents.filter((a) => !a.hidden)
// Default agent
const current = get().agent
const agent = current && visible.some((a) => a.name === current) ? current : visible[0]?.name || "build"
// Default model: use default agent's model, or first connected provider's default model
const existing = get().model
const fallback = (() => {
const defaultAgent = visible[0]
if (defaultAgent?.model) return defaultAgent.model
for (const p of providers) {
const defaultModelID = defaults[p.id]
const match = defaultModelID ? p.models.find((m) => m.id === defaultModelID) : p.models[0]
if (match) return { providerID: p.id, modelID: match.id }
}
return null
})()
const model = existing || fallback
set({ agents: visible, commands, providers, defaults, agent, model, 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 })
},
setModel: (selection) => set({ model: selection }),
cycleAgent: (direction = 1) => {
const { agents, agent } = get()
const primary = agents.filter((a) => a.mode === "primary" || a.mode === "all")
if (primary.length < 2) return
const idx = primary.findIndex((a) => a.name === agent)
const next = (idx + direction + primary.length) % primary.length
get().setAgent(primary[next].name)
},
}))

312
src/stores/connections.ts Normal file
View File

@@ -0,0 +1,312 @@
import { create } from "zustand"
import * as SecureStore from "expo-secure-store"
import type { ServerConnection, ConnectionType } from "../lib/types"
import { createClient, type Client, type Project } from "../lib/sdk"
const CONNECTIONS_KEY = "opencode_connections"
const PASSWORDS_PREFIX = "opencode_password_"
const RECENT_DIRS_KEY = "opencode_recent_dirs"
const MAX_RECENT_DIRS = 10
// Cached auth so we can create directory-scoped clients without async SecureStore lookups
interface ClientBase {
baseUrl: string
auth?: { username: string; password: string }
}
interface ConnectionsState {
connections: ServerConnection[]
activeConnection: ServerConnection | null
client: Client | null
clientBase: ClientBase | null
currentProject: Project | null
serverHome: string | null // Home directory on the server machine (for ~ expansion)
recentDirectories: string[]
isLoading: boolean
error: string | null
// Actions
loadConnections: () => Promise<void>
addConnection: (connection: Omit<ServerConnection, "id">, password?: string) => Promise<void>
removeConnection: (id: string) => Promise<void>
setActiveConnection: (id: string) => Promise<void>
testConnection: (connection: ServerConnection, password?: string) => Promise<boolean>
updateConnection: (id: string, updates: Partial<ServerConnection>) => Promise<void>
refreshProject: () => Promise<void>
// Create a one-off client pointing at a specific directory (for cross-project operations)
clientForDirectory: (directory: string) => Client | null
// Switch the active connection's directory and reload
switchDirectory: (directory?: string) => Promise<void>
// Record a directory as recently used
addRecentDirectory: (directory: string) => Promise<void>
}
function generateId(): string {
return Math.random().toString(36).slice(2, 11)
}
function buildClient(
url: string,
directory?: string,
auth?: { username: string; password: string },
): { client: Client; base: ClientBase } {
const base: ClientBase = { baseUrl: url, auth }
const client = createClient({ baseUrl: url, directory, auth })
return { client, base }
}
export const useConnections = create<ConnectionsState>((set, get) => ({
connections: [],
activeConnection: null,
client: null,
clientBase: null,
serverHome: null,
currentProject: null,
recentDirectories: [],
isLoading: true,
error: null,
loadConnections: async () => {
try {
set({ isLoading: true, error: null })
const [stored, recentRaw] = await Promise.all([
SecureStore.getItemAsync(CONNECTIONS_KEY),
SecureStore.getItemAsync(RECENT_DIRS_KEY),
])
const connections: ServerConnection[] = stored ? JSON.parse(stored) : []
const recentDirectories: string[] = recentRaw ? JSON.parse(recentRaw) : []
// Find active connection
const active = connections.find((c) => c.active) || null
// Create client for active connection
let client: Client | null = null
let base: ClientBase | null = null
let project: Project | null = null
let home: string | null = null
if (active) {
const password = await SecureStore.getItemAsync(`${PASSWORDS_PREFIX}${active.id}`)
const auth = active.username && password ? { username: active.username, password } : undefined
const built = buildClient(active.url, active.directory, auth)
client = built.client
base = built.base
// Fetch current project info and server paths
try {
const [proj, paths] = await Promise.all([
client.project.current().catch(() => null),
client.path.get().catch(() => null),
])
project = proj
home = paths?.home || null
} catch {
// Server might be offline
}
}
set({
connections,
activeConnection: active,
client,
clientBase: base,
currentProject: project,
serverHome: home,
recentDirectories,
isLoading: false,
})
} catch (error) {
set({ error: "Failed to load connections", isLoading: false })
}
},
addConnection: async (connection, password) => {
const id = generateId()
const newConnection: ServerConnection = {
...connection,
id,
active: get().connections.length === 0, // First connection is active
}
const connections = [...get().connections, newConnection]
// Store password separately if provided
if (password) {
await SecureStore.setItemAsync(`${PASSWORDS_PREFIX}${id}`, password)
}
await SecureStore.setItemAsync(CONNECTIONS_KEY, JSON.stringify(connections))
// If this is the first/active connection, create client
let client = get().client
let base = get().clientBase
let activeConnection = get().activeConnection
if (newConnection.active) {
activeConnection = newConnection
const auth = newConnection.username && password ? { username: newConnection.username, password } : undefined
const built = buildClient(newConnection.url, newConnection.directory, auth)
client = built.client
base = built.base
}
set({ connections, activeConnection, client, clientBase: base })
},
removeConnection: async (id) => {
const connections = get().connections.filter((c) => c.id !== id)
// Remove stored password
await SecureStore.deleteItemAsync(`${PASSWORDS_PREFIX}${id}`)
await SecureStore.setItemAsync(CONNECTIONS_KEY, JSON.stringify(connections))
// If removing active connection, clear client
const wasActive = get().activeConnection?.id === id
if (wasActive) {
const newActive = connections[0] || null
if (newActive) {
// Mark new connection as active
newActive.active = true
await SecureStore.setItemAsync(CONNECTIONS_KEY, JSON.stringify(connections))
const password = await SecureStore.getItemAsync(`${PASSWORDS_PREFIX}${newActive.id}`)
const auth = newActive.username && password ? { username: newActive.username, password } : undefined
const built = buildClient(newActive.url, newActive.directory, auth)
set({ connections, activeConnection: newActive, client: built.client, clientBase: built.base })
} else {
set({ connections, activeConnection: null, client: null, clientBase: null })
}
} else {
set({ connections })
}
},
setActiveConnection: async (id) => {
const connections = get().connections.map((c) => ({
...c,
active: c.id === id,
}))
await SecureStore.setItemAsync(CONNECTIONS_KEY, JSON.stringify(connections))
const active = connections.find((c) => c.id === id) || null
let client: Client | null = null
let base: ClientBase | null = null
let project: Project | null = null
let home: string | null = null
if (active) {
const password = await SecureStore.getItemAsync(`${PASSWORDS_PREFIX}${active.id}`)
const auth = active.username && password ? { username: active.username, password } : undefined
const built = buildClient(active.url, active.directory, auth)
client = built.client
base = built.base
try {
const [proj, paths] = await Promise.all([
client.project.current().catch(() => null),
client.path.get().catch(() => null),
])
project = proj
home = paths?.home || null
} catch {
// Server might be offline
}
// Update last connected time
active.lastConnected = Date.now()
await SecureStore.setItemAsync(CONNECTIONS_KEY, JSON.stringify(connections))
}
set({ connections, activeConnection: active, client, clientBase: base, currentProject: project, serverHome: home })
},
testConnection: async (connection, password) => {
try {
const client = createClient({
baseUrl: connection.url,
directory: connection.directory,
auth: connection.username && password ? { username: connection.username, password } : undefined,
})
await client.global.health()
return true
} catch {
return false
}
},
updateConnection: async (id, updates) => {
const connections = get().connections.map((c) => (c.id === id ? { ...c, ...updates } : c))
await SecureStore.setItemAsync(CONNECTIONS_KEY, JSON.stringify(connections))
// If updating active connection, recreate client
if (get().activeConnection?.id === id) {
const active = connections.find((c) => c.id === id)!
const password = await SecureStore.getItemAsync(`${PASSWORDS_PREFIX}${id}`)
const auth = active.username && password ? { username: active.username, password } : undefined
const built = buildClient(active.url, active.directory, auth)
try {
const [project, paths] = await Promise.all([
built.client.project.current().catch(() => null),
built.client.path.get().catch(() => null),
])
set({
connections,
activeConnection: active,
client: built.client,
clientBase: built.base,
currentProject: project,
serverHome: paths?.home || null,
})
} catch {
set({
connections,
activeConnection: active,
client: built.client,
clientBase: built.base,
currentProject: null,
})
}
} else {
set({ connections })
}
},
refreshProject: async () => {
const client = get().client
if (!client) return
try {
const project = await client.project.current()
set({ currentProject: project })
} catch {
set({ currentProject: null })
}
},
clientForDirectory: (directory) => {
const base = get().clientBase
if (!base) return null
// Reuse current client if directory matches
const active = get().activeConnection
if (active?.directory === directory) return get().client
return createClient({ baseUrl: base.baseUrl, directory, auth: base.auth })
},
switchDirectory: async (directory) => {
const active = get().activeConnection
if (!active) return
// Update connection directory and recreate client
const dir = directory?.trim() || undefined
await get().updateConnection(active.id, { directory: dir })
// Record in recents if it's a real directory
if (dir) await get().addRecentDirectory(dir)
},
addRecentDirectory: async (directory) => {
const current = get().recentDirectories
// Move to front, dedup, cap at MAX
const updated = [directory, ...current.filter((d) => d !== directory)].slice(0, MAX_RECENT_DIRS)
set({ recentDirectories: updated })
await SecureStore.setItemAsync(RECENT_DIRS_KEY, JSON.stringify(updated))
},
}))

371
src/stores/events.ts Normal file
View File

@@ -0,0 +1,371 @@
import { create } from "zustand"
import { useConnections } from "./connections"
import { useSessions } from "./sessions"
import { send as notify } from "../lib/notifications"
import type { Client, Part, Session, Message } from "../lib/sdk"
// Session status from the server
type SessionStatus = { type: "idle" } | { type: "busy" } | { type: "retry"; attempt: number; message: string }
// Tool status labels derived from part type
const TOOL_STATUS: Record<string, string> = {
read: "Gathering context...",
list: "Searching codebase...",
grep: "Searching codebase...",
glob: "Searching codebase...",
webfetch: "Searching web...",
edit: "Making edits...",
write: "Making edits...",
apply_patch: "Making edits...",
bash: "Running command...",
task: "Delegating...",
todowrite: "Planning...",
todoread: "Planning...",
}
function statusFromPart(part: Part): string {
if (part.type === "reasoning") return "Thinking..."
if (part.type === "tool" && part.tool) return TOOL_STATUS[part.tool] || `Running ${part.tool}...`
if (part.type === "text") return "Writing..."
return "Working..."
}
interface EventsState {
connected: boolean
reconnectAttempts: number
lastDisconnectAt: number | null
sessionStatus: Record<string, SessionStatus>
statusText: Record<string, string>
// Permissions & questions (pending per session)
permissions: Record<
string,
Array<{
id: string
sessionID: string
permission: string
patterns: string[]
metadata: Record<string, unknown>
tool?: { messageID: string; callID: string }
}>
>
questions: Record<
string,
Array<{
id: string
sessionID: string
questions: Array<{
question: string
header: string
options: Array<{ label: string; description: string }>
multiple?: boolean
custom?: boolean
}>
tool?: { messageID: string; callID: string }
}>
>
connect: () => void
disconnect: () => void
}
let controller: AbortController | null = null
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
const RECONNECT_DELAYS_MS = [1000, 2000, 4000, 8000, 15000] as const
const STABLE_CONNECTION_MS = 10_000
const PROLONGED_DISCONNECT_MS = 30_000
// Re-fetch pending permissions and questions from the server for a session.
// Called when entering a session to recover from missed SSE events or failed
// optimistic removals.
export async function refreshPending(client: Client, sessionID: string) {
try {
const [perms, questions] = await Promise.all([client.permission.list(), client.question.list()])
const sessionPerms = (perms || []).filter((p: Record<string, unknown>) => p.sessionID === sessionID)
const sessionQuestions = (questions || []).filter((q: Record<string, unknown>) => q.sessionID === sessionID)
useEvents.setState((state) => ({
permissions: { ...state.permissions, [sessionID]: sessionPerms as any },
questions: { ...state.questions, [sessionID]: sessionQuestions as any },
}))
} catch (err) {
console.warn("[Events] Failed to refresh pending:", err)
}
}
export const useEvents = create<EventsState>((set, get) => ({
connected: false,
reconnectAttempts: 0,
lastDisconnectAt: null,
sessionStatus: {},
statusText: {},
permissions: {},
questions: {},
connect: () => {
controller?.abort()
controller = null
if (reconnectTimer) {
clearTimeout(reconnectTimer)
reconnectTimer = null
}
const client = useConnections.getState().client
if (!client) return
controller = new AbortController()
const currentController = controller
set({ connected: true })
console.log("[SSE] Connecting to event stream...")
// Run in background
;(async () => {
let reconnectScheduled = false
const stableTimer = setTimeout(() => {
if (!currentController.signal.aborted) {
set({ reconnectAttempts: 0, lastDisconnectAt: null })
}
}, STABLE_CONNECTION_MS)
const scheduleReconnect = (reason: unknown) => {
if (reconnectScheduled || currentController.signal.aborted) return
reconnectScheduled = true
const state = get()
const reconnectAttempts = state.reconnectAttempts + 1
const lastDisconnectAt = state.lastDisconnectAt ?? Date.now()
const disconnectedFor = Date.now() - lastDisconnectAt
set({ connected: false, reconnectAttempts, lastDisconnectAt })
if (disconnectedFor >= PROLONGED_DISCONNECT_MS) {
notify({
category: "connection",
title: "Connection interrupted",
body: "Trying to reconnect to your server",
sessionId: "",
dedupeKey: "sse-prolonged-disconnect",
dedupeCooldownMs: 60_000,
})
}
const baseDelay = RECONNECT_DELAYS_MS[Math.min(reconnectAttempts - 1, RECONNECT_DELAYS_MS.length - 1)]
const jitteredDelay = Math.min(15_000, Math.round(baseDelay * (0.75 + Math.random() * 0.5)))
console.warn(`[SSE] Connection lost, reconnecting in ${jitteredDelay}ms:`, reason)
reconnectTimer = setTimeout(() => {
reconnectTimer = null
get().connect()
}, jitteredDelay)
}
try {
for await (const event of client.global.events(currentController.signal)) {
if (currentController.signal.aborted) break
const payload = (event as any).payload || event
const type = payload.type as string
const props = payload.properties || {}
switch (type) {
case "session.status": {
const sessionID = props.sessionID as string
const status = props.status as SessionStatus
if (!sessionID) break
// Detect busy → idle transition for completion notification
const previous = get().sessionStatus[sessionID]
const completed = previous?.type === "busy" && status.type === "idle"
set((state) => ({
sessionStatus: { ...state.sessionStatus, [sessionID]: status },
// Clear status text when idle
statusText: status.type === "idle" ? { ...state.statusText, [sessionID]: "" } : state.statusText,
}))
// SSE is the source of truth — update sending state unconditionally
if (status.type === "idle") {
useSessions.setState((state) => ({
sending: { ...state.sending, [sessionID]: false },
}))
// Refresh messages if this is the session the user is viewing
const sessions = useSessions.getState()
if (sessions.currentSession?.id === sessionID) {
sessions.refreshMessages()
}
}
if (completed) {
const match = useSessions.getState().sessions.find((s) => s.id === sessionID)
notify({
category: "completed",
title: "Task completed",
body: match?.title || "Session finished processing",
sessionId: sessionID,
})
}
break
}
case "message.updated": {
const info = props.info as Message | undefined
if (!info) break
useSessions.getState().handleEvent({ type, properties: { info } } as any)
break
}
case "message.part.updated": {
const part = props.part as Part | undefined
if (!part) break
// Update status text from the latest part
const sessionID = (part as any).sessionID as string
if (sessionID) {
set((state) => ({
statusText: { ...state.statusText, [sessionID]: statusFromPart(part) },
}))
}
useSessions.getState().handleEvent({ type, properties: { part } } as any)
break
}
case "session.updated": {
const info = props.info as Session | undefined
if (!info) break
useSessions.getState().handleEvent({ type, properties: { info } } as any)
break
}
case "session.created": {
const info = props.info as Session | undefined
if (!info) break
// Add to sessions list
useSessions.setState((state) => {
const exists = state.sessions.some((s) => s.id === info.id)
if (exists) return {}
return { sessions: [info, ...state.sessions] }
})
break
}
case "session.error": {
const error = props.error as { message?: string } | undefined
const sessionID = props.sessionID as string
if (!sessionID) break
// Clear sending state unconditionally — SSE is truth
useSessions.setState((state) => ({
sending: { ...state.sending, [sessionID]: false },
// Surface error only if user is viewing this session
...(state.currentSession?.id === sessionID
? { error: error?.message || "Session error occurred" }
: {}),
}))
if (useSessions.getState().currentSession?.id === sessionID) {
useSessions.getState().refreshMessages()
}
notify({
category: "errors",
title: "Session error",
body: error?.message || "Something went wrong",
sessionId: sessionID,
})
break
}
case "permission.asked": {
const req = props as any
if (!req.id || !req.sessionID) break
const existing = get().permissions[req.sessionID] || []
if (existing.some((item) => item.id === req.id)) break
set((state) => ({
permissions: {
...state.permissions,
[req.sessionID]: [...(state.permissions[req.sessionID] || []), req],
},
}))
notify({
category: "permissions",
title: req.permission || "Permission requested",
body: req.patterns?.join(", ") || "A tool needs your approval",
sessionId: req.sessionID,
})
break
}
case "permission.replied": {
const sessionID = props.sessionID as string
const requestID = props.requestID as string
if (!sessionID || !requestID) break
set((state) => ({
permissions: {
...state.permissions,
[sessionID]: (state.permissions[sessionID] || []).filter((p) => p.id !== requestID),
},
}))
break
}
case "question.asked": {
const req = props as any
if (!req.id || !req.sessionID) break
const existing = get().questions[req.sessionID] || []
if (existing.some((item) => item.id === req.id)) break
set((state) => ({
questions: {
...state.questions,
[req.sessionID]: [...(state.questions[req.sessionID] || []), req],
},
}))
notify({
category: "questions",
title: req.questions?.[0]?.header || "Input needed",
body: req.questions?.[0]?.question || "The assistant has a question",
sessionId: req.sessionID,
})
break
}
case "question.replied":
case "question.rejected": {
const sessionID = props.sessionID as string
const requestID = props.requestID as string
if (!sessionID || !requestID) break
set((state) => ({
questions: {
...state.questions,
[sessionID]: (state.questions[sessionID] || []).filter((q) => q.id !== requestID),
},
}))
break
}
}
}
scheduleReconnect(new Error("Event stream closed"))
} catch (err) {
scheduleReconnect(err)
} finally {
clearTimeout(stableTimer)
if (currentController.signal.aborted) {
console.log("[SSE] Disconnected (aborted)")
}
}
})()
},
disconnect: () => {
console.log("[SSE] Disconnecting")
if (reconnectTimer) {
clearTimeout(reconnectTimer)
reconnectTimer = null
}
controller?.abort()
controller = null
set({
connected: false,
reconnectAttempts: 0,
lastDisconnectAt: null,
sessionStatus: {},
statusText: {},
permissions: {},
questions: {},
})
},
}))

374
src/stores/sessions.ts Normal file
View File

@@ -0,0 +1,374 @@
import { create } from "zustand"
import type { Session, Message, Part, Event, MessageWithParts, Client } from "../lib/sdk"
import { useConnections } from "./connections"
import { useSettings } from "./settings"
// Helper to convert API response to our internal format
function parseMessages(response: MessageWithParts[]): { messages: Message[]; parts: Record<string, Part[]> } {
const messages: Message[] = []
const parts: Record<string, Part[]> = {}
for (const item of response || []) {
messages.push(item.info)
parts[item.info.id] = item.parts || []
}
return { messages, parts }
}
function pageSize(): number {
return useSettings.getState().pageSize
}
interface SessionsState {
sessions: Session[]
currentSession: Session | null
messages: Message[]
parts: Record<string, Part[]>
isLoading: boolean
// Per-session optimistic sending flag — bridging gap between user tap and SSE busy
sending: Record<string, boolean>
loadingMore: boolean
hasMore: boolean
error: string | null
// Actions
loadSessions: () => Promise<void>
selectSession: (sessionID: string, directory?: string) => Promise<void>
loadOlderMessages: () => Promise<void>
createSession: (title?: string) => Promise<Session | null>
deleteSession: (sessionID: string) => Promise<void>
sendMessage: (
text: string,
model?: { providerID: string; modelID: string },
agent?: string,
files?: Array<{ uri: string; mime: string; filename?: string; base64?: string }>,
) => Promise<void>
abortSession: () => Promise<void>
refreshMessages: () => Promise<void>
// Event handling
handleEvent: (event: Event) => void
}
// Get the right client for a session's directory
function clientFor(directory?: string): Client | null {
const connState = useConnections.getState()
if (!directory) return connState.client
const connDir = connState.activeConnection?.directory
if (directory !== connDir) return connState.clientForDirectory(directory)
return connState.client
}
export const useSessions = create<SessionsState>((set, get) => ({
sessions: [],
currentSession: null,
messages: [],
parts: {},
isLoading: false,
sending: {},
loadingMore: false,
hasMore: false,
error: null,
loadSessions: async () => {
const client = useConnections.getState().client
if (!client) {
set({ error: "No active connection" })
return
}
try {
set({ isLoading: true, error: null })
const sessions = await client.session.list({ roots: true, limit: 50 })
set({ sessions, isLoading: false })
} catch (error) {
set({ error: "Failed to load sessions", isLoading: false })
}
},
selectSession: async (sessionID, directory) => {
// Use directory-specific client if the session belongs to a different project
const connState = useConnections.getState()
const client = directory ? connState.clientForDirectory(directory) : connState.client
if (!client) {
set({ error: "No active connection" })
return
}
try {
// Reset optimistic sending — SSE sessionStatus is the source of truth
set((state) => ({
isLoading: true,
error: null,
hasMore: false,
loadingMore: false,
sending: { ...state.sending, [sessionID]: false },
}))
const [session, messagesResponse] = await Promise.all([
client.session.get(sessionID),
client.session.messages(sessionID, { limit: pageSize() }),
])
// Parse the API response format: array of { info, parts }
const { messages, parts } = parseMessages(messagesResponse)
set({
currentSession: session,
messages,
parts,
isLoading: false,
// If we got exactly PAGE_SIZE messages, there are probably more
hasMore: messagesResponse.length >= pageSize(),
})
} catch (err) {
console.error("Failed to load session:", err)
set({ error: "Failed to load session", isLoading: false })
}
},
loadOlderMessages: async () => {
const client = clientFor(get().currentSession?.directory)
const session = get().currentSession
if (!client || !session) return
if (get().loadingMore || !get().hasMore) return
try {
set({ loadingMore: true })
// Fetch ALL messages for this session
const response = await client.session.messages(session.id)
const { messages: all, parts: allParts } = parseMessages(response)
// Merge: use all messages from full fetch, but keep any temp/optimistic messages
const existing = get().messages
const temp = existing.filter((m) => m.id.startsWith("temp-"))
const merged = [...all, ...temp]
set({
messages: merged,
parts: { ...allParts, ...Object.fromEntries(temp.map((m) => [m.id, get().parts[m.id] || []])) },
loadingMore: false,
hasMore: false, // We loaded everything
})
} catch (error) {
console.error("Failed to load older messages:", error)
set({ loadingMore: false })
}
},
createSession: async (title) => {
const client = useConnections.getState().client
if (!client) {
set({ error: "No active connection" })
return null
}
try {
const session = await client.session.create({ title })
// Don't optimistically add to sessions list — let loadSessions() handle it
// to avoid duplicate key errors from race conditions
set({
currentSession: session,
messages: [],
parts: {},
hasMore: false,
loadingMore: false,
})
return session
} catch (error) {
set({ error: "Failed to create session" })
return null
}
},
deleteSession: async (sessionID) => {
const client = useConnections.getState().client
if (!client) {
set({ error: "No active connection" })
return
}
try {
await client.session.delete(sessionID)
set((state) => ({
sessions: state.sessions.filter((s) => s.id !== sessionID),
currentSession: state.currentSession?.id === sessionID ? null : state.currentSession,
messages: state.currentSession?.id === sessionID ? [] : state.messages,
parts: state.currentSession?.id === sessionID ? {} : state.parts,
}))
} catch (error) {
set({ error: "Failed to delete session" })
}
},
sendMessage: async (text, model, agent, files) => {
const client = clientFor(get().currentSession?.directory)
const session = get().currentSession
if (!client || !session) {
set({ error: "No active session" })
return
}
try {
set((state) => ({ sending: { ...state.sending, [session.id]: true }, error: null }))
// Add user message optimistically
const ts = Date.now()
const userMessage: Message = {
id: `temp-${ts}`,
sessionID: session.id,
role: "user",
time: { created: ts },
model,
agent,
}
const optimisticParts: Part[] = []
if (text) {
optimisticParts.push({
id: `temp-part-text-${ts}`,
messageID: userMessage.id,
type: "text",
text,
})
}
if (files) {
for (let i = 0; i < files.length; i++) {
const f = files[i]
optimisticParts.push({
id: `temp-part-file-${ts}-${i}`,
messageID: userMessage.id,
type: "file",
mime: f.mime,
url: f.uri,
filename: f.filename,
})
}
}
set((state) => ({
messages: [...state.messages, userMessage],
parts: { ...state.parts, [userMessage.id]: optimisticParts },
}))
// Build prompt parts - images are already converted to JPEG with base64 by toJpeg()
const promptParts: Array<
{ type: "text"; text: string } | { type: "file"; mime: string; url: string; filename?: string }
> = []
if (text) {
promptParts.push({ type: "text", text })
}
if (files) {
for (const f of files) {
const url = f.base64 ? `data:${f.mime};base64,${f.base64}` : f.uri
promptParts.push({ type: "file", mime: f.mime, url, filename: f.filename })
}
}
// Fire and forget - SSE events will update messages/parts/status in real-time
client.session.prompt(session.id, { parts: promptParts, model, agent }).catch((err) => {
console.error("Failed to send message:", err)
set((state) => ({ error: String(err), sending: { ...state.sending, [session.id]: false } }))
get().refreshMessages()
})
} catch (err) {
console.error("[sendMessage] error:", err)
set((state) => ({ error: String(err), sending: { ...state.sending, [session.id]: false } }))
get().refreshMessages()
}
},
abortSession: async () => {
const client = clientFor(get().currentSession?.directory)
const session = get().currentSession
if (!client || !session) return
try {
await client.session.abort(session.id)
set((state) => ({ sending: { ...state.sending, [session.id]: false } }))
} catch {
set({ error: "Failed to abort session" })
}
},
refreshMessages: async () => {
const client = clientFor(get().currentSession?.directory)
const session = get().currentSession
if (!client || !session) return
try {
const response = await client.session.messages(session.id)
const { messages, parts } = parseMessages(response)
set({ messages, parts })
} catch (error) {
set({ error: "Failed to refresh messages" })
}
},
handleEvent: (event) => {
const { currentSession } = get()
if (!currentSession) return
const props = (event as any).properties || {}
switch (event.type) {
case "message.updated": {
const message = (props.info || props.message) as Message | undefined
if (!message || message.sessionID !== currentSession.id) return
set((state) => {
// Remove temp messages when real ones arrive
const filtered = state.messages.filter((m) => !m.id.startsWith("temp-") || m.id === message.id)
const exists = filtered.some((m) => m.id === message.id)
return {
messages: exists ? filtered.map((m) => (m.id === message.id ? message : m)) : [...filtered, message],
}
})
break
}
case "message.part.updated": {
const part = props.part as Part | undefined
if (!part) return
// Only handle parts for current session
if (part.sessionID && part.sessionID !== currentSession.id) return
set((state) => {
const messageParts = state.parts[part.messageID] || []
const exists = messageParts.some((p) => p.id === part.id)
return {
parts: {
...state.parts,
[part.messageID]: exists
? messageParts.map((p) => (p.id === part.id ? part : p))
: [...messageParts, part],
},
}
})
break
}
case "message.removed": {
const messageID = props.messageID as string
if (!messageID) return
set((state) => ({
messages: state.messages.filter((m) => m.id !== messageID),
parts: Object.fromEntries(Object.entries(state.parts).filter(([k]) => k !== messageID)),
}))
break
}
case "session.updated": {
const session = (props.info || props) as Session | undefined
if (!session?.id) return
set((state) => ({
sessions: state.sessions.map((s) => (s.id === session.id ? session : s)),
currentSession: state.currentSession?.id === session.id ? session : state.currentSession,
}))
break
}
}
},
}))

59
src/stores/settings.ts Normal file
View File

@@ -0,0 +1,59 @@
import { create } from "zustand"
import * as SecureStore from "expo-secure-store"
import { type Category, defaultPreferences } from "../lib/notifications"
const SETTINGS_KEY = "opencode_settings"
interface Settings {
pageSize: number
notifications: Record<Category, boolean>
}
const DEFAULTS: Settings = {
pageSize: 25,
notifications: { ...defaultPreferences },
}
interface SettingsState extends Settings {
loaded: boolean
load: () => Promise<void>
setPageSize: (size: number) => Promise<void>
setNotification: (category: Category, enabled: boolean) => Promise<void>
}
function snapshot(get: () => SettingsState): Settings {
return { pageSize: get().pageSize, notifications: get().notifications }
}
async function persist(settings: Settings) {
await SecureStore.setItemAsync(SETTINGS_KEY, JSON.stringify(settings))
}
export const useSettings = create<SettingsState>((set, get) => ({
...DEFAULTS,
loaded: false,
load: async () => {
const raw = await SecureStore.getItemAsync(SETTINGS_KEY)
if (raw) {
const parsed = JSON.parse(raw) as Partial<Settings>
// Merge stored notifications with defaults so new categories get their default
const notifications = { ...DEFAULTS.notifications, ...parsed.notifications }
set({ ...DEFAULTS, ...parsed, notifications, loaded: true })
return
}
set({ loaded: true })
},
setPageSize: async (size) => {
const clamped = Math.max(10, Math.min(200, size))
set({ pageSize: clamped })
await persist({ ...snapshot(get), pageSize: clamped })
},
setNotification: async (category, enabled) => {
const notifications = { ...get().notifications, [category]: enabled }
set({ notifications })
await persist({ ...snapshot(get), notifications })
},
}))

31
src/types/expo-icons.d.ts vendored Normal file
View File

@@ -0,0 +1,31 @@
// Override @expo/vector-icons types to fix React 18/19 JSX element type mismatch.
// The library bundles @types/react@18 internally which conflicts with our React 19.
// This declaration makes Ionicons (and other icon sets) work as valid JSX elements.
declare module "@expo/vector-icons" {
import type { TextProps } from "react-native"
interface IconProps extends TextProps {
name: string
size?: number
color?: string
style?: any
}
interface IconComponent extends React.FC<IconProps> {
glyphMap: Record<string, number>
}
export const Ionicons: IconComponent
export const MaterialIcons: IconComponent
export const MaterialCommunityIcons: IconComponent
export const FontAwesome: IconComponent
export const FontAwesome5: IconComponent
export const Feather: IconComponent
export const AntDesign: IconComponent
export const Entypo: IconComponent
export const EvilIcons: IconComponent
export const Foundation: IconComponent
export const Octicons: IconComponent
export const SimpleLineIcons: IconComponent
export const Zocial: IconComponent
}

100
src/types/react-native-marked.d.ts vendored Normal file
View File

@@ -0,0 +1,100 @@
// Override react-native-marked types to fix React 18/19 ReactNode mismatch.
// The library bundles @types/react@18 internally which conflicts with our React 19.
// This declaration re-exports the module with React 19-compatible types.
declare module "react-native-marked" {
import type { ReactNode } from "react"
import type { FlatListProps, ImageStyle, TextStyle, ViewStyle } from "react-native"
import type { Hooks, Tokenizer } from "marked"
export interface RendererInterface {
paragraph(children: ReactNode[], styles?: ViewStyle): ReactNode
blockquote(children: ReactNode[], styles?: ViewStyle): ReactNode
heading(text: string | ReactNode[], styles?: TextStyle, depth?: number): ReactNode
code(text: string, language?: string, containerStyle?: ViewStyle, textStyle?: TextStyle): ReactNode
hr(styles?: ViewStyle): ReactNode
listItem(children: ReactNode[], styles?: ViewStyle): ReactNode
list(
ordered: boolean,
li: ReactNode[],
listStyle?: ViewStyle,
textStyle?: TextStyle,
startIndex?: number,
): ReactNode
escape(text: string, styles?: TextStyle): ReactNode
link(children: string | ReactNode[], href: string, styles?: TextStyle, title?: string): ReactNode
image(uri: string, alt?: string, style?: ImageStyle, title?: string): ReactNode
strong(children: string | ReactNode[], styles?: TextStyle): ReactNode
em(children: string | ReactNode[], styles?: TextStyle): ReactNode
codespan(text: string, styles?: TextStyle): ReactNode
br(): ReactNode
del(children: string | ReactNode[], styles?: TextStyle): ReactNode
text(text: string | ReactNode[], styles?: TextStyle): ReactNode
html(text: string | ReactNode[], styles?: TextStyle): ReactNode
linkImage(href: string, imageUrl: string, alt?: string, style?: ImageStyle, title?: string | null): ReactNode
table(
header: ReactNode[][],
rows: ReactNode[][][],
tableStyle?: ViewStyle,
rowStyle?: ViewStyle,
cellStyle?: ViewStyle,
): ReactNode
}
export class Renderer implements RendererInterface {
paragraph(children: ReactNode[], styles?: ViewStyle): ReactNode
blockquote(children: ReactNode[], styles?: ViewStyle): ReactNode
heading(text: string | ReactNode[], styles?: TextStyle): ReactNode
code(text: string, language?: string, containerStyle?: ViewStyle, textStyle?: TextStyle): ReactNode
hr(styles?: ViewStyle): ReactNode
listItem(children: ReactNode[], styles?: ViewStyle): ReactNode
list(
ordered: boolean,
li: ReactNode[],
listStyle?: ViewStyle,
textStyle?: TextStyle,
startIndex?: number,
): ReactNode
escape(text: string, styles?: TextStyle): ReactNode
link(children: string | ReactNode[], href: string, styles?: TextStyle, title?: string): ReactNode
image(uri: string, alt?: string, style?: ImageStyle, title?: string): ReactNode
strong(children: string | ReactNode[], styles?: TextStyle): ReactNode
em(children: string | ReactNode[], styles?: TextStyle): ReactNode
codespan(text: string, styles?: TextStyle): ReactNode
br(): ReactNode
del(children: string | ReactNode[], styles?: TextStyle): ReactNode
text(text: string | ReactNode[], styles?: TextStyle): ReactNode
html(text: string | ReactNode[], styles?: TextStyle): ReactNode
linkImage(href: string, imageUrl: string, alt?: string, style?: ImageStyle, title?: string | null): ReactNode
table(
header: ReactNode[][],
rows: ReactNode[][][],
tableStyle?: ViewStyle,
rowStyle?: ViewStyle,
cellStyle?: ViewStyle,
): ReactNode
getKey(): string
}
export interface UserTheme {
colors?: Record<string, string>
spacing?: Record<string, number>
}
export interface MarkedStyles {
[key: string]: ViewStyle | TextStyle | ImageStyle | undefined
}
export interface MarkdownProps {
value: string
flatListProps?: Omit<FlatListProps<ReactNode>, "data" | "renderItem" | "horizontal">
theme?: UserTheme
renderer?: RendererInterface
styles?: MarkedStyles | Record<string, unknown>
baseUrl?: string
tokenizer?: Tokenizer
hooks?: Hooks
}
const Markdown: React.FC<MarkdownProps>
export default Markdown
}