feat: add opencode provider and refactor stream model to head/tail architecture

This commit is contained in:
Mohamed Boudra
2026-01-11 16:00:25 +07:00
parent 7fe10f9b4a
commit ebabcc09e5
42 changed files with 4416 additions and 2926 deletions
+125 -40
View File
@@ -22,16 +22,26 @@ import {
Search,
Brain,
} from "lucide-react-native";
import { StyleSheet, useUnistyles, UnistylesRuntime } from "react-native-unistyles";
import {
StyleSheet,
useUnistyles,
UnistylesRuntime,
} from "react-native-unistyles";
import { baseColors, theme } from "@/styles/theme";
import { createMarkdownStyles, createCompactMarkdownStyles } from "@/styles/markdown-styles";
import {
createMarkdownStyles,
createCompactMarkdownStyles,
} from "@/styles/markdown-styles";
import { Colors, Fonts } from "@/constants/theme";
import * as Clipboard from "expo-clipboard";
import type { TodoEntry, ThoughtStatus } from "@/types/stream";
import { extractPrincipalParam } from "@/utils/tool-call-parsers";
import { resolveToolCallPreview } from "./tool-call-preview";
import { useToolCallSheet } from "./tool-call-sheet";
import { ToolCallDetailsContent, useToolCallDetails } from "./tool-call-details";
import {
ToolCallDetailsContent,
useToolCallDetails,
} from "./tool-call-details";
interface UserMessageProps {
message: string;
@@ -259,13 +269,17 @@ function isLikelyPathToken(value: string): boolean {
return false;
}
const looksLikeDir = value.endsWith("/") || value.startsWith("./") || value.startsWith("../");
const looksLikeDir =
value.endsWith("/") || value.startsWith("./") || value.startsWith("../");
return hasExtension || looksLikeDir || value.includes("/");
}
function normalizeInlinePathValue(value: string): string | null {
const trimmed = value.trim().replace(/^['"`]/, "").replace(/['"`]$/, "");
const trimmed = value
.trim()
.replace(/^['"`]/, "")
.replace(/['"`]$/, "");
if (!trimmed) {
return null;
}
@@ -290,7 +304,9 @@ function parseInlinePathToken(
return null;
}
const lineStart = parseInt(rangeOnlyMatch[1], 10);
const lineEnd = rangeOnlyMatch[2] ? parseInt(rangeOnlyMatch[2], 10) : undefined;
const lineEnd = rangeOnlyMatch[2]
? parseInt(rangeOnlyMatch[2], 10)
: undefined;
return {
raw: rawValue,
path: basePath,
@@ -335,10 +351,7 @@ export const AssistantMessage = memo(function AssistantMessage({
const { theme } = useUnistyles();
const lastPathRef = useRef<string | null>(null);
const markdownStyles = useMemo(
() => createMarkdownStyles(theme),
[theme]
);
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);
const markdownRules = useMemo(() => {
return {
@@ -360,7 +373,11 @@ export const AssistantMessage = memo(function AssistantMessage({
styles: any,
inheritedStyles: any = {}
) => (
<Text key={node.key} style={[inheritedStyles, styles.textgroup]} selectable>
<Text
key={node.key}
style={[inheritedStyles, styles.textgroup]}
selectable
>
{children}
</Text>
),
@@ -371,7 +388,11 @@ export const AssistantMessage = memo(function AssistantMessage({
styles: any,
inheritedStyles: any = {}
) => (
<Text key={node.key} style={[inheritedStyles, styles.code_block]} selectable>
<Text
key={node.key}
style={[inheritedStyles, styles.code_block]}
selectable
>
{node.content}
</Text>
),
@@ -402,7 +423,10 @@ export const AssistantMessage = memo(function AssistantMessage({
return (
<Text
key={node.key}
style={[inheritedStyles, assistantMessageStylesheet.markdownCodeInline]}
style={[
inheritedStyles,
assistantMessageStylesheet.markdownCodeInline,
]}
selectable
>
{content}
@@ -415,7 +439,10 @@ export const AssistantMessage = memo(function AssistantMessage({
key={node.key}
onPress={() => parsed && onInlinePathPress?.(parsed)}
selectable={false}
style={[assistantMessageStylesheet.pathChip, assistantMessageStylesheet.pathChipText]}
style={[
assistantMessageStylesheet.pathChip,
assistantMessageStylesheet.pathChipText,
]}
>
{content}
</Text>
@@ -460,7 +487,9 @@ export const AssistantMessage = memo(function AssistantMessage({
return (
<View key={node.key} style={styles.list_item}>
<Text style={iconStyle}>{bullet}</Text>
<View style={[contentStyle, { flex: 1, flexShrink: 1, minWidth: 0 }]}>
<View
style={[contentStyle, { flex: 1, flexShrink: 1, minWidth: 0 }]}
>
{children}
</View>
</View>
@@ -781,7 +810,10 @@ export const TodoListCard = memo(function TodoListCard({
[items]
);
const timestampLabel = useMemo(() => formatPlanTimestamp(timestamp), [timestamp]);
const timestampLabel = useMemo(
() => formatPlanTimestamp(timestamp),
[timestamp]
);
const iconColor = theme.colors.background;
@@ -791,10 +823,14 @@ export const TodoListCard = memo(function TodoListCard({
<View style={todoListCardStylesheet.header}>
<View style={todoListCardStylesheet.headerMeta}>
<Text style={todoListCardStylesheet.title}>Plan</Text>
<Text style={todoListCardStylesheet.timestamp}>{timestampLabel}</Text>
<Text style={todoListCardStylesheet.timestamp}>
{timestampLabel}
</Text>
</View>
<View style={todoListCardStylesheet.providerBadge}>
<Text style={todoListCardStylesheet.providerText}>{providerLabel}</Text>
<Text style={todoListCardStylesheet.providerText}>
{providerLabel}
</Text>
</View>
</View>
<Text style={todoListCardStylesheet.progressText}>
@@ -809,7 +845,10 @@ export const TodoListCard = memo(function TodoListCard({
</Text>
) : (
items.map((item, idx) => (
<View key={`${item.text}-${idx}`} style={todoListCardStylesheet.itemRow}>
<View
key={`${item.text}-${idx}`}
style={todoListCardStylesheet.itemRow}
>
<View
style={[
todoListCardStylesheet.checkbox,
@@ -889,10 +928,14 @@ const ExpandableBadge = memo(function ExpandableBadge({
};
}, [isLoading, spinAnim]);
const spin = spinAnim.interpolate({
inputRange: [0, 1],
outputRange: ["0deg", "360deg"],
});
const spin = useMemo(
() =>
spinAnim.interpolate({
inputRange: [0, 1],
outputRange: ["0deg", "360deg"],
}),
[spinAnim]
);
const IconComponent = icon;
const iconColor = isError
@@ -921,7 +964,9 @@ const ExpandableBadge = memo(function ExpandableBadge({
accessibilityState={hasDetails ? { expanded: isExpanded } : undefined}
style={({ pressed }) => [
expandableBadgeStylesheet.pressable,
pressed && hasDetails ? expandableBadgeStylesheet.pressablePressed : null,
pressed && hasDetails
? expandableBadgeStylesheet.pressablePressed
: null,
]}
>
<View style={expandableBadgeStylesheet.headerRow}>
@@ -930,7 +975,10 @@ const ExpandableBadge = memo(function ExpandableBadge({
{label}
</Text>
{secondaryLabel ? (
<Text style={expandableBadgeStylesheet.secondaryLabel} numberOfLines={1}>
<Text
style={expandableBadgeStylesheet.secondaryLabel}
numberOfLines={1}
>
{secondaryLabel}
</Text>
) : (
@@ -948,7 +996,9 @@ const ExpandableBadge = memo(function ExpandableBadge({
) : null}
</View>
{detailContent ? (
<View style={expandableBadgeStylesheet.detailWrapper}>{detailContent}</View>
<View style={expandableBadgeStylesheet.detailWrapper}>
{detailContent}
</View>
) : null}
</Pressable>
</View>
@@ -963,7 +1013,10 @@ const agentThoughtStylesheet = StyleSheet.create((theme) => ({
},
}));
export const AgentThoughtMessage = memo(function AgentThoughtMessage({ message, status = "ready" }: AgentThoughtMessageProps) {
export const AgentThoughtMessage = memo(function AgentThoughtMessage({
message,
status = "ready",
}: AgentThoughtMessageProps) {
const { theme } = useUnistyles();
const [isExpanded, setIsExpanded] = useState(false);
const markdownContent = useMemo(() => message?.trim() ?? "", [message]);
@@ -996,7 +1049,11 @@ export const AgentThoughtMessage = memo(function AgentThoughtMessage({ message,
styles: any,
inheritedStyles: any = {}
) => (
<Text key={node.key} style={[inheritedStyles, styles.textgroup]} selectable>
<Text
key={node.key}
style={[inheritedStyles, styles.textgroup]}
selectable
>
{children}
</Text>
),
@@ -1007,7 +1064,11 @@ export const AgentThoughtMessage = memo(function AgentThoughtMessage({ message,
styles: any,
inheritedStyles: any = {}
) => (
<Text key={node.key} style={[inheritedStyles, styles.code_block]} selectable>
<Text
key={node.key}
style={[inheritedStyles, styles.code_block]}
selectable
>
{node.content}
</Text>
),
@@ -1029,7 +1090,11 @@ export const AgentThoughtMessage = memo(function AgentThoughtMessage({ message,
styles: any,
inheritedStyles: any = {}
) => (
<Text key={node.key} style={[inheritedStyles, styles.code_inline]} selectable>
<Text
key={node.key}
style={[inheritedStyles, styles.code_inline]}
selectable
>
{node.content}
</Text>
),
@@ -1072,7 +1137,9 @@ export const AgentThoughtMessage = memo(function AgentThoughtMessage({ message,
return (
<View key={node.key} style={styles.list_item}>
<Text style={iconStyle}>{bullet}</Text>
<View style={[contentStyle, { flex: 1, flexShrink: 1, minWidth: 0 }]}>
<View
style={[contentStyle, { flex: 1, flexShrink: 1, minWidth: 0 }]}
>
{children}
</View>
</View>
@@ -1083,7 +1150,11 @@ export const AgentThoughtMessage = memo(function AgentThoughtMessage({ message,
const renderDetails = useCallback(() => {
if (!markdownContent) {
return <Text style={agentThoughtStylesheet.emptyText}>No captured thinking</Text>;
return (
<Text style={agentThoughtStylesheet.emptyText}>
No captured thinking
</Text>
);
}
return (
<Markdown style={markdownStyles} rules={markdownRules}>
@@ -1124,14 +1195,16 @@ const toolKindIcons: Record<string, any> = {
// Derive tool kind from tool name for icon selection
function getToolKindFromName(toolName: string): string {
const lower = toolName.toLowerCase();
if (lower === "read" || lower === "read_file" || lower.startsWith("read")) return "read";
if (lower === "edit" || lower === "write" || lower === "apply_patch") return "edit";
if (lower === "read" || lower === "read_file" || lower.startsWith("read"))
return "read";
if (lower === "edit" || lower === "write" || lower === "apply_patch")
return "edit";
if (lower === "bash" || lower === "shell") return "execute";
if (lower === "grep" || lower === "glob" || lower === "web_search") return "search";
if (lower === "grep" || lower === "glob" || lower === "web_search")
return "search";
return "tool";
}
export const ToolCall = memo(function ToolCall({
toolName,
args,
@@ -1145,7 +1218,8 @@ export const ToolCall = memo(function ToolCall({
// Check if we're on mobile (use bottom sheet) or desktop (inline expand)
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
UnistylesRuntime.breakpoint === "xs" ||
UnistylesRuntime.breakpoint === "sm";
const kind = getToolKindFromName(toolName);
const IconComponent = toolKindIcons[kind] || Wrench;
@@ -1157,7 +1231,8 @@ export const ToolCall = memo(function ToolCall({
);
// Check if there's any content to display
const hasDetails = args !== undefined || result !== undefined || error !== undefined;
const hasDetails =
args !== undefined || result !== undefined || error !== undefined;
// Parse tool call details for inline rendering
const { display, errorText } = useToolCallDetails({ args, result, error });
@@ -1184,7 +1259,11 @@ export const ToolCall = memo(function ToolCall({
if (isMobile) return null;
return (
<View style={toolCallInlineStyles.detailsContainer}>
<ToolCallDetailsContent display={display} errorText={errorText} maxHeight={400} />
<ToolCallDetailsContent
display={display}
errorText={errorText}
maxHeight={400}
/>
</View>
);
}, [isMobile, display, errorText]);
@@ -1196,7 +1275,13 @@ export const ToolCall = memo(function ToolCall({
icon={IconComponent}
isExpanded={!isMobile && isExpanded}
onToggle={hasDetails ? handleToggle : undefined}
renderDetails={hasDetails && !isMobile ? renderDetails : (hasDetails ? () => null : undefined)}
renderDetails={
hasDetails && !isMobile
? renderDetails
: hasDetails
? () => null
: undefined
}
isLoading={status === "executing"}
isError={status === "failed"}
/>