Inital commit
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
import Markdown from "@ronradtke/react-native-markdown-display";
|
||||
import { memo } from "react";
|
||||
import { ActivityIndicator, StyleSheet, View } from "react-native";
|
||||
import { CollapsibleMessage } from "@/components/CollapsibleMessage";
|
||||
import { colors, monoFont } from "@/lib/theme";
|
||||
|
||||
type Props = {
|
||||
text: string;
|
||||
streaming?: boolean;
|
||||
};
|
||||
|
||||
function AssistantMessageComponent({ text, streaming }: Props) {
|
||||
if (!text.trim()) {
|
||||
if (!streaming) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<View style={styles.thinking}>
|
||||
<ActivityIndicator size="small" color={colors.textMuted} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CollapsibleMessage text={text} forceExpanded={streaming} style={styles.container}>
|
||||
<Markdown style={markdownStyles} colorScheme="dark">
|
||||
{text}
|
||||
</Markdown>
|
||||
</CollapsibleMessage>
|
||||
);
|
||||
}
|
||||
|
||||
export const AssistantMessage = memo(AssistantMessageComponent);
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
alignSelf: "stretch",
|
||||
},
|
||||
thinking: {
|
||||
alignSelf: "flex-start",
|
||||
paddingVertical: 4,
|
||||
},
|
||||
});
|
||||
|
||||
const markdownStyles = StyleSheet.create({
|
||||
body: {
|
||||
color: colors.text,
|
||||
fontSize: 15,
|
||||
lineHeight: 22,
|
||||
},
|
||||
paragraph: {
|
||||
marginTop: 0,
|
||||
marginBottom: 10,
|
||||
},
|
||||
heading1: {
|
||||
color: colors.text,
|
||||
fontSize: 22,
|
||||
fontWeight: "700",
|
||||
marginTop: 12,
|
||||
marginBottom: 8,
|
||||
},
|
||||
heading2: {
|
||||
color: colors.text,
|
||||
fontSize: 19,
|
||||
fontWeight: "700",
|
||||
marginTop: 12,
|
||||
marginBottom: 6,
|
||||
},
|
||||
heading3: {
|
||||
color: colors.text,
|
||||
fontSize: 17,
|
||||
fontWeight: "700",
|
||||
marginTop: 10,
|
||||
marginBottom: 6,
|
||||
},
|
||||
heading4: {
|
||||
color: colors.text,
|
||||
fontSize: 15,
|
||||
fontWeight: "700",
|
||||
marginTop: 8,
|
||||
marginBottom: 4,
|
||||
},
|
||||
heading5: {
|
||||
color: colors.text,
|
||||
fontSize: 15,
|
||||
fontWeight: "600",
|
||||
},
|
||||
heading6: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: 14,
|
||||
fontWeight: "600",
|
||||
},
|
||||
strong: {
|
||||
fontWeight: "700",
|
||||
},
|
||||
em: {
|
||||
fontStyle: "italic",
|
||||
},
|
||||
link: {
|
||||
color: colors.accent,
|
||||
textDecorationLine: "underline",
|
||||
},
|
||||
blockquote: {
|
||||
backgroundColor: colors.surfaceDeep,
|
||||
borderLeftWidth: 3,
|
||||
borderLeftColor: colors.border,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 4,
|
||||
marginVertical: 6,
|
||||
borderRadius: 4,
|
||||
},
|
||||
code_inline: {
|
||||
backgroundColor: colors.surface,
|
||||
color: colors.accent,
|
||||
fontFamily: monoFont,
|
||||
fontSize: 13,
|
||||
borderRadius: 4,
|
||||
paddingHorizontal: 4,
|
||||
},
|
||||
code_block: {
|
||||
backgroundColor: colors.surfaceDeep,
|
||||
borderColor: colors.border,
|
||||
borderWidth: 1,
|
||||
borderRadius: 8,
|
||||
color: colors.text,
|
||||
fontFamily: monoFont,
|
||||
fontSize: 12.5,
|
||||
lineHeight: 18,
|
||||
padding: 10,
|
||||
marginVertical: 6,
|
||||
},
|
||||
fence: {
|
||||
backgroundColor: colors.surfaceDeep,
|
||||
borderColor: colors.border,
|
||||
borderWidth: 1,
|
||||
borderRadius: 8,
|
||||
overflow: "hidden",
|
||||
marginVertical: 6,
|
||||
},
|
||||
fence_header: {
|
||||
backgroundColor: colors.surface,
|
||||
borderBottomColor: colors.border,
|
||||
borderBottomWidth: 1,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 4,
|
||||
},
|
||||
fence_code: {
|
||||
backgroundColor: colors.surfaceDeep,
|
||||
padding: 10,
|
||||
},
|
||||
fence_language_label: {
|
||||
color: colors.textMuted,
|
||||
fontFamily: monoFont,
|
||||
fontSize: 11,
|
||||
},
|
||||
fence_copy_text: {
|
||||
color: colors.textMuted,
|
||||
fontSize: 11,
|
||||
},
|
||||
fence_token: {
|
||||
color: colors.text,
|
||||
fontFamily: monoFont,
|
||||
fontSize: 12.5,
|
||||
lineHeight: 18,
|
||||
},
|
||||
bullet_list: {
|
||||
marginBottom: 10,
|
||||
},
|
||||
ordered_list: {
|
||||
marginBottom: 10,
|
||||
},
|
||||
list_item: {
|
||||
flexDirection: "row",
|
||||
marginBottom: 4,
|
||||
},
|
||||
bullet_list_icon: {
|
||||
color: colors.textMuted,
|
||||
marginRight: 8,
|
||||
},
|
||||
ordered_list_icon: {
|
||||
color: colors.textMuted,
|
||||
marginRight: 8,
|
||||
},
|
||||
hr: {
|
||||
backgroundColor: colors.border,
|
||||
height: 1,
|
||||
marginVertical: 12,
|
||||
},
|
||||
table: {
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
borderRadius: 6,
|
||||
marginVertical: 6,
|
||||
},
|
||||
th: {
|
||||
padding: 6,
|
||||
fontWeight: "700",
|
||||
},
|
||||
td: {
|
||||
padding: 6,
|
||||
},
|
||||
tr: {
|
||||
borderBottomWidth: 1,
|
||||
borderColor: colors.borderSubtle,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { memo, useEffect, useState, type ReactNode } from "react";
|
||||
import { Pressable, StyleSheet, Text, View, type StyleProp, type ViewStyle } from "react-native";
|
||||
import { colors } from "@/lib/theme";
|
||||
|
||||
const CHAR_THRESHOLD = 480;
|
||||
const LINE_THRESHOLD = 10;
|
||||
export const COLLAPSED_MAX_HEIGHT = 220;
|
||||
|
||||
export function isLongMessage(text: string): boolean {
|
||||
const trimmed = text.trim();
|
||||
if (trimmed.length > CHAR_THRESHOLD) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let lines = 1;
|
||||
for (let i = 0; i < trimmed.length; i++) {
|
||||
if (trimmed.charCodeAt(i) === 10) {
|
||||
lines += 1;
|
||||
if (lines > LINE_THRESHOLD) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
type Props = {
|
||||
text: string;
|
||||
/** Keep fully expanded (e.g. while streaming). */
|
||||
forceExpanded?: boolean;
|
||||
collapsedMaxHeight?: number;
|
||||
toggleAlign?: "left" | "right";
|
||||
style?: StyleProp<ViewStyle>;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
function CollapsibleMessageComponent({
|
||||
text,
|
||||
forceExpanded = false,
|
||||
collapsedMaxHeight = COLLAPSED_MAX_HEIGHT,
|
||||
toggleAlign = "left",
|
||||
style,
|
||||
children,
|
||||
}: Props) {
|
||||
const long = isLongMessage(text);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (forceExpanded) {
|
||||
setExpanded(true);
|
||||
}
|
||||
}, [forceExpanded]);
|
||||
|
||||
const collapsed = long && !expanded && !forceExpanded;
|
||||
|
||||
return (
|
||||
<View style={style}>
|
||||
<View style={collapsed ? { maxHeight: collapsedMaxHeight, overflow: "hidden" } : undefined}>
|
||||
{children}
|
||||
</View>
|
||||
{long && !forceExpanded ? (
|
||||
<Pressable
|
||||
onPress={() => setExpanded((value) => !value)}
|
||||
style={[styles.toggle, toggleAlign === "right" && styles.toggleRight]}
|
||||
hitSlop={8}
|
||||
>
|
||||
<Text style={styles.toggleText}>{expanded ? "Show less" : "Show more"}</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export const CollapsibleMessage = memo(CollapsibleMessageComponent);
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
toggle: {
|
||||
alignSelf: "flex-start",
|
||||
paddingTop: 6,
|
||||
paddingBottom: 2,
|
||||
},
|
||||
toggleRight: {
|
||||
alignSelf: "flex-end",
|
||||
},
|
||||
toggleText: {
|
||||
color: colors.accent,
|
||||
fontSize: 13,
|
||||
fontWeight: "600",
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { memo, type ReactNode } from "react";
|
||||
import { StyleSheet, Text, View } from "react-native";
|
||||
import { colors, monoFont } from "@/lib/theme";
|
||||
|
||||
export type Sender = "user" | "agent";
|
||||
|
||||
const SENDER_LABEL: Record<Sender, string> = {
|
||||
user: "you",
|
||||
agent: "pi",
|
||||
};
|
||||
|
||||
const SENDER_COLOR: Record<Sender, string> = {
|
||||
user: colors.senderUser,
|
||||
agent: colors.senderAgent,
|
||||
};
|
||||
|
||||
const RAIL_COLOR: Record<Sender, string> = {
|
||||
user: colors.railUser,
|
||||
agent: colors.railAgent,
|
||||
};
|
||||
|
||||
type Props = {
|
||||
sender: Sender;
|
||||
/** Show the sender label (first message of a consecutive run). */
|
||||
groupStart: boolean;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
function MessageRowComponent({ sender, groupStart, children }: Props) {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.container,
|
||||
{ borderLeftColor: RAIL_COLOR[sender] },
|
||||
groupStart && styles.groupStart,
|
||||
]}
|
||||
>
|
||||
{groupStart ? (
|
||||
<Text style={[styles.sender, { color: SENDER_COLOR[sender] }]}>
|
||||
{SENDER_LABEL[sender]}
|
||||
</Text>
|
||||
) : null}
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export const MessageRow = memo(MessageRowComponent);
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
borderLeftWidth: 2,
|
||||
paddingLeft: 12,
|
||||
paddingRight: 4,
|
||||
paddingVertical: 3,
|
||||
},
|
||||
groupStart: {
|
||||
marginTop: 18,
|
||||
},
|
||||
sender: {
|
||||
fontFamily: monoFont,
|
||||
fontSize: 11,
|
||||
fontWeight: "700",
|
||||
letterSpacing: 1.5,
|
||||
textTransform: "uppercase",
|
||||
marginBottom: 5,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import { memo, useState } from "react";
|
||||
import { Pressable, StyleSheet, Text, View } from "react-native";
|
||||
import { colors, monoFont } from "@/lib/theme";
|
||||
|
||||
type Props = {
|
||||
toolName: string;
|
||||
status: "running" | "done" | "error";
|
||||
argsPreview?: string;
|
||||
detail?: string;
|
||||
};
|
||||
|
||||
const STATUS_COLOR: Record<Props["status"], string> = {
|
||||
running: colors.warning,
|
||||
done: colors.success,
|
||||
error: colors.error,
|
||||
};
|
||||
|
||||
function ToolCallCardComponent({ toolName, status, argsPreview, detail }: Props) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const output = detail?.trim() ?? "";
|
||||
const expandable = output.length > 0;
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
style={styles.container}
|
||||
onPress={expandable ? () => setExpanded((value) => !value) : undefined}
|
||||
>
|
||||
<View style={styles.header}>
|
||||
<Text style={[styles.prompt, { color: STATUS_COLOR[status] }]}>
|
||||
{status === "error" ? "✕" : "❯"}
|
||||
</Text>
|
||||
<Text style={styles.name} numberOfLines={1}>
|
||||
{toolName}
|
||||
</Text>
|
||||
{argsPreview ? (
|
||||
<Text style={styles.args} numberOfLines={1}>
|
||||
{argsPreview}
|
||||
</Text>
|
||||
) : (
|
||||
<View style={styles.spacer} />
|
||||
)}
|
||||
{status === "running" ? <Text style={styles.statusLabel}>running…</Text> : null}
|
||||
{expandable ? <Text style={styles.chevron}>{expanded ? "−" : "+"}</Text> : null}
|
||||
</View>
|
||||
|
||||
{expandable ? (
|
||||
<View style={styles.outputWrap}>
|
||||
{expanded ? (
|
||||
<Text style={styles.output} selectable>
|
||||
{output}
|
||||
</Text>
|
||||
) : (
|
||||
<Text style={styles.outputPreview} numberOfLines={2}>
|
||||
{output}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
) : null}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
export const ToolCallCard = memo(ToolCallCardComponent);
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
alignSelf: "stretch",
|
||||
paddingVertical: 2,
|
||||
gap: 4,
|
||||
},
|
||||
header: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
},
|
||||
prompt: {
|
||||
fontFamily: monoFont,
|
||||
fontSize: 12,
|
||||
fontWeight: "700",
|
||||
},
|
||||
name: {
|
||||
color: colors.textSecondary,
|
||||
fontFamily: monoFont,
|
||||
fontSize: 13,
|
||||
fontWeight: "600",
|
||||
},
|
||||
args: {
|
||||
flex: 1,
|
||||
color: colors.textMuted,
|
||||
fontFamily: monoFont,
|
||||
fontSize: 12,
|
||||
},
|
||||
spacer: {
|
||||
flex: 1,
|
||||
},
|
||||
statusLabel: {
|
||||
color: colors.warning,
|
||||
fontFamily: monoFont,
|
||||
fontSize: 11,
|
||||
},
|
||||
chevron: {
|
||||
color: colors.textMuted,
|
||||
fontFamily: monoFont,
|
||||
fontSize: 13,
|
||||
},
|
||||
outputWrap: {
|
||||
borderLeftWidth: 1,
|
||||
borderLeftColor: colors.borderSubtle,
|
||||
marginLeft: 4,
|
||||
paddingLeft: 10,
|
||||
},
|
||||
output: {
|
||||
color: colors.textSecondary,
|
||||
fontFamily: monoFont,
|
||||
fontSize: 12,
|
||||
lineHeight: 17,
|
||||
},
|
||||
outputPreview: {
|
||||
color: colors.textMuted,
|
||||
fontFamily: monoFont,
|
||||
fontSize: 12,
|
||||
lineHeight: 17,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { memo } from "react";
|
||||
import { StyleSheet, Text } from "react-native";
|
||||
import { CollapsibleMessage } from "@/components/CollapsibleMessage";
|
||||
import { colors } from "@/lib/theme";
|
||||
|
||||
type Props = {
|
||||
text: string;
|
||||
};
|
||||
|
||||
function UserMessageComponent({ text }: Props) {
|
||||
return (
|
||||
<CollapsibleMessage text={text}>
|
||||
<Text style={styles.text} selectable>
|
||||
{text}
|
||||
</Text>
|
||||
</CollapsibleMessage>
|
||||
);
|
||||
}
|
||||
|
||||
export const UserMessage = memo(UserMessageComponent);
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
text: {
|
||||
color: colors.text,
|
||||
fontSize: 15,
|
||||
lineHeight: 22,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user