Initial commit: OpenCode mobile app (extracted from opencode monorepo)
This commit is contained in:
110
src/components/AuthGate.tsx
Normal file
110
src/components/AuthGate.tsx
Normal 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",
|
||||
},
|
||||
})
|
||||
147
src/components/chat/DiffView.tsx
Normal file
147
src/components/chat/DiffView.tsx
Normal 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" },
|
||||
})
|
||||
246
src/components/chat/DirectorySwitcher.tsx
Normal file
246
src/components/chat/DirectorySwitcher.tsx
Normal 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 },
|
||||
})
|
||||
82
src/components/chat/ImageAttachments.tsx
Normal file
82
src/components/chat/ImageAttachments.tsx
Normal 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" },
|
||||
})
|
||||
162
src/components/chat/MessageBubble.tsx
Normal file
162
src/components/chat/MessageBubble.tsx
Normal 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" },
|
||||
})
|
||||
186
src/components/chat/ModelPicker.tsx
Normal file
186
src/components/chat/ModelPicker.tsx
Normal 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 },
|
||||
})
|
||||
61
src/components/chat/PermissionPrompt.tsx
Normal file
61
src/components/chat/PermissionPrompt.tsx
Normal 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" },
|
||||
})
|
||||
190
src/components/chat/QuestionPrompt.tsx
Normal file
190
src/components/chat/QuestionPrompt.tsx
Normal 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 },
|
||||
})
|
||||
48
src/components/chat/ReasoningBlock.tsx
Normal file
48
src/components/chat/ReasoningBlock.tsx
Normal 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" },
|
||||
})
|
||||
338
src/components/chat/SessionInfo.tsx
Normal file
338
src/components/chat/SessionInfo.tsx
Normal 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" },
|
||||
})
|
||||
88
src/components/chat/SlashPopover.tsx
Normal file
88
src/components/chat/SlashPopover.tsx
Normal 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" },
|
||||
})
|
||||
46
src/components/chat/StatusIndicator.tsx
Normal file
46
src/components/chat/StatusIndicator.tsx
Normal 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" },
|
||||
})
|
||||
454
src/components/chat/ToolCallCard.tsx
Normal file
454
src/components/chat/ToolCallCard.tsx
Normal 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" },
|
||||
})
|
||||
12
src/components/chat/index.ts
Normal file
12
src/components/chat/index.ts
Normal 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"
|
||||
85
src/components/markdown/CodeBlock.tsx
Normal file
85
src/components/markdown/CodeBlock.tsx
Normal 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",
|
||||
},
|
||||
})
|
||||
117
src/components/markdown/Markdown.tsx
Normal file
117
src/components/markdown/Markdown.tsx
Normal 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" },
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
2
src/components/markdown/index.tsx
Normal file
2
src/components/markdown/index.tsx
Normal file
@@ -0,0 +1,2 @@
|
||||
export { Markdown } from "./Markdown"
|
||||
export { CodeBlock } from "./CodeBlock"
|
||||
Reference in New Issue
Block a user