Inital commit

This commit is contained in:
2026-07-09 12:07:57 -05:00
commit 6fa32c1020
39 changed files with 17590 additions and 0 deletions
+206
View File
@@ -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",
},
});
+68
View File
@@ -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,
},
});
+124
View File
@@ -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,
},
});
+28
View File
@@ -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,
},
});