diff --git a/package-lock.json b/package-lock.json
index d30631ffe..0ba125773 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -11,7 +11,8 @@
"license": "MIT",
"workspaces": [
"packages/server",
- "packages/app"
+ "packages/app",
+ "packages/relay"
],
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.1.75"
@@ -1607,6 +1608,13 @@
"react-native": "*"
}
},
+ "node_modules/@cloudflare/workers-types": {
+ "version": "4.20260113.0",
+ "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260113.0.tgz",
+ "integrity": "sha512-CS2tUdGn1EMAV5GoFYYUfsZ4vwwXiYxwrUiI8ZRkxrJGqkHNGily/5Zf+vt/wh1HSoiCIChNYiuLEoCA/XUybw==",
+ "dev": true,
+ "license": "MIT OR Apache-2.0"
+ },
"node_modules/@cspotcode/source-map-support": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
@@ -5965,6 +5973,10 @@
"resolved": "packages/app",
"link": true
},
+ "node_modules/@paseo/relay": {
+ "resolved": "packages/relay",
+ "link": true
+ },
"node_modules/@paseo/server": {
"resolved": "packages/server",
"link": true
@@ -21627,6 +21639,20 @@
"react-native": "*"
}
},
+ "packages/relay": {
+ "name": "@paseo/relay",
+ "version": "0.1.0",
+ "dependencies": {
+ "ws": "^8.14.2"
+ },
+ "devDependencies": {
+ "@cloudflare/workers-types": "^4.20241230.0",
+ "@types/node": "^20.9.0",
+ "@types/ws": "^8.5.8",
+ "typescript": "^5.2.2",
+ "vitest": "^3.2.4"
+ }
+ },
"packages/server": {
"name": "@paseo/server",
"version": "0.1.0",
diff --git a/package.json b/package.json
index a44c666d3..1e73bd9c6 100644
--- a/package.json
+++ b/package.json
@@ -4,7 +4,8 @@
"private": true,
"workspaces": [
"packages/server",
- "packages/app"
+ "packages/app",
+ "packages/relay"
],
"scripts": {
"dev": "npm run dev --workspace=@paseo/server",
diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx
index aa2783b43..8e7b3d889 100644
--- a/packages/app/src/app/_layout.tsx
+++ b/packages/app/src/app/_layout.tsx
@@ -7,6 +7,7 @@ import { RealtimeProvider } from "@/contexts/realtime-context";
import { useAppSettings } from "@/hooks/use-settings";
import { View, ActivityIndicator, Text } from "react-native";
import { UnistylesRuntime, useUnistyles } from "react-native-unistyles";
+import { darkTheme } from "@/styles/theme";
import { DaemonRegistryProvider, useDaemonRegistry } from "@/contexts/daemon-registry-context";
import { DaemonConnectionsProvider } from "@/contexts/daemon-connections-context";
import { MultiDaemonSessionHost } from "@/components/multi-daemon-session-host";
@@ -144,7 +145,7 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) {
);
const content = (
-
+
{!isMobile && }
{children}
@@ -165,10 +166,21 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) {
}
function ProvidersWrapper({ children }: { children: ReactNode }) {
- const { isLoading: settingsLoading } = useAppSettings();
+ const { settings, isLoading: settingsLoading } = useAppSettings();
const { daemons, isLoading: registryLoading } = useDaemonRegistry();
const isLoading = settingsLoading || registryLoading;
+ // Apply theme setting on mount and when it changes
+ useEffect(() => {
+ if (isLoading) return;
+ if (settings.theme === "auto") {
+ UnistylesRuntime.setAdaptiveThemes(true);
+ } else {
+ UnistylesRuntime.setAdaptiveThemes(false);
+ UnistylesRuntime.setTheme(settings.theme);
+ }
+ }, [isLoading, settings.theme]);
+
if (isLoading) {
return ;
}
@@ -207,10 +219,10 @@ function LoadingView() {
flex: 1,
justifyContent: "center",
alignItems: "center",
- backgroundColor: "#09090b",
+ backgroundColor: darkTheme.colors.surface0,
}}
>
-
+
);
}
@@ -223,13 +235,13 @@ function MissingDaemonView() {
justifyContent: "center",
alignItems: "center",
padding: 24,
- backgroundColor: "#09090b",
+ backgroundColor: darkTheme.colors.surface0,
}}
>
-
+
({
container: {
flex: 1,
- backgroundColor: theme.colors.background,
+ backgroundColor: theme.colors.surface0,
},
content: {
flex: 1,
@@ -170,7 +170,7 @@ const styles = StyleSheet.create((theme) => ({
},
subtitle: {
fontSize: theme.fontSize.sm,
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
textAlign: "center",
},
primaryButton: {
@@ -194,7 +194,7 @@ const styles = StyleSheet.create((theme) => ({
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
- backgroundColor: theme.colors.muted,
+ backgroundColor: theme.colors.surface2,
},
matchLabel: {
fontSize: theme.fontSize.base,
@@ -204,6 +204,6 @@ const styles = StyleSheet.create((theme) => ({
matchMeta: {
marginTop: theme.spacing[1],
fontSize: theme.fontSize.sm,
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
},
}));
diff --git a/packages/app/src/app/agent/[serverId]/[agentId].tsx b/packages/app/src/app/agent/[serverId]/[agentId].tsx
index dfad79294..93e474397 100644
--- a/packages/app/src/app/agent/[serverId]/[agentId].tsx
+++ b/packages/app/src/app/agent/[serverId]/[agentId].tsx
@@ -644,7 +644,7 @@ function AgentScreenContent({
color={
isExplorerOpen
? theme.colors.foreground
- : theme.colors.mutedForeground
+ : theme.colors.foregroundMuted
}
/>
) : (
@@ -653,14 +653,14 @@ function AgentScreenContent({
color={
isExplorerOpen
? theme.colors.foreground
- : theme.colors.mutedForeground
+ : theme.colors.foregroundMuted
}
/>
)}
-
+
@@ -748,7 +748,7 @@ function AgentScreenContent({
<>
Fetching…
@@ -777,7 +777,7 @@ function AgentScreenContent({
{/* Sub-Agents Section */}
-
+
Sub-Agents
{childAgents.length === 0 ? (
@@ -807,7 +807,7 @@ function AgentScreenContent({
) : null}
@@ -953,11 +953,11 @@ const styles = StyleSheet.create((theme) => ({
outerContainer: {
flex: 1,
flexDirection: "row",
- backgroundColor: theme.colors.background,
+ backgroundColor: theme.colors.surface0,
},
container: {
flex: 1,
- backgroundColor: theme.colors.background,
+ backgroundColor: theme.colors.surface0,
},
headerRightContent: {
flexDirection: "row",
@@ -978,7 +978,7 @@ const styles = StyleSheet.create((theme) => ({
},
loadingText: {
fontSize: theme.fontSize.base,
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
},
centerState: {
flex: 1,
@@ -994,20 +994,20 @@ const styles = StyleSheet.create((theme) => ({
},
errorText: {
fontSize: theme.fontSize.lg,
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
textAlign: "center",
},
statusText: {
marginTop: theme.spacing[2],
textAlign: "center",
fontSize: theme.fontSize.sm,
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
},
errorDetails: {
marginTop: theme.spacing[1],
textAlign: "center",
fontSize: theme.fontSize.xs,
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
},
offlineTitle: {
fontSize: theme.fontSize.base,
@@ -1017,12 +1017,12 @@ const styles = StyleSheet.create((theme) => ({
},
offlineDescription: {
fontSize: theme.fontSize.sm,
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
textAlign: "center",
},
offlineDetails: {
fontSize: theme.fontSize.xs,
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
textAlign: "center",
},
menuButton: {
@@ -1036,7 +1036,7 @@ const styles = StyleSheet.create((theme) => ({
...StyleSheet.absoluteFillObject,
},
dropdownMenu: {
- backgroundColor: theme.colors.popover,
+ backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg,
padding: theme.spacing[2],
shadowColor: "#000",
@@ -1054,7 +1054,7 @@ const styles = StyleSheet.create((theme) => ({
},
menuMetaLabel: {
fontSize: theme.fontSize.xs,
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
letterSpacing: 0.5,
textTransform: "uppercase",
},
@@ -1069,7 +1069,7 @@ const styles = StyleSheet.create((theme) => ({
},
menuMetaPendingText: {
fontSize: theme.fontSize.sm,
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
},
menuMetaValueError: {
color: theme.colors.destructive,
@@ -1110,13 +1110,13 @@ const styles = StyleSheet.create((theme) => ({
},
menuSubAgentsLabel: {
fontSize: theme.fontSize.xs,
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
letterSpacing: 0.5,
textTransform: "uppercase",
},
menuSubAgentsEmpty: {
fontSize: theme.fontSize.base,
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[3],
},
diff --git a/packages/app/src/app/agents.tsx b/packages/app/src/app/agents.tsx
index 76ed37aa1..0969322bd 100644
--- a/packages/app/src/app/agents.tsx
+++ b/packages/app/src/app/agents.tsx
@@ -46,6 +46,6 @@ export default function AgentsScreen() {
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
- backgroundColor: theme.colors.background,
+ backgroundColor: theme.colors.surface0,
},
}));
diff --git a/packages/app/src/app/audio-test.tsx b/packages/app/src/app/audio-test.tsx
index 718dff6ac..6e154ed95 100644
--- a/packages/app/src/app/audio-test.tsx
+++ b/packages/app/src/app/audio-test.tsx
@@ -11,7 +11,7 @@ const styles = StyleSheet.create((theme) => ({
backgroundColor: theme.colors.palette.white,
},
scrollViewDark: {
- backgroundColor: theme.colors.background,
+ backgroundColor: theme.colors.surface0,
},
container: {
padding: theme.spacing[6],
@@ -31,7 +31,7 @@ const styles = StyleSheet.create((theme) => ({
marginBottom: theme.spacing[8],
},
subtitleDark: {
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
},
section: {
marginBottom: theme.spacing[8],
@@ -83,7 +83,7 @@ const styles = StyleSheet.create((theme) => ({
borderRadius: theme.borderRadius.lg,
},
statusCardDark: {
- backgroundColor: theme.colors.card,
+ backgroundColor: theme.colors.surface2,
},
statusLabel: {
fontSize: theme.fontSize.sm,
@@ -92,7 +92,7 @@ const styles = StyleSheet.create((theme) => ({
marginBottom: theme.spacing[1],
},
statusLabelDark: {
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
},
statusValue: {
fontSize: theme.fontSize.base,
@@ -146,7 +146,7 @@ const styles = StyleSheet.create((theme) => ({
backgroundColor: theme.colors.palette.gray[300],
},
buttonDisabledDark: {
- backgroundColor: theme.colors.muted,
+ backgroundColor: theme.colors.surface2,
},
buttonText: {
fontSize: theme.fontSize.base,
@@ -157,7 +157,7 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.palette.gray[500],
},
buttonTextDisabledDark: {
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
},
fullWidthButton: {
padding: theme.spacing[4],
@@ -202,7 +202,7 @@ const styles = StyleSheet.create((theme) => ({
borderRadius: theme.borderRadius.lg,
},
configCardDark: {
- backgroundColor: theme.colors.card,
+ backgroundColor: theme.colors.surface2,
},
configTitle: {
fontSize: theme.fontSize.sm,
@@ -211,7 +211,7 @@ const styles = StyleSheet.create((theme) => ({
marginBottom: theme.spacing[2],
},
configTitleDark: {
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
},
configText: {
fontSize: theme.fontSize.sm,
@@ -219,7 +219,7 @@ const styles = StyleSheet.create((theme) => ({
marginBottom: theme.spacing[1],
},
configTextDark: {
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
},
gap3: {
gap: theme.spacing[3],
diff --git a/packages/app/src/app/index.tsx b/packages/app/src/app/index.tsx
index 53e639b75..3911956ac 100644
--- a/packages/app/src/app/index.tsx
+++ b/packages/app/src/app/index.tsx
@@ -538,7 +538,7 @@ export default function HomeScreen() {
style={styles.hostBadge}
onPress={() => openDropdownSheet("host")}
>
-
+
{hostLabel}
({
container: {
flex: 1,
- backgroundColor: theme.colors.background,
+ backgroundColor: theme.colors.surface0,
},
agentPanel: {
flex: 1,
@@ -684,7 +684,7 @@ const styles = StyleSheet.create((theme) => ({
flex: 1,
},
inputAreaWrapper: {
- backgroundColor: theme.colors.background,
+ backgroundColor: theme.colors.surface0,
},
configScrollContent: {
flexGrow: 1,
@@ -701,7 +701,7 @@ const styles = StyleSheet.create((theme) => ({
},
dropdownHelper: {
fontSize: theme.fontSize.sm,
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
},
dropdownSheetList: {
marginTop: theme.spacing[3],
@@ -712,7 +712,7 @@ const styles = StyleSheet.create((theme) => ({
borderRadius: theme.borderRadius.lg,
borderWidth: 1,
borderColor: theme.colors.border,
- backgroundColor: theme.colors.background,
+ backgroundColor: theme.colors.surface0,
marginBottom: theme.spacing[2],
},
dropdownSheetOptionSelected: {
@@ -724,7 +724,7 @@ const styles = StyleSheet.create((theme) => ({
fontWeight: theme.fontWeight.semibold,
},
dropdownSheetOptionDescription: {
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
marginTop: theme.spacing[1],
},
@@ -757,11 +757,11 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
- backgroundColor: theme.colors.muted,
+ backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.full,
},
hostBadgeLabel: {
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.semibold,
},
@@ -769,7 +769,7 @@ const styles = StyleSheet.create((theme) => ({
width: 6,
height: 6,
borderRadius: theme.borderRadius.full,
- backgroundColor: theme.colors.mutedForeground,
+ backgroundColor: theme.colors.foregroundMuted,
},
hostStatusDotOnline: {
backgroundColor: theme.colors.palette.green[500],
diff --git a/packages/app/src/app/orchestrator.tsx b/packages/app/src/app/orchestrator.tsx
index 65cd3963b..505e78104 100644
--- a/packages/app/src/app/orchestrator.tsx
+++ b/packages/app/src/app/orchestrator.tsx
@@ -75,7 +75,7 @@ export default function OrchestratorScreen() {
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
- backgroundColor: theme.colors.background,
+ backgroundColor: theme.colors.surface0,
},
content: {
flex: 1,
diff --git a/packages/app/src/app/settings.tsx b/packages/app/src/app/settings.tsx
index 695fef053..917291845 100644
--- a/packages/app/src/app/settings.tsx
+++ b/packages/app/src/app/settings.tsx
@@ -12,9 +12,10 @@ import {
Platform,
} from "react-native";
import { router } from "expo-router";
-import { StyleSheet, useUnistyles } from "react-native-unistyles";
+import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
+import { Sun, Moon, Monitor } from "lucide-react-native";
import { Fonts } from "@/constants/theme";
-import { useAppSettings } from "@/hooks/use-settings";
+import { useAppSettings, type AppSettings } from "@/hooks/use-settings";
import { useDaemonRegistry, type DaemonProfile } from "@/contexts/daemon-registry-context";
import { useDaemonConnections, type ConnectionStatus } from "@/contexts/daemon-connections-context";
import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons";
@@ -34,7 +35,7 @@ const delay = (ms: number) =>
const styles = StyleSheet.create((theme) => ({
loadingContainer: {
flex: 1,
- backgroundColor: theme.colors.background,
+ backgroundColor: theme.colors.surface0,
alignItems: "center",
justifyContent: "center",
},
@@ -44,7 +45,7 @@ const styles = StyleSheet.create((theme) => ({
},
container: {
flex: 1,
- backgroundColor: theme.colors.background,
+ backgroundColor: theme.colors.surface0,
},
scrollView: {
flex: 1,
@@ -57,7 +58,7 @@ const styles = StyleSheet.create((theme) => ({
marginBottom: theme.spacing[6],
},
sectionTitle: {
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.semibold,
letterSpacing: 0.6,
@@ -66,7 +67,7 @@ const styles = StyleSheet.create((theme) => ({
marginLeft: theme.spacing[1],
},
label: {
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.semibold,
letterSpacing: 0.4,
@@ -74,7 +75,7 @@ const styles = StyleSheet.create((theme) => ({
marginBottom: theme.spacing[2],
},
input: {
- backgroundColor: theme.colors.background,
+ backgroundColor: theme.colors.surface0,
color: theme.colors.foreground,
padding: theme.spacing[3],
borderRadius: theme.borderRadius.md,
@@ -87,7 +88,7 @@ const styles = StyleSheet.create((theme) => ({
},
// Host card styles
hostCard: {
- backgroundColor: theme.colors.card,
+ backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg,
borderWidth: 1,
borderColor: theme.colors.border,
@@ -109,7 +110,7 @@ const styles = StyleSheet.create((theme) => ({
fontWeight: theme.fontWeight.semibold,
},
hostUrl: {
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
fontFamily: Fonts.mono,
},
@@ -151,7 +152,7 @@ const styles = StyleSheet.create((theme) => ({
borderRadius: theme.borderRadius.md,
},
hostActionText: {
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.semibold,
},
@@ -189,13 +190,13 @@ const styles = StyleSheet.create((theme) => ({
borderStyle: "dashed",
},
addButtonText: {
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.semibold,
},
// Add/Edit form
formCard: {
- backgroundColor: theme.colors.card,
+ backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg,
borderWidth: 1,
borderColor: theme.colors.border,
@@ -237,7 +238,7 @@ const styles = StyleSheet.create((theme) => ({
},
// Audio settings card
audioCard: {
- backgroundColor: theme.colors.card,
+ backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg,
borderWidth: 1,
borderColor: theme.colors.border,
@@ -263,7 +264,7 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.base,
},
audioRowDescription: {
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
marginTop: 2,
},
@@ -281,11 +282,11 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[1],
},
footerText: {
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
footerVersion: {
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
resetButton: {
@@ -298,7 +299,7 @@ const styles = StyleSheet.create((theme) => ({
},
// Empty state
emptyCard: {
- backgroundColor: theme.colors.card,
+ backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg,
borderWidth: 1,
borderColor: theme.colors.border,
@@ -306,10 +307,39 @@ const styles = StyleSheet.create((theme) => ({
marginBottom: theme.spacing[3],
},
emptyText: {
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
textAlign: "center",
},
+ // Theme toggle
+ themeToggleContainer: {
+ flexDirection: "row",
+ backgroundColor: theme.colors.surface2,
+ borderRadius: theme.borderRadius.lg,
+ padding: 4,
+ gap: 4,
+ },
+ themeToggleButton: {
+ flex: 1,
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "center",
+ gap: theme.spacing[2],
+ paddingVertical: theme.spacing[3],
+ paddingHorizontal: theme.spacing[3],
+ borderRadius: theme.borderRadius.md,
+ },
+ themeToggleButtonActive: {
+ backgroundColor: theme.colors.surface3,
+ },
+ themeToggleText: {
+ fontSize: theme.fontSize.sm,
+ fontWeight: theme.fontWeight.medium,
+ color: theme.colors.foregroundMuted,
+ },
+ themeToggleTextActive: {
+ color: theme.colors.foreground,
+ },
}));
type DaemonTestState = {
@@ -502,6 +532,19 @@ export default function SettingsScreen() {
[settings, updateSettings]
);
+ const handleThemeChange = useCallback(
+ (newTheme: AppSettings["theme"]) => {
+ void updateSettings({ theme: newTheme });
+ if (newTheme === "auto") {
+ UnistylesRuntime.setAdaptiveThemes(true);
+ } else {
+ UnistylesRuntime.setAdaptiveThemes(false);
+ UnistylesRuntime.setTheme(newTheme);
+ }
+ },
+ [updateSettings]
+ );
+
function validateServerUrl(url: string): boolean {
try {
const urlObj = new URL(url);
@@ -637,6 +680,49 @@ export default function SettingsScreen() {
)}
+ {/* Appearance */}
+
+ Appearance
+
+ handleThemeChange("light")}
+ >
+
+
+ Light
+
+
+ handleThemeChange("dark")}
+ >
+
+
+ Dark
+
+
+ handleThemeChange("auto")}
+ >
+
+
+ System
+
+
+
+
+
{/* Audio Settings */}
Audio
@@ -727,7 +813,7 @@ function DaemonCard({
? theme.colors.palette.amber[500]
: statusTone === "error"
? theme.colors.destructive
- : theme.colors.mutedForeground;
+ : theme.colors.foregroundMuted;
const badgeText = statusLabel;
const connectionError = typeof lastError === "string" && lastError.trim().length > 0 ? lastError.trim() : null;
const daemonConnection = useSessionStore(
@@ -870,7 +956,7 @@ function DaemonCard({
? theme.colors.palette.green[400]
: testState?.status === "error"
? theme.colors.palette.red[300]
- : theme.colors.mutedForeground;
+ : theme.colors.foregroundMuted;
return (
@@ -911,7 +997,7 @@ function DaemonCard({
disabled={isRestarting}
>
{isRestarting ? (
-
+
) : (
Restart
)}
diff --git a/packages/app/src/components/active-processes.tsx b/packages/app/src/components/active-processes.tsx
index c30c5403b..2f9769552 100644
--- a/packages/app/src/components/active-processes.tsx
+++ b/packages/app/src/components/active-processes.tsx
@@ -46,7 +46,7 @@ function getModeColor(modeId?: string): string {
const styles = StyleSheet.create((theme) => ({
container: {
- backgroundColor: theme.colors.card,
+ backgroundColor: theme.colors.surface2,
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
},
@@ -57,7 +57,7 @@ const styles = StyleSheet.create((theme) => ({
borderBottomColor: theme.colors.border,
},
backButton: {
- backgroundColor: theme.colors.muted,
+ backgroundColor: theme.colors.surface2,
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[2],
borderRadius: theme.borderRadius.lg,
@@ -87,7 +87,7 @@ const styles = StyleSheet.create((theme) => ({
backgroundColor: theme.colors.primary,
},
processItemInactive: {
- backgroundColor: theme.colors.muted,
+ backgroundColor: theme.colors.surface2,
},
agentIcon: {
width: 12,
diff --git a/packages/app/src/components/agent-activity.tsx b/packages/app/src/components/agent-activity.tsx
index 8a75366a2..635790af9 100644
--- a/packages/app/src/components/agent-activity.tsx
+++ b/packages/app/src/components/agent-activity.tsx
@@ -252,19 +252,19 @@ export function AgentActivityItem({ item }: AgentActivityItemProps) {
const stylesheet = StyleSheet.create((theme) => ({
card: {
- backgroundColor: theme.colors.card,
+ backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg,
padding: theme.spacing[3],
marginBottom: theme.spacing[2],
},
thoughtCard: {
- backgroundColor: theme.colors.muted,
+ backgroundColor: theme.colors.surface2,
borderLeftWidth: 3,
borderLeftColor: theme.colors.palette.purple[500],
paddingVertical: theme.spacing[2],
},
timestamp: {
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
marginBottom: theme.spacing[1],
},
@@ -283,11 +283,11 @@ const stylesheet = StyleSheet.create((theme) => ({
lineHeight: 20,
},
thoughtText: {
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
fontStyle: 'italic',
},
toolCard: {
- backgroundColor: theme.colors.card,
+ backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg,
marginBottom: theme.spacing[2],
overflow: 'hidden',
@@ -327,7 +327,7 @@ const stylesheet = StyleSheet.create((theme) => ({
fontWeight: theme.fontWeight.semibold,
},
expandIcon: {
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
toolContent: {
@@ -345,20 +345,20 @@ const stylesheet = StyleSheet.create((theme) => ({
marginBottom: theme.spacing[1],
},
code: {
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
fontFamily: Fonts.mono,
- backgroundColor: theme.colors.muted,
+ backgroundColor: theme.colors.surface2,
padding: theme.spacing[2],
borderRadius: theme.borderRadius.md,
},
emptyText: {
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
fontStyle: 'italic',
},
planCard: {
- backgroundColor: theme.colors.card,
+ backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg,
marginBottom: theme.spacing[2],
overflow: 'hidden',
@@ -399,11 +399,11 @@ const stylesheet = StyleSheet.create((theme) => ({
flex: 1,
},
infoText: {
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
unknownCard: {
- backgroundColor: theme.colors.card,
+ backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg,
marginBottom: theme.spacing[2],
overflow: 'hidden',
@@ -428,6 +428,6 @@ const stylesheet = StyleSheet.create((theme) => ({
borderTopWidth: theme.borderWidth[1],
borderTopColor: theme.colors.border,
padding: theme.spacing[3],
- backgroundColor: theme.colors.muted,
+ backgroundColor: theme.colors.surface2,
},
}));
diff --git a/packages/app/src/components/agent-form/agent-form-dropdowns.tsx b/packages/app/src/components/agent-form/agent-form-dropdowns.tsx
index bb6444ff9..967b09f76 100644
--- a/packages/app/src/components/agent-form/agent-form-dropdowns.tsx
+++ b/packages/app/src/components/agent-form/agent-form-dropdowns.tsx
@@ -87,7 +87,7 @@ export function DropdownField({
>
{value || placeholder}
-
+
{errorMessage ? {errorMessage} : null}
{warningMessage ? {warningMessage} : null}
@@ -138,7 +138,7 @@ export function SelectField({
{value || placeholder || "Select..."}
-
+
{errorMessage ? {errorMessage} : null}
{warningMessage ? {warningMessage} : null}
@@ -404,7 +404,7 @@ export function ComboSelect({
ref={inputRef}
style={styles.dropdownSearchInput}
placeholder={`Search ${label.toLowerCase()}...`}
- placeholderTextColor={defaultTheme.colors.mutedForeground}
+ placeholderTextColor={defaultTheme.colors.foregroundMuted}
value={searchQuery}
onChangeText={setSearchQuery}
autoCapitalize="none"
@@ -486,7 +486,7 @@ function CompactSelectField({
>
{label}
{isLoading ? (
-
+
) : (
-
+
) : (
{displayBranch}
-
+
)}
@@ -1179,7 +1179,7 @@ const styles = StyleSheet.create((theme) => ({
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
- backgroundColor: theme.colors.background,
+ backgroundColor: theme.colors.surface0,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.lg,
@@ -1196,14 +1196,14 @@ const styles = StyleSheet.create((theme) => ({
},
dropdownPlaceholder: {
flex: 1,
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
},
dropdownSearchInput: {
borderRadius: theme.borderRadius.md,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
- backgroundColor: theme.colors.background,
+ backgroundColor: theme.colors.surface0,
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
color: theme.colors.foreground,
@@ -1222,7 +1222,7 @@ const styles = StyleSheet.create((theme) => ({
opacity: 0.45,
},
dropdownSheetContainer: {
- backgroundColor: theme.colors.card,
+ backgroundColor: theme.colors.surface2,
borderTopLeftRadius: theme.borderRadius["2xl"],
borderTopRightRadius: theme.borderRadius["2xl"],
paddingTop: theme.spacing[4],
@@ -1259,7 +1259,7 @@ const styles = StyleSheet.create((theme) => ({
borderRadius: theme.borderRadius.lg,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
- backgroundColor: theme.colors.background,
+ backgroundColor: theme.colors.surface0,
marginBottom: theme.spacing[2],
},
dropdownSheetOptionSelected: {
@@ -1271,7 +1271,7 @@ const styles = StyleSheet.create((theme) => ({
fontWeight: theme.fontWeight.semibold,
},
dropdownSheetOptionDescription: {
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
marginTop: theme.spacing[1],
},
@@ -1288,7 +1288,7 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.sm,
},
helperText: {
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
selectorColumn: {
@@ -1339,7 +1339,7 @@ const styles = StyleSheet.create((theme) => ({
fontWeight: theme.fontWeight.semibold,
},
input: {
- backgroundColor: theme.colors.background,
+ backgroundColor: theme.colors.surface0,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.lg,
@@ -1359,7 +1359,7 @@ const styles = StyleSheet.create((theme) => ({
selectFieldControl: {
flexDirection: "row",
alignItems: "center",
- backgroundColor: theme.colors.background,
+ backgroundColor: theme.colors.surface0,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.lg,
@@ -1374,7 +1374,7 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[1],
},
selectFieldLabel: {
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.medium,
textTransform: "uppercase",
@@ -1385,7 +1385,7 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.base,
},
selectFieldPlaceholder: {
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
},
gitOptionsContainer: {
@@ -1395,7 +1395,7 @@ const styles = StyleSheet.create((theme) => ({
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
- backgroundColor: theme.colors.background,
+ backgroundColor: theme.colors.surface0,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.lg,
@@ -1415,7 +1415,7 @@ const styles = StyleSheet.create((theme) => ({
fontWeight: theme.fontWeight.semibold,
},
worktreeToggleDescription: {
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
baseBranchRow: {
@@ -1425,7 +1425,7 @@ const styles = StyleSheet.create((theme) => ({
paddingHorizontal: theme.spacing[4],
},
baseBranchLabel: {
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
baseBranchValueRow: {
@@ -1446,7 +1446,7 @@ const styles = StyleSheet.create((theme) => ({
},
baseBranchInput: {
flex: 1,
- backgroundColor: theme.colors.background,
+ backgroundColor: theme.colors.surface0,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.md,
@@ -1469,7 +1469,7 @@ const styles = StyleSheet.create((theme) => ({
left: 0,
},
desktopDropdownContainer: {
- backgroundColor: theme.colors.popover,
+ backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
@@ -1494,7 +1494,7 @@ const styles = StyleSheet.create((theme) => ({
flex: 1,
},
compactSelectControl: {
- backgroundColor: theme.colors.background,
+ backgroundColor: theme.colors.surface0,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.lg,
@@ -1506,7 +1506,7 @@ const styles = StyleSheet.create((theme) => ({
opacity: theme.opacity[50],
},
compactSelectLabel: {
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.medium,
textTransform: "uppercase",
@@ -1517,7 +1517,7 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.sm,
},
compactSelectPlaceholder: {
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
}));
diff --git a/packages/app/src/components/agent-input-area.tsx b/packages/app/src/components/agent-input-area.tsx
index 42660dee4..96450802e 100644
--- a/packages/app/src/components/agent-input-area.tsx
+++ b/packages/app/src/components/agent-input-area.tsx
@@ -22,6 +22,7 @@ import { AgentStatusBar } from "./agent-status-bar";
import { RealtimeControls } from "./realtime-controls";
import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker";
import { useSessionStore } from "@/stores/session-store";
+import { useDraftStore } from "@/stores/draft-store";
import {
MessageInput,
type MessagePayload,
@@ -86,8 +87,8 @@ export function AgentInputArea({
state.sessions[serverId]?.agents?.get(agentId)
);
- const getDraftInput = useSessionStore((state) => state.getDraftInput);
- const saveDraftInput = useSessionStore((state) => state.saveDraftInput);
+ const getDraftInput = useDraftStore((state) => state.getDraftInput);
+ const saveDraftInput = useDraftStore((state) => state.saveDraftInput);
const queuedMessagesRaw = useSessionStore((state) =>
state.sessions[serverId]?.queuedMessages?.get(agentId)
@@ -639,7 +640,7 @@ const styles = StyleSheet.create(((theme: Theme) => ({
justifyContent: "space-between",
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
- backgroundColor: theme.colors.card,
+ backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
@@ -661,7 +662,7 @@ const styles = StyleSheet.create(((theme: Theme) => ({
borderRadius: theme.borderRadius.full,
alignItems: "center",
justifyContent: "center",
- backgroundColor: theme.colors.muted,
+ backgroundColor: theme.colors.surface2,
},
queueSendButton: {
backgroundColor: theme.colors.palette.blue[600],
diff --git a/packages/app/src/components/agent-list.tsx b/packages/app/src/components/agent-list.tsx
index cffdd1ef7..d86bd96c0 100644
--- a/packages/app/src/components/agent-list.tsx
+++ b/packages/app/src/components/agent-list.tsx
@@ -94,7 +94,7 @@ export function AgentList({
const isRunning = agent.status === "running";
const agentKey = `${agent.serverId}:${agent.id}`;
const isSelected = selectedAgentId === agentKey;
- const statusColor = isRunning ? "#3b82f6" : agent.requiresAttention ? "#22c55e" : null;
+ const statusColor = isRunning ? theme.colors.accent : agent.requiresAttention ? theme.colors.success : null;
return (
) : undefined
}
@@ -225,19 +225,19 @@ const styles = StyleSheet.create((theme) => ({
paddingBottom: theme.spacing[4],
},
agentItem: {
- paddingVertical: theme.spacing[3],
+ paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
- marginBottom: theme.spacing[2],
+ marginBottom: theme.spacing[1],
},
agentItemSelected: {
- backgroundColor: theme.colors.palette.zinc[800],
+ backgroundColor: theme.colors.surface2,
},
agentItemHovered: {
- backgroundColor: theme.colors.palette.zinc[850],
+ backgroundColor: theme.colors.surface1,
},
agentItemPressed: {
- backgroundColor: theme.colors.palette.zinc[800],
+ backgroundColor: theme.colors.surface2,
},
agentContent: {
flex: 1,
@@ -255,7 +255,7 @@ const styles = StyleSheet.create((theme) => ({
xs: "400",
md: "300",
},
- color: theme.colors.palette.zinc[200],
+ color: theme.colors.foregroundMuted,
},
agentTitleHighlighted: {
color: theme.colors.foreground,
@@ -263,7 +263,7 @@ const styles = StyleSheet.create((theme) => ({
secondaryRow: {
fontSize: theme.fontSize.sm,
fontWeight: "300",
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
},
statusDot: {
width: 8,
@@ -283,7 +283,7 @@ const styles = StyleSheet.create((theme) => ({
backgroundColor: "rgba(0,0,0,0.35)",
},
sheetContainer: {
- backgroundColor: theme.colors.card,
+ backgroundColor: theme.colors.surface2,
borderTopLeftRadius: theme.borderRadius["2xl"],
borderTopRightRadius: theme.borderRadius["2xl"],
paddingHorizontal: theme.spacing[6],
@@ -296,7 +296,7 @@ const styles = StyleSheet.create((theme) => ({
width: 40,
height: 4,
borderRadius: theme.borderRadius.full,
- backgroundColor: theme.colors.muted,
+ backgroundColor: theme.colors.surface2,
},
sheetTitle: {
fontSize: theme.fontSize.lg,
@@ -305,7 +305,7 @@ const styles = StyleSheet.create((theme) => ({
},
sheetSubtitle: {
fontSize: theme.fontSize.sm,
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
},
sheetButton: {
borderRadius: theme.borderRadius.lg,
@@ -324,7 +324,7 @@ const styles = StyleSheet.create((theme) => ({
opacity: 0.5,
},
sheetCancelButton: {
- backgroundColor: theme.colors.muted,
+ backgroundColor: theme.colors.surface2,
},
sheetCancelText: {
color: theme.colors.foreground,
diff --git a/packages/app/src/components/agent-status-bar.tsx b/packages/app/src/components/agent-status-bar.tsx
index c48ea2c7a..ae76224a3 100644
--- a/packages/app/src/components/agent-status-bar.tsx
+++ b/packages/app/src/components/agent-status-bar.tsx
@@ -52,7 +52,7 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
agent.currentModeId ||
"default"}
-
+
)}
@@ -76,17 +76,17 @@ const styles = StyleSheet.create((theme) => ({
modeBadge: {
flexDirection: "row",
alignItems: "center",
- backgroundColor: theme.colors.popover,
+ backgroundColor: theme.colors.surface2,
gap: theme.spacing[1],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
borderRadius: theme.borderRadius["2xl"],
},
modeBadgePressed: {
- backgroundColor: theme.colors.background,
+ backgroundColor: theme.colors.surface0,
},
modeBadgeText: {
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.semibold,
textTransform: "capitalize",
diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx
index 28e0c91cb..55544264b 100644
--- a/packages/app/src/components/agent-stream-view.tsx
+++ b/packages/app/src/components/agent-stream-view.tsx
@@ -85,9 +85,7 @@ export function AgentStreamView({
const [isNearBottom, setIsNearBottom] = useState(true);
const hasScrolledInitially = useRef(false);
const hasAutoScrolledOnce = useRef(false);
- const isProgrammaticScrollRef = useRef(false);
const isNearBottomRef = useRef(true);
- const isUserScrollingRef = useRef(false);
const streamItemCountRef = useRef(0);
const { open: openExplorer, setActiveTab: setExplorerTab } =
useExplorerSidebarStore();
@@ -160,42 +158,17 @@ export function AgentStreamView({
(event: NativeSyntheticEvent) => {
const { contentOffset } = event.nativeEvent;
const threshold = Math.max(insets.bottom, 32);
+ // In inverted list: scrollTop 0 = bottom, higher values = scrolled up
const nearBottom = contentOffset.y <= threshold;
- if (isProgrammaticScrollRef.current) {
- if (nearBottom && !isNearBottomRef.current) {
- isNearBottomRef.current = true;
- setIsNearBottom(true);
- }
- return;
+ if (isNearBottomRef.current !== nearBottom) {
+ isNearBottomRef.current = nearBottom;
+ setIsNearBottom(nearBottom);
}
-
- if (!nearBottom && !isUserScrollingRef.current) {
- return;
- }
-
- if (isNearBottomRef.current === nearBottom) {
- return;
- }
-
- isNearBottomRef.current = nearBottom;
- setIsNearBottom(nearBottom);
},
[insets.bottom]
);
- const handleScrollBeginDrag = useCallback(() => {
- isUserScrollingRef.current = true;
- }, []);
-
- const handleMomentumScrollBegin = useCallback(() => {
- isUserScrollingRef.current = true;
- }, []);
-
- const handleScrollEnd = useCallback(() => {
- isUserScrollingRef.current = false;
- }, []);
-
const scrollToBottomInternal = useCallback(
({ animated }: { animated: boolean }) => {
const list = flatListRef.current;
@@ -203,19 +176,9 @@ export function AgentStreamView({
return;
}
- isProgrammaticScrollRef.current = true;
list.scrollToOffset({ offset: 0, animated });
- if (!animated) {
- isProgrammaticScrollRef.current = false;
- isNearBottomRef.current = true;
- setIsNearBottom(true);
- } else {
- setTimeout(() => {
- isProgrammaticScrollRef.current = false;
- isNearBottomRef.current = true;
- setIsNearBottom(true);
- }, 300);
- }
+ isNearBottomRef.current = true;
+ setIsNearBottom(true);
},
[]
);
@@ -582,10 +545,6 @@ export function AgentStreamView({
}}
style={stylesheet.list}
onScroll={handleScroll}
- onScrollBeginDrag={handleScrollBeginDrag}
- onScrollEndDrag={handleScrollEnd}
- onMomentumScrollBegin={handleMomentumScrollBegin}
- onMomentumScrollEnd={handleScrollEnd}
scrollEventThrottle={16}
ListEmptyComponent={
@@ -1005,7 +964,7 @@ function PermissionRequestCard({
style={[
permissionStyles.container,
{
- backgroundColor: theme.colors.secondary,
+ backgroundColor: theme.colors.surface2,
borderColor: theme.colors.border,
},
]}
@@ -1020,7 +979,7 @@ function PermissionRequestCard({
{description}
@@ -1032,7 +991,7 @@ function PermissionRequestCard({
Proposed Plan
@@ -1041,7 +1000,7 @@ function PermissionRequestCard({
style={[
permissionStyles.contentCard,
{
- backgroundColor: theme.colors.background,
+ backgroundColor: theme.colors.surface0,
borderColor: theme.colors.border,
},
]}
@@ -1058,7 +1017,7 @@ function PermissionRequestCard({
Command
@@ -1068,7 +1027,7 @@ function PermissionRequestCard({
Command
@@ -1088,7 +1047,7 @@ function PermissionRequestCard({
Directory
@@ -1111,7 +1070,7 @@ function PermissionRequestCard({
Proposed Changes
@@ -1127,7 +1086,7 @@ function PermissionRequestCard({
permissionStyles.fileBadge,
{
borderColor: theme.colors.border,
- backgroundColor: theme.colors.card,
+ backgroundColor: theme.colors.surface2,
},
]}
>
@@ -1146,7 +1105,7 @@ function PermissionRequestCard({
permissionStyles.diffWrapper,
{
borderColor: theme.colors.border,
- backgroundColor: theme.colors.card,
+ backgroundColor: theme.colors.surface2,
},
]}
>
@@ -1162,7 +1121,7 @@ function PermissionRequestCard({
File Content
@@ -1173,7 +1132,7 @@ function PermissionRequestCard({
style={[
permissionStyles.contentCard,
{
- backgroundColor: theme.colors.background,
+ backgroundColor: theme.colors.surface0,
borderColor: theme.colors.border,
},
]}
@@ -1183,7 +1142,7 @@ function PermissionRequestCard({
style={[
permissionStyles.metadataLabel,
{
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
marginBottom: theme.spacing[1],
},
]}
@@ -1209,7 +1168,7 @@ function PermissionRequestCard({
Raw Request
@@ -1218,7 +1177,7 @@ function PermissionRequestCard({
style={[
permissionStyles.contentCard,
{
- backgroundColor: theme.colors.background,
+ backgroundColor: theme.colors.surface0,
borderColor: theme.colors.border,
},
]}
@@ -1306,7 +1265,7 @@ function PermissionRequestCard({
const stylesheet = StyleSheet.create((theme) => ({
container: {
flex: 1,
- backgroundColor: theme.colors.background,
+ backgroundColor: theme.colors.surface0,
},
contentWrapper: {
width: "100%",
@@ -1361,14 +1320,14 @@ const stylesheet = StyleSheet.create((theme) => ({
width: 6,
height: 6,
borderRadius: 3,
- backgroundColor: theme.colors.mutedForeground,
+ backgroundColor: theme.colors.foregroundMuted,
},
invertedWrapper: {
transform: [{ scaleY: -1 }],
width: "100%",
},
emptyStateText: {
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
textAlign: "center",
},
@@ -1390,7 +1349,7 @@ const stylesheet = StyleSheet.create((theme) => ({
width: 48,
height: 48,
borderRadius: 24,
- backgroundColor: theme.colors.muted,
+ backgroundColor: theme.colors.surface2,
alignItems: "center",
justifyContent: "center",
shadowColor: "#000",
diff --git a/packages/app/src/components/artifact-drawer.tsx b/packages/app/src/components/artifact-drawer.tsx
index c555c9e29..51fc81302 100644
--- a/packages/app/src/components/artifact-drawer.tsx
+++ b/packages/app/src/components/artifact-drawer.tsx
@@ -26,7 +26,7 @@ interface ArtifactDrawerProps {
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
- backgroundColor: theme.colors.background,
+ backgroundColor: theme.colors.surface0,
flexDirection: "column",
},
header: {
@@ -77,7 +77,7 @@ const styles = StyleSheet.create((theme) => ({
fontWeight: theme.fontWeight.semibold,
},
closeButton: {
- backgroundColor: theme.colors.muted,
+ backgroundColor: theme.colors.surface2,
width: 40,
height: 40,
borderRadius: 20,
@@ -91,7 +91,7 @@ const styles = StyleSheet.create((theme) => ({
},
contentScroll: {
flex: 1,
- backgroundColor: theme.colors.background,
+ backgroundColor: theme.colors.surface0,
},
contentScrollContainer: {
padding: theme.spacing[4],
@@ -103,16 +103,16 @@ const styles = StyleSheet.create((theme) => ({
justifyContent: "center",
},
imagePlaceholderText: {
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
imagePlaceholderSubtext: {
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
marginTop: theme.spacing[2],
},
codeContainer: {
- backgroundColor: theme.colors.card,
+ backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg,
padding: theme.spacing[4],
borderWidth: theme.borderWidth[1],
@@ -124,14 +124,14 @@ const styles = StyleSheet.create((theme) => ({
fontFamily: Fonts.mono,
},
metadataContainer: {
- backgroundColor: theme.colors.card,
+ backgroundColor: theme.colors.surface2,
padding: theme.spacing[3],
borderTopWidth: theme.borderWidth[1],
borderTopColor: theme.colors.border,
marginTop: theme.spacing[2],
},
metadataTitle: {
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.semibold,
marginBottom: theme.spacing[2],
@@ -141,12 +141,12 @@ const styles = StyleSheet.create((theme) => ({
marginBottom: theme.spacing[1],
},
metadataLabel: {
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
width: 80,
},
metadataValue: {
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
flex: 1,
fontFamily: Fonts.mono,
diff --git a/packages/app/src/components/audio-debug-notice.tsx b/packages/app/src/components/audio-debug-notice.tsx
index 42b4be6a2..839974056 100644
--- a/packages/app/src/components/audio-debug-notice.tsx
+++ b/packages/app/src/components/audio-debug-notice.tsx
@@ -100,16 +100,16 @@ export function AudioDebugNotice({ info, onDismiss, title = "Dictation Debug" }:
style={[
styles.container,
{
- backgroundColor: theme.colors.secondary,
+ backgroundColor: theme.colors.surface2,
borderColor: theme.colors.border,
},
]}
>
- {title}
+ {title}
{onDismiss ? (
-
+
) : null}
@@ -131,13 +131,13 @@ export function AudioDebugNotice({ info, onDismiss, title = "Dictation Debug" }:
) : (
-
+
Raw audio path unavailable. Set STT_DEBUG_AUDIO_DIR on the server to persist recordings.
)}
{stats ? (
-
+
{stats}
) : null}
diff --git a/packages/app/src/components/command-autocomplete.tsx b/packages/app/src/components/command-autocomplete.tsx
index 14751df10..fb83afafe 100644
--- a/packages/app/src/components/command-autocomplete.tsx
+++ b/packages/app/src/components/command-autocomplete.tsx
@@ -108,7 +108,7 @@ export function useCommandAutocomplete(commands: AgentSlashCommand[], filter: st
const styles = StyleSheet.create(((theme: Theme) => ({
container: {
- backgroundColor: theme.colors.card,
+ backgroundColor: theme.colors.surface2,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.lg,
@@ -136,12 +136,12 @@ const styles = StyleSheet.create(((theme: Theme) => ({
fontFamily: Fonts.mono,
},
commandArgs: {
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
fontFamily: Fonts.mono,
},
commandDescription: {
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
marginTop: 2,
},
@@ -150,7 +150,7 @@ const styles = StyleSheet.create(((theme: Theme) => ({
paddingVertical: theme.spacing[3],
},
loadingText: {
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
emptyItem: {
@@ -158,7 +158,7 @@ const styles = StyleSheet.create(((theme: Theme) => ({
paddingVertical: theme.spacing[3],
},
emptyText: {
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
})) as any) as Record;
diff --git a/packages/app/src/components/conversation-selector.tsx b/packages/app/src/components/conversation-selector.tsx
index fb703e97c..07a13a94d 100644
--- a/packages/app/src/components/conversation-selector.tsx
+++ b/packages/app/src/components/conversation-selector.tsx
@@ -317,7 +317,7 @@ export function ConversationSelector({
const styles = StyleSheet.create((theme) => ({
triggerButton: {
- backgroundColor: theme.colors.muted,
+ backgroundColor: theme.colors.surface2,
padding: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
},
@@ -329,7 +329,7 @@ const styles = StyleSheet.create((theme) => ({
flex: 1,
},
modalContent: {
- backgroundColor: theme.colors.card,
+ backgroundColor: theme.colors.surface2,
borderTopLeftRadius: theme.spacing[6],
borderTopRightRadius: theme.spacing[6],
height: "80%",
@@ -360,7 +360,7 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[2],
- backgroundColor: theme.colors.muted,
+ backgroundColor: theme.colors.surface2,
paddingVertical: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
},
@@ -379,14 +379,14 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center",
},
loadingText: {
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
},
emptyContainer: {
padding: theme.spacing[8],
alignItems: "center",
},
emptyText: {
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
},
conversationItem: {
flexDirection: "row",
@@ -398,7 +398,7 @@ const styles = StyleSheet.create((theme) => ({
backgroundColor: "transparent",
},
conversationItemActive: {
- backgroundColor: theme.colors.muted,
+ backgroundColor: theme.colors.surface2,
},
conversationContent: {
flex: 1,
@@ -409,7 +409,7 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.base,
},
conversationDate: {
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
marginTop: theme.spacing[1],
},
diff --git a/packages/app/src/components/dictation-controls.tsx b/packages/app/src/components/dictation-controls.tsx
index c40773ebf..a1faf7338 100644
--- a/packages/app/src/components/dictation-controls.tsx
+++ b/packages/app/src/components/dictation-controls.tsx
@@ -102,7 +102,7 @@ export function DictationControls({
accessibilityLabel="Retry dictation"
style={[styles.actionButton, styles.actionButtonConfirm]}
>
-
+
) : (
<>
@@ -118,7 +118,7 @@ export function DictationControls({
accessibilityLabel="Insert transcription and send"
style={[styles.actionButton, styles.actionButtonConfirm]}
>
-
+
>
)}
@@ -165,7 +165,7 @@ export function DictationOverlay({
@@ -243,7 +243,7 @@ export function DictationOverlay({
>
@@ -265,7 +265,7 @@ const styles = StyleSheet.create((theme) => ({
justifyContent: "center",
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
- backgroundColor: theme.colors.background,
+ backgroundColor: theme.colors.surface0,
},
activeContainer: {
flexDirection: "row",
@@ -296,11 +296,11 @@ const styles = StyleSheet.create((theme) => ({
},
actionButtonCancel: {
borderColor: theme.colors.border,
- backgroundColor: theme.colors.background,
+ backgroundColor: theme.colors.surface0,
},
actionButtonSecondary: {
borderColor: theme.colors.border,
- backgroundColor: theme.colors.background,
+ backgroundColor: theme.colors.surface0,
},
actionButtonConfirm: {
borderColor: theme.colors.foreground,
diff --git a/packages/app/src/components/dictation-status-notice.tsx b/packages/app/src/components/dictation-status-notice.tsx
index 442ac7dd5..96d31d4a9 100644
--- a/packages/app/src/components/dictation-status-notice.tsx
+++ b/packages/app/src/components/dictation-status-notice.tsx
@@ -48,12 +48,12 @@ export function DictationStatusNotice({
case "error":
return theme.colors.palette.red[500];
default:
- return theme.colors.background;
+ return theme.colors.surface0;
}
})();
const foregroundColor = variant === "info" ? theme.colors.foreground : theme.colors.palette.white;
- const secondaryColor = variant === "info" ? theme.colors.mutedForeground : theme.colors.palette.white;
+ const secondaryColor = variant === "info" ? theme.colors.foregroundMuted : theme.colors.palette.white;
return (
({
color: theme.colors.foreground,
},
headerLine: {
- backgroundColor: theme.colors.muted,
+ backgroundColor: theme.colors.surface2,
},
headerText: {
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
},
addLine: {
backgroundColor: "rgba(46, 160, 67, 0.15)",
},
addText: {
- color: theme.colors.foreground,
+ color: theme.colors.palette.green[800],
},
removeLine: {
backgroundColor: "rgba(248, 81, 73, 0.1)",
},
removeText: {
- color: theme.colors.foreground,
+ color: theme.colors.palette.red[800],
},
addHighlight: {
backgroundColor: "rgba(46, 160, 67, 0.4)",
@@ -132,10 +132,10 @@ const styles = StyleSheet.create((theme) => ({
backgroundColor: "rgba(248, 81, 73, 0.35)",
},
contextLine: {
- backgroundColor: theme.colors.card,
+ backgroundColor: theme.colors.surface2,
},
contextText: {
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
},
emptyState: {
padding: theme.spacing[4],
@@ -144,6 +144,6 @@ const styles = StyleSheet.create((theme) => ({
},
emptyText: {
fontSize: theme.fontSize.sm,
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
},
}));
diff --git a/packages/app/src/components/empty-state.tsx b/packages/app/src/components/empty-state.tsx
index 6ec39e6b1..1dff80b00 100644
--- a/packages/app/src/components/empty-state.tsx
+++ b/packages/app/src/components/empty-state.tsx
@@ -49,7 +49,7 @@ const styles = StyleSheet.create((theme) => ({
},
subtitle: {
fontSize: theme.fontSize.lg,
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
textAlign: "center",
marginBottom: theme.spacing[8],
},
@@ -77,7 +77,7 @@ const styles = StyleSheet.create((theme) => ({
secondaryButton: {
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
- backgroundColor: theme.colors.muted,
+ backgroundColor: theme.colors.surface2,
},
secondaryButtonText: {
fontSize: theme.fontSize.base,
diff --git a/packages/app/src/components/explorer-sidebar.tsx b/packages/app/src/components/explorer-sidebar.tsx
index cb15b09ff..6f2d97f60 100644
--- a/packages/app/src/components/explorer-sidebar.tsx
+++ b/packages/app/src/components/explorer-sidebar.tsx
@@ -251,7 +251,7 @@ function SidebarContent({
color={
activeTab === "changes"
? theme.colors.foreground
- : theme.colors.mutedForeground
+ : theme.colors.foregroundMuted
}
/>
-
+
)}
@@ -352,14 +352,14 @@ const styles = StyleSheet.create((theme) => ({
top: 0,
right: 0,
bottom: 0,
- backgroundColor: theme.colors.background,
+ backgroundColor: theme.colors.surface0,
overflow: "hidden",
},
desktopSidebar: {
position: "relative",
borderLeftWidth: 1,
borderLeftColor: theme.colors.border,
- backgroundColor: theme.colors.background,
+ backgroundColor: theme.colors.surface0,
},
resizeHandle: {
position: "absolute",
@@ -396,12 +396,12 @@ const styles = StyleSheet.create((theme) => ({
borderRadius: theme.borderRadius.md,
},
tabActive: {
- backgroundColor: theme.colors.muted,
+ backgroundColor: theme.colors.surface2,
},
tabText: {
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
},
tabTextActive: {
color: theme.colors.foreground,
@@ -430,6 +430,6 @@ const styles = StyleSheet.create((theme) => ({
padding: theme.spacing[2],
},
viewToggleActive: {
- backgroundColor: theme.colors.muted,
+ backgroundColor: theme.colors.surface2,
},
}));
diff --git a/packages/app/src/components/file-drop-zone.tsx b/packages/app/src/components/file-drop-zone.tsx
index 09c4b441d..13dc3b9c3 100644
--- a/packages/app/src/components/file-drop-zone.tsx
+++ b/packages/app/src/components/file-drop-zone.tsx
@@ -80,7 +80,7 @@ const styles = StyleSheet.create((theme) => ({
},
backdrop: {
...StyleSheet.absoluteFillObject,
- backgroundColor: theme.colors.background,
+ backgroundColor: theme.colors.surface0,
opacity: 0.7,
},
overlayContent: {
diff --git a/packages/app/src/components/file-explorer-pane.tsx b/packages/app/src/components/file-explorer-pane.tsx
index be434114c..550edcf92 100644
--- a/packages/app/src/components/file-explorer-pane.tsx
+++ b/packages/app/src/components/file-explorer-pane.tsx
@@ -756,7 +756,7 @@ export function FileExplorerPane({
{currentSortLabel}
-
+
{showListLoadingBanner && (
@@ -947,7 +947,7 @@ export function FileExplorerPane({
{selectedEntryPath?.split("/").pop() ?? "Preview"}
-
+
{isPreviewLoading && !preview ? (
@@ -1030,7 +1030,7 @@ export function FileExplorerPane({
hitSlop={8}
style={styles.downloadToastDismiss}
>
-
+
)}
@@ -1291,7 +1291,7 @@ function splitFileName(fileName: string): { base: string; ext: string } {
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
- backgroundColor: theme.colors.background,
+ backgroundColor: theme.colors.surface0,
},
content: {
flex: 1,
@@ -1325,7 +1325,7 @@ const styles = StyleSheet.create((theme) => ({
pathText: {
flex: 1,
fontSize: theme.fontSize.sm,
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
fontFamily: Fonts.mono,
},
backButton: {
@@ -1343,7 +1343,7 @@ const styles = StyleSheet.create((theme) => ({
paddingHorizontal: theme.spacing[3],
},
loadingBannerText: {
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
sortButton: {
@@ -1355,10 +1355,10 @@ const styles = StyleSheet.create((theme) => ({
borderRadius: theme.borderRadius.md,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
- backgroundColor: theme.colors.card,
+ backgroundColor: theme.colors.surface2,
},
sortButtonText: {
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
centerState: {
@@ -1369,7 +1369,7 @@ const styles = StyleSheet.create((theme) => ({
padding: theme.spacing[4],
},
loadingText: {
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
errorText: {
@@ -1401,12 +1401,12 @@ const styles = StyleSheet.create((theme) => ({
paddingVertical: theme.spacing[1],
},
goToWorkspaceButtonText: {
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.semibold,
},
emptyText: {
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
textAlign: "center",
},
@@ -1422,7 +1422,7 @@ const styles = StyleSheet.create((theme) => ({
marginBottom: theme.spacing[1],
},
entryRowBackground: {
- backgroundColor: theme.colors.card,
+ backgroundColor: theme.colors.surface2,
},
entryInfo: {
flex: 1,
@@ -1443,7 +1443,7 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.base,
},
entryMeta: {
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
marginTop: theme.spacing[1],
},
@@ -1465,7 +1465,7 @@ const styles = StyleSheet.create((theme) => ({
borderRadius: theme.borderRadius.md,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
- backgroundColor: theme.colors.card,
+ backgroundColor: theme.colors.surface2,
padding: theme.spacing[1],
},
entryMenuItem: {
@@ -1499,7 +1499,7 @@ const styles = StyleSheet.create((theme) => ({
borderColor: theme.colors.border,
padding: theme.spacing[2],
gap: theme.spacing[2],
- backgroundColor: theme.colors.card,
+ backgroundColor: theme.colors.surface2,
marginHorizontal: theme.spacing[1],
marginBottom: theme.spacing[1],
minWidth: 0,
@@ -1511,10 +1511,10 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center",
justifyContent: "center",
overflow: "hidden",
- backgroundColor: theme.colors.muted,
+ backgroundColor: theme.colors.surface2,
},
gridImageBackground: {
- backgroundColor: theme.colors.background,
+ backgroundColor: theme.colors.surface0,
},
gridImage: {
width: "100%",
@@ -1525,11 +1525,11 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.sm,
},
gridMeta: {
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
sheetBackground: {
- backgroundColor: theme.colors.card,
+ backgroundColor: theme.colors.surface2,
},
handleIndicator: {
backgroundColor: theme.colors.palette.zinc[600],
@@ -1586,7 +1586,7 @@ const styles = StyleSheet.create((theme) => ({
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
- backgroundColor: theme.colors.card,
+ backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
@@ -1608,12 +1608,12 @@ const styles = StyleSheet.create((theme) => ({
fontWeight: theme.fontWeight.semibold,
},
downloadToastStatus: {
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
downloadProgressBar: {
height: 3,
- backgroundColor: theme.colors.muted,
+ backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.full,
marginTop: theme.spacing[1],
overflow: "hidden",
diff --git a/packages/app/src/components/git-diff-pane.tsx b/packages/app/src/components/git-diff-pane.tsx
index 43c09e106..3f3c69f9b 100644
--- a/packages/app/src/components/git-diff-pane.tsx
+++ b/packages/app/src/components/git-diff-pane.tsx
@@ -37,41 +37,63 @@ interface HighlightedTextProps {
lineType: "add" | "remove" | "context" | "header";
}
+// GitHub syntax highlight colors for dark/light modes
+const darkHighlightColors: Record = {
+ keyword: "#ff7b72",
+ comment: "#8b949e",
+ string: "#a5d6ff",
+ number: "#79c0ff",
+ literal: "#79c0ff",
+ function: "#d2a8ff",
+ definition: "#d2a8ff",
+ class: "#ffa657",
+ type: "#ff7b72",
+ tag: "#7ee787",
+ attribute: "#79c0ff",
+ property: "#79c0ff",
+ variable: "#c9d1d9",
+ operator: "#79c0ff",
+ punctuation: "#c9d1d9",
+ regexp: "#a5d6ff",
+ escape: "#79c0ff",
+ meta: "#8b949e",
+ heading: "#79c0ff",
+ link: "#a5d6ff",
+};
+
+const lightHighlightColors: Record = {
+ keyword: "#cf222e",
+ comment: "#6e7781",
+ string: "#0a3069",
+ number: "#0550ae",
+ literal: "#0550ae",
+ function: "#8250df",
+ definition: "#8250df",
+ class: "#953800",
+ type: "#cf222e",
+ tag: "#116329",
+ attribute: "#0550ae",
+ property: "#0550ae",
+ variable: "#24292f",
+ operator: "#0550ae",
+ punctuation: "#24292f",
+ regexp: "#0a3069",
+ escape: "#0550ae",
+ meta: "#6e7781",
+ heading: "#0550ae",
+ link: "#0a3069",
+};
+
function HighlightedText({ tokens, lineType }: HighlightedTextProps) {
const { theme } = useUnistyles();
+ const isDark = theme.colors.surface0 === "#18181c";
- // Get color for a highlight style using GitHub Dark theme
- // Text colors are the same regardless of line type - only background changes
+ // Get color for a highlight style
const getTokenColor = (style: HighlightStyle | null): string => {
- const baseColor = "#c9d1d9"; // GitHub foreground
-
+ const baseColor = isDark ? "#c9d1d9" : "#24292f";
if (!style) return baseColor;
-
- // GitHub Dark theme colors
- const highlightColors: Record = {
- keyword: "#ff7b72", // red
- comment: "#8b949e", // gray
- string: "#a5d6ff", // light blue
- number: "#79c0ff", // blue
- literal: "#79c0ff", // blue
- function: "#d2a8ff", // purple
- definition: "#d2a8ff", // purple
- class: "#ffa657", // orange
- type: "#ff7b72", // red (same as keyword in GitHub)
- tag: "#7ee787", // green
- attribute: "#79c0ff", // blue
- property: "#79c0ff", // blue
- variable: baseColor,
- operator: "#79c0ff", // blue
- punctuation: "#c9d1d9", // foreground
- regexp: "#a5d6ff", // light blue
- escape: "#79c0ff", // blue
- meta: "#8b949e", // gray
- heading: "#79c0ff", // blue
- link: "#a5d6ff", // light blue
- };
-
- return highlightColors[style] ?? baseColor;
+ const colors = isDark ? darkHighlightColors : lightHighlightColors;
+ return colors[style] ?? baseColor;
};
return (
@@ -253,7 +275,7 @@ const DiffFileSection = memo(function DiffFileSection({
>
@@ -470,7 +492,7 @@ const styles = StyleSheet.create((theme) => ({
},
loadingText: {
fontSize: theme.fontSize.base,
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
},
errorContainer: {
flex: 1,
@@ -492,14 +514,14 @@ const styles = StyleSheet.create((theme) => ({
},
emptyText: {
fontSize: theme.fontSize.lg,
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
},
fileSection: {
borderRadius: theme.borderRadius.lg,
overflow: "hidden",
- backgroundColor: theme.colors.muted,
+ backgroundColor: theme.colors.surface2,
borderWidth: theme.borderWidth[1],
- borderColor: theme.colors.accentBorder,
+ borderColor: theme.colors.borderAccent,
marginBottom: theme.spacing[2],
},
fileHeader: {
@@ -566,13 +588,13 @@ const styles = StyleSheet.create((theme) => ({
diffContent: {
borderTopWidth: theme.borderWidth[1],
borderTopColor: theme.colors.border,
- backgroundColor: "#0d1117",
+ backgroundColor: theme.colors.surface1,
},
diffContentInner: {
flexDirection: "column",
},
linesContainer: {
- backgroundColor: "#0d1117",
+ backgroundColor: theme.colors.surface1,
},
diffLineContainer: {
paddingHorizontal: theme.spacing[3],
@@ -587,24 +609,24 @@ const styles = StyleSheet.create((theme) => ({
backgroundColor: "rgba(46, 160, 67, 0.15)", // GitHub green
},
addLineText: {
- color: "#c9d1d9", // Same text color as all code
+ color: theme.colors.foreground,
},
removeLineContainer: {
backgroundColor: "rgba(248, 81, 73, 0.1)", // GitHub red
},
removeLineText: {
- color: "#c9d1d9", // Same text color as all code
+ color: theme.colors.foreground,
},
headerLineContainer: {
- backgroundColor: theme.colors.muted,
+ backgroundColor: theme.colors.surface2,
},
headerLineText: {
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
},
contextLineContainer: {
- backgroundColor: "#0d1117",
+ backgroundColor: theme.colors.surface1,
},
contextLineText: {
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
},
}));
diff --git a/packages/app/src/components/headers/back-header.tsx b/packages/app/src/components/headers/back-header.tsx
index a70c723da..b69d9481c 100644
--- a/packages/app/src/components/headers/back-header.tsx
+++ b/packages/app/src/components/headers/back-header.tsx
@@ -22,7 +22,7 @@ export function BackHeader({ title, rightContent, onBack }: BackHeaderProps) {
onPress={onBack ?? (() => router.back())}
style={styles.backButton}
>
-
+
{title && (
diff --git a/packages/app/src/components/headers/menu-header.tsx b/packages/app/src/components/headers/menu-header.tsx
index 1b48671b1..fa7330878 100644
--- a/packages/app/src/components/headers/menu-header.tsx
+++ b/packages/app/src/components/headers/menu-header.tsx
@@ -19,7 +19,7 @@ export function MenuHeader({ title, rightContent }: MenuHeaderProps) {
const MenuIcon = isMobile ? Menu : PanelLeft;
const menuIconColor = !isMobile && isOpen
? theme.colors.foreground
- : theme.colors.mutedForeground;
+ : theme.colors.foregroundMuted;
return (
({
header: {
- backgroundColor: theme.colors.background,
+ backgroundColor: theme.colors.surface0,
},
inner: {},
row: {
diff --git a/packages/app/src/components/home-footer.tsx b/packages/app/src/components/home-footer.tsx
index a32a680b3..141e15edb 100644
--- a/packages/app/src/components/home-footer.tsx
+++ b/packages/app/src/components/home-footer.tsx
@@ -212,7 +212,7 @@ export function HomeFooter() {
const styles = StyleSheet.create((theme) => ({
container: {
- backgroundColor: theme.colors.background,
+ backgroundColor: theme.colors.surface0,
borderTopWidth: theme.borderWidth[1],
borderTopColor: theme.colors.border,
},
@@ -264,7 +264,7 @@ const styles = StyleSheet.create((theme) => ({
flex: 1,
},
hostPickerContainer: {
- backgroundColor: theme.colors.card,
+ backgroundColor: theme.colors.surface2,
padding: theme.spacing[4],
borderTopLeftRadius: theme.borderRadius.xl,
borderTopRightRadius: theme.borderRadius.xl,
@@ -279,7 +279,7 @@ const styles = StyleSheet.create((theme) => ({
hostPickerButton: {
paddingVertical: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
- backgroundColor: theme.colors.muted,
+ backgroundColor: theme.colors.surface2,
},
hostPickerButtonText: {
color: theme.colors.foreground,
@@ -290,7 +290,7 @@ const styles = StyleSheet.create((theme) => ({
paddingVertical: theme.spacing[3],
},
hostPickerCancelText: {
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
textAlign: "center",
},
diff --git a/packages/app/src/components/message-input.tsx b/packages/app/src/components/message-input.tsx
index 196d0ca33..15387a1d2 100644
--- a/packages/app/src/components/message-input.tsx
+++ b/packages/app/src/components/message-input.tsx
@@ -616,9 +616,9 @@ const styles = StyleSheet.create(((theme: any) => ({
inputWrapper: {
flexDirection: "column",
gap: theme.spacing[3],
- backgroundColor: theme.colors.muted,
+ backgroundColor: theme.colors.surface2,
borderWidth: theme.borderWidth[1],
- borderColor: theme.colors.accentBorder,
+ borderColor: theme.colors.borderAccent,
borderRadius: theme.borderRadius["2xl"],
paddingVertical: {
xs: theme.spacing[2],
@@ -645,7 +645,7 @@ const styles = StyleSheet.create(((theme: any) => ({
position: "relative",
borderRadius: theme.borderRadius.md,
borderWidth: 1,
- borderColor: theme.colors.accentBorder,
+ borderColor: theme.colors.borderAccent,
overflow: "hidden",
...(IS_WEB
? {
@@ -726,7 +726,7 @@ const styles = StyleSheet.create(((theme: any) => ({
width: 34,
height: 34,
borderRadius: theme.borderRadius.full,
- backgroundColor: theme.colors.palette.blue[600],
+ backgroundColor: theme.colors.accent,
alignItems: "center",
justifyContent: "center",
},
diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx
index 7658f0055..635439e12 100644
--- a/packages/app/src/components/message.tsx
+++ b/packages/app/src/components/message.tsx
@@ -99,7 +99,7 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({
marginBottom: theme.spacing[4],
},
bubble: {
- backgroundColor: theme.colors.muted,
+ backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius["2xl"],
borderTopRightRadius: theme.borderRadius.sm,
paddingHorizontal: theme.spacing[4],
@@ -120,13 +120,13 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({
marginTop: theme.spacing[1],
marginRight: theme.spacing[4],
alignSelf: "flex-end",
- backgroundColor: theme.colors.secondary,
+ backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.base,
paddingHorizontal: theme.spacing[2],
paddingVertical: 4,
},
copiedTagText: {
- color: theme.colors.secondaryForeground,
+ color: theme.colors.foreground,
fontSize: theme.fontSize.xs,
},
}));
@@ -231,8 +231,8 @@ export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
},
// Used in custom markdownRules for inline code styling
markdownCodeInline: {
- backgroundColor: theme.colors.secondary,
- color: theme.colors.secondaryForeground,
+ backgroundColor: theme.colors.surface2,
+ color: theme.colors.foreground,
paddingHorizontal: theme.spacing[2],
paddingVertical: 2,
borderRadius: theme.borderRadius.sm,
@@ -241,7 +241,7 @@ export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
},
// Used in custom markdownRules for path chip styling
pathChip: {
- backgroundColor: theme.colors.secondary,
+ backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.full,
paddingHorizontal: theme.spacing[2],
paddingVertical: 2,
@@ -249,7 +249,7 @@ export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
marginVertical: 2,
},
pathChipText: {
- color: theme.colors.secondaryForeground,
+ color: theme.colors.foreground,
fontFamily: Fonts.mono,
fontSize: 13,
},
@@ -262,7 +262,7 @@ const turnCopyButtonStylesheet = StyleSheet.create((theme) => ({
marginLeft: theme.spacing[4],
},
iconColor: {
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
},
iconHoveredColor: {
color: theme.colors.foreground,
@@ -340,8 +340,8 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
pressable: {
borderRadius: theme.borderRadius.lg,
borderWidth: theme.borderWidth[1],
- borderColor: theme.colors.accentBorder,
- backgroundColor: theme.colors.secondary,
+ borderColor: theme.colors.borderAccent,
+ backgroundColor: theme.colors.surface2,
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[1],
},
@@ -369,7 +369,7 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
},
secondaryLabel: {
flex: 1,
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.normal,
marginLeft: theme.spacing[2],
@@ -386,7 +386,7 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
borderRadius: theme.borderRadius.md,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
- backgroundColor: theme.colors.background,
+ backgroundColor: theme.colors.surface0,
padding: theme.spacing[2],
gap: theme.spacing[2],
flexShrink: 1,
@@ -721,7 +721,7 @@ const activityLogStylesheet = StyleSheet.create((theme) => ({
marginTop: theme.spacing[1],
},
detailsText: {
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
marginRight: theme.spacing[1],
},
@@ -875,7 +875,7 @@ const todoListCardStylesheet = StyleSheet.create((theme) => ({
marginBottom: theme.spacing[2],
},
card: {
- backgroundColor: theme.colors.card,
+ backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
@@ -899,7 +899,7 @@ const todoListCardStylesheet = StyleSheet.create((theme) => ({
letterSpacing: 0.6,
},
timestamp: {
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
providerBadge: {
@@ -914,7 +914,7 @@ const todoListCardStylesheet = StyleSheet.create((theme) => ({
fontWeight: theme.fontWeight.semibold,
},
progressText: {
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
marginBottom: theme.spacing[2],
},
@@ -946,11 +946,11 @@ const todoListCardStylesheet = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.sm,
},
itemTextCompleted: {
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
textDecorationLine: "line-through",
},
emptyText: {
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
fontStyle: "italic",
},
@@ -979,7 +979,7 @@ export const TodoListCard = memo(function TodoListCard({
[timestamp]
);
- const iconColor = theme.colors.background;
+ const iconColor = theme.colors.surface0;
return (
({
emptyText: {
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
fontStyle: "italic" as const,
},
diff --git a/packages/app/src/components/mode-selector-modal.tsx b/packages/app/src/components/mode-selector-modal.tsx
index 67f910412..f3d28ca8d 100644
--- a/packages/app/src/components/mode-selector-modal.tsx
+++ b/packages/app/src/components/mode-selector-modal.tsx
@@ -75,7 +75,7 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center",
},
modeSelectorContent: {
- backgroundColor: theme.colors.card,
+ backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg,
padding: theme.spacing[4],
minWidth: 280,
@@ -85,7 +85,7 @@ const styles = StyleSheet.create((theme) => ({
padding: theme.spacing[4],
borderRadius: theme.borderRadius.md,
marginBottom: theme.spacing[2],
- backgroundColor: theme.colors.muted,
+ backgroundColor: theme.colors.surface2,
},
modeItemActive: {
backgroundColor: theme.colors.primary,
@@ -100,7 +100,7 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.primaryForeground,
},
modeDescription: {
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
modeDescriptionActive: {
diff --git a/packages/app/src/components/realtime-controls.tsx b/packages/app/src/components/realtime-controls.tsx
index 234351255..6009f6c5b 100644
--- a/packages/app/src/components/realtime-controls.tsx
+++ b/packages/app/src/components/realtime-controls.tsx
@@ -48,7 +48,7 @@ export function RealtimeControls() {
size={20}
color={
isMuted
- ? theme.colors.background
+ ? theme.colors.surface0
: theme.colors.foreground
}
/>
@@ -91,7 +91,7 @@ const styles = StyleSheet.create((theme) => ({
borderRadius: theme.borderRadius.full,
alignItems: "center",
justifyContent: "center",
- backgroundColor: theme.colors.muted,
+ backgroundColor: theme.colors.surface2,
borderWidth: theme.borderWidth[2],
borderColor: theme.colors.border,
},
diff --git a/packages/app/src/components/sliding-sidebar.tsx b/packages/app/src/components/sliding-sidebar.tsx
index b602f470b..2d4d60bc9 100644
--- a/packages/app/src/components/sliding-sidebar.tsx
+++ b/packages/app/src/components/sliding-sidebar.tsx
@@ -226,7 +226,7 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
style={styles.settingsButton}
onPress={handleSettingsMobile}
>
-
+
Settings
@@ -273,7 +273,7 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
style={styles.settingsButton}
onPress={handleSettingsDesktop}
>
-
+
Settings
@@ -294,7 +294,7 @@ const styles = StyleSheet.create((theme) => ({
top: 0,
left: 0,
bottom: 0,
- backgroundColor: theme.colors.background,
+ backgroundColor: theme.colors.surface0,
overflow: "hidden",
},
sidebarContent: {
@@ -305,7 +305,7 @@ const styles = StyleSheet.create((theme) => ({
desktopSidebar: {
borderRightWidth: 1,
borderRightColor: theme.colors.border,
- backgroundColor: theme.colors.background,
+ backgroundColor: theme.colors.surface0,
},
sidebarHeader: {
paddingHorizontal: theme.spacing[4],
@@ -326,7 +326,7 @@ const styles = StyleSheet.create((theme) => ({
},
newAgentButtonHovered: {
borderColor: theme.colors.palette.zinc[500],
- backgroundColor: theme.colors.muted,
+ backgroundColor: theme.colors.surface2,
},
newAgentButtonText: {
fontSize: theme.fontSize.base,
@@ -361,6 +361,6 @@ const styles = StyleSheet.create((theme) => ({
settingsButtonText: {
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.normal,
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
},
}));
diff --git a/packages/app/src/components/tool-call-details.tsx b/packages/app/src/components/tool-call-details.tsx
index 3302ddf0f..0686a5557 100644
--- a/packages/app/src/components/tool-call-details.tsx
+++ b/packages/app/src/components/tool-call-details.tsx
@@ -357,7 +357,7 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[2],
},
sectionTitle: {
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.semibold,
textTransform: "uppercase",
@@ -370,7 +370,7 @@ const styles = StyleSheet.create((theme) => ({
borderRadius: theme.borderRadius.base,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
- backgroundColor: theme.colors.card,
+ backgroundColor: theme.colors.surface2,
},
fileBadgeText: {
color: theme.colors.foreground,
@@ -378,7 +378,7 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.xs,
},
rangeText: {
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
diffContainer: {
@@ -386,13 +386,13 @@ const styles = StyleSheet.create((theme) => ({
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.base,
overflow: "hidden",
- backgroundColor: theme.colors.card,
+ backgroundColor: theme.colors.surface2,
},
scrollArea: {
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.base,
- backgroundColor: theme.colors.card,
+ backgroundColor: theme.colors.surface2,
},
scrollContent: {
padding: theme.spacing[2],
@@ -407,7 +407,7 @@ const styles = StyleSheet.create((theme) => ({
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.base,
- backgroundColor: theme.colors.card,
+ backgroundColor: theme.colors.surface2,
},
jsonScrollError: {
borderColor: theme.colors.destructive,
@@ -419,7 +419,7 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.destructive,
},
emptyStateText: {
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
fontStyle: "italic",
},
diff --git a/packages/app/src/components/tool-call-sheet.tsx b/packages/app/src/components/tool-call-sheet.tsx
index e67996612..e0abf535b 100644
--- a/packages/app/src/components/tool-call-sheet.tsx
+++ b/packages/app/src/components/tool-call-sheet.tsx
@@ -7,11 +7,13 @@ import React, {
ReactNode,
} from "react";
import { View, Text, Pressable } from "react-native";
-import { StyleSheet } from "react-native-unistyles";
+import { StyleSheet, useUnistyles } from "react-native-unistyles";
+import Animated from "react-native-reanimated";
import {
BottomSheetModal,
BottomSheetScrollView,
BottomSheetBackdrop,
+ BottomSheetBackgroundProps,
} from "@gorhom/bottom-sheet";
import { Pencil, Eye, SquareTerminal, Search, Wrench, X } from "lucide-react-native";
import { ToolCallDetailsContent, useToolCallDetails } from "./tool-call-details";
@@ -53,6 +55,17 @@ const toolKindIcons: Record [style, { backgroundColor: theme.colors.surface2, borderRadius: 16 }],
+ [style, theme.colors.surface2]
+ );
+ return ;
+}
+
// ----- Provider Component -----
interface ToolCallSheetProviderProps {
@@ -163,14 +176,14 @@ function ToolCallSheetContent({ data, onClose }: ToolCallSheetContentProps) {
const styles = StyleSheet.create((theme) => ({
sheetBackground: {
- backgroundColor: theme.colors.card,
+ backgroundColor: theme.colors.surface2,
},
handleIndicator: {
backgroundColor: theme.colors.palette.zinc[600],
},
container: {
flex: 1,
- backgroundColor: theme.colors.card,
+ backgroundColor: theme.colors.surface2,
},
header: {
flexDirection: "row",
@@ -200,11 +213,11 @@ const styles = StyleSheet.create((theme) => ({
padding: theme.spacing[2],
},
closeIcon: {
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
},
content: {
flex: 1,
- backgroundColor: theme.colors.card,
+ backgroundColor: theme.colors.surface2,
},
contentContainer: {
paddingHorizontal: theme.spacing[4],
diff --git a/packages/app/src/components/voice-button.tsx b/packages/app/src/components/voice-button.tsx
index d9388fd0c..3fe678e4b 100644
--- a/packages/app/src/components/voice-button.tsx
+++ b/packages/app/src/components/voice-button.tsx
@@ -32,7 +32,7 @@ const styles = StyleSheet.create((theme) => ({
elevation: 8,
},
buttonIdle: {
- backgroundColor: theme.colors.muted,
+ backgroundColor: theme.colors.surface2,
},
buttonRecording: {
backgroundColor: theme.colors.destructive,
@@ -44,7 +44,7 @@ const styles = StyleSheet.create((theme) => ({
backgroundColor: theme.colors.palette.green[500],
},
label: {
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
// Recording icon
diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx
index 05666b9f1..7912c5f40 100644
--- a/packages/app/src/contexts/session-context.tsx
+++ b/packages/app/src/contexts/session-context.tsx
@@ -35,13 +35,14 @@ import {
type SessionState,
type DaemonConnectionSnapshot,
} from "@/stores/session-store";
+import { useDraftStore } from "@/stores/draft-store";
import type { AgentDirectoryEntry } from "@/types/agent-directory";
import { sendOsNotification } from "@/utils/os-notifications";
-// Re-export types from session-store for backward compatibility
+// Re-export types from session-store and draft-store for backward compatibility
+export type { DraftInput } from "@/stores/draft-store";
export type {
MessageEntry,
- DraftInput,
ProviderModelState,
Agent,
ExplorerEntry,
@@ -428,8 +429,7 @@ export function SessionProvider({
const setGitDiffs = useSessionStore((state) => state.setGitDiffs);
const setFileExplorer = useSessionStore((state) => state.setFileExplorer);
const setProviderModels = useSessionStore((state) => state.setProviderModels);
- const getDraftInput = useSessionStore((state) => state.getDraftInput);
- const saveDraftInput = useSessionStore((state) => state.saveDraftInput);
+ const clearDraftInput = useDraftStore((state) => state.clearDraftInput);
const setQueuedMessages = useSessionStore((state) => state.setQueuedMessages);
const getSession = useSessionStore((state) => state.getSession);
const updateSessionClient = useSessionStore((state) => state.updateSessionClient);
@@ -1515,7 +1515,7 @@ export function SessionProvider({
clearAgentStreamHead(serverId, agentId);
// Remove draft input
- saveDraftInput(agentId, { text: "", images: [] });
+ clearDraftInput(agentId);
setPendingPermissions(serverId, (prev) => {
let changed = false;
@@ -1592,7 +1592,7 @@ export function SessionProvider({
setHasHydratedAgents,
updateConnectionStatus,
getSession,
- saveDraftInput,
+ clearDraftInput,
notifyAgentAttention,
]);
diff --git a/packages/app/src/stores/draft-store.ts b/packages/app/src/stores/draft-store.ts
new file mode 100644
index 000000000..ac61046da
--- /dev/null
+++ b/packages/app/src/stores/draft-store.ts
@@ -0,0 +1,64 @@
+import { create } from "zustand";
+import { persist, createJSONStorage } from "zustand/middleware";
+import AsyncStorage from "@react-native-async-storage/async-storage";
+
+export interface DraftInput {
+ text: string;
+ images: Array<{ uri: string; mimeType: string }>;
+}
+
+interface DraftStoreState {
+ drafts: Record;
+ createModalDraft: DraftInput | null;
+}
+
+interface DraftStoreActions {
+ getDraftInput: (agentId: string) => DraftInput | undefined;
+ saveDraftInput: (agentId: string, draft: DraftInput) => void;
+ clearDraftInput: (agentId: string) => void;
+ getCreateModalDraft: () => DraftInput | null;
+ saveCreateModalDraft: (draft: DraftInput | null) => void;
+}
+
+type DraftStore = DraftStoreState & DraftStoreActions;
+
+export const useDraftStore = create()(
+ persist(
+ (set, get) => ({
+ drafts: {},
+ createModalDraft: null,
+
+ getDraftInput: (agentId) => {
+ return get().drafts[agentId];
+ },
+
+ saveDraftInput: (agentId, draft) => {
+ set((state) => ({
+ drafts: {
+ ...state.drafts,
+ [agentId]: draft,
+ },
+ }));
+ },
+
+ clearDraftInput: (agentId) => {
+ set((state) => {
+ const { [agentId]: _, ...rest } = state.drafts;
+ return { drafts: rest };
+ });
+ },
+
+ getCreateModalDraft: () => {
+ return get().createModalDraft;
+ },
+
+ saveCreateModalDraft: (draft) => {
+ set({ createModalDraft: draft });
+ },
+ }),
+ {
+ name: "paseo-drafts",
+ storage: createJSONStorage(() => AsyncStorage),
+ }
+ )
+);
diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts
index 0c417e1ba..d2966e4ef 100644
--- a/packages/app/src/stores/session-store.ts
+++ b/packages/app/src/stores/session-store.ts
@@ -60,11 +60,6 @@ export type MessageEntry =
status: "executing" | "completed" | "failed";
};
-export type DraftInput = {
- text: string;
- images: Array<{ uri: string; mimeType: string }>;
-};
-
export type ProviderModelState = {
models: AgentModelDefinition[] | null;
fetchedAt: Date | null;
@@ -250,12 +245,6 @@ export interface SessionState {
interface SessionStoreState {
sessions: Record;
- // Top-level drafts (keyed by agentId, not serverId:agentId since agent IDs are globally unique)
- drafts: Map;
-
- // Single draft for the create agent modal
- createModalDraft: DraftInput | null;
-
// Agent activity timestamps (top-level, keyed by agentId to prevent cascade rerenders)
agentLastActivity: Map;
}
@@ -305,14 +294,6 @@ interface SessionStoreActions {
// Provider models
setProviderModels: (serverId: string, models: Map | ((prev: Map) => Map)) => void;
- // Draft inputs (top-level, not in sessions)
- getDraftInput: (agentId: string) => DraftInput | undefined;
- saveDraftInput: (agentId: string, draft: DraftInput) => void;
-
- // Create modal draft
- getCreateModalDraft: () => DraftInput | null;
- saveCreateModalDraft: (draft: DraftInput | null) => void;
-
// Queued messages
setQueuedMessages: (serverId: string, value: Map }>> | ((prev: Map }>>) => Map }>>)) => void;
@@ -392,8 +373,6 @@ function createInitialSessionState(serverId: string, client: DaemonClientV2, aud
export const useSessionStore = create()(
subscribeWithSelector((set, get) => ({
sessions: {},
- drafts: new Map(),
- createModalDraft: null,
agentLastActivity: new Map(),
// Session management
@@ -779,34 +758,6 @@ export const useSessionStore = create()(
});
},
- // Draft inputs (top-level, keyed by agentId only)
- getDraftInput: (agentId) => {
- return get().drafts.get(agentId);
- },
-
- saveDraftInput: (agentId, draft) => {
- set((prev) => {
- const nextDrafts = new Map(prev.drafts);
- nextDrafts.set(agentId, draft);
- return {
- ...prev,
- drafts: nextDrafts,
- };
- });
- },
-
- // Create modal draft
- getCreateModalDraft: () => {
- return get().createModalDraft;
- },
-
- saveCreateModalDraft: (draft) => {
- set((prev) => ({
- ...prev,
- createModalDraft: draft,
- }));
- },
-
// Queued messages
setQueuedMessages: (serverId, value) => {
set((prev) => {
diff --git a/packages/app/src/styles/markdown-styles.ts b/packages/app/src/styles/markdown-styles.ts
index 6f0f117a1..1aa304ed9 100644
--- a/packages/app/src/styles/markdown-styles.ts
+++ b/packages/app/src/styles/markdown-styles.ts
@@ -91,7 +91,7 @@ export function createMarkdownStyles(theme: Theme) {
heading6: {
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.semibold,
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
marginTop: theme.spacing[3],
marginBottom: theme.spacing[1],
lineHeight: 20,
@@ -104,7 +104,7 @@ export function createMarkdownStyles(theme: Theme) {
// =========================================================================
strong: {
- fontWeight: theme.fontWeight.bold,
+ fontWeight: theme.fontWeight.medium,
},
em: {
@@ -113,7 +113,7 @@ export function createMarkdownStyles(theme: Theme) {
s: {
textDecorationLine: "line-through" as const,
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
},
link: {
@@ -131,8 +131,8 @@ export function createMarkdownStyles(theme: Theme) {
// =========================================================================
code_inline: {
- backgroundColor: theme.colors.secondary,
- color: theme.colors.secondaryForeground,
+ backgroundColor: theme.colors.surface2,
+ color: theme.colors.foreground,
paddingHorizontal: theme.spacing[1],
paddingVertical: 2,
borderRadius: theme.borderRadius.sm,
@@ -141,8 +141,8 @@ export function createMarkdownStyles(theme: Theme) {
},
code_block: {
- backgroundColor: theme.colors.secondary,
- color: theme.colors.secondaryForeground,
+ backgroundColor: theme.colors.surface2,
+ color: theme.colors.foreground,
padding: theme.spacing[3],
borderRadius: theme.borderRadius.md,
fontFamily: Fonts.mono,
@@ -151,8 +151,8 @@ export function createMarkdownStyles(theme: Theme) {
},
fence: {
- backgroundColor: theme.colors.secondary,
- color: theme.colors.secondaryForeground,
+ backgroundColor: theme.colors.surface2,
+ color: theme.colors.foreground,
padding: theme.spacing[3],
borderRadius: theme.borderRadius.md,
borderWidth: 1,
@@ -178,7 +178,7 @@ export function createMarkdownStyles(theme: Theme) {
},
thead: {
- backgroundColor: theme.colors.secondary,
+ backgroundColor: theme.colors.surface2,
},
tbody: {},
@@ -188,7 +188,7 @@ export function createMarkdownStyles(theme: Theme) {
borderBottomWidth: 1,
borderRightWidth: 1,
borderColor: theme.colors.border,
- backgroundColor: theme.colors.secondary,
+ backgroundColor: theme.colors.surface2,
fontWeight: theme.fontWeight.semibold,
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
@@ -244,14 +244,14 @@ export function createMarkdownStyles(theme: Theme) {
},
bullet_list_icon: {
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
marginRight: 4,
fontSize: theme.fontSize.base,
lineHeight: 22,
},
ordered_list_icon: {
- color: theme.colors.mutedForeground,
+ color: theme.colors.foregroundMuted,
marginRight: 4,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.semibold,
@@ -264,7 +264,7 @@ export function createMarkdownStyles(theme: Theme) {
// =========================================================================
blockquote: {
- backgroundColor: theme.colors.muted,
+ backgroundColor: theme.colors.surface2,
borderLeftWidth: 4,
borderLeftColor: theme.colors.primary,
paddingHorizontal: theme.spacing[4],
diff --git a/packages/app/src/styles/theme.ts b/packages/app/src/styles/theme.ts
index 3ced85ae6..fd4511b5a 100644
--- a/packages/app/src/styles/theme.ts
+++ b/packages/app/src/styles/theme.ts
@@ -103,10 +103,34 @@ export const baseColors = {
},
} as const;
-// Semantic color tokens (shadcn-style)
+// Semantic color tokens - Layer-based system
const lightSemanticColors = {
- background: "#ffffff",
+ // Surfaces (layers) - shifted one step lighter
+ surface0: "#ffffff", // App background
+ surface1: "#fafafa", // Subtle hover (was zinc-100, now zinc-50)
+ surface2: "#f4f4f5", // Elevated: badges, inputs, sheets (was zinc-200, now zinc-100)
+ surface3: "#e4e4e7", // Highest elevation (was zinc-300, now zinc-200)
+
+ // Text
foreground: "#09090b",
+ foregroundMuted: "#71717a",
+
+ // Borders - shifted one step lighter
+ border: "#e4e4e7", // (was zinc-200, now zinc-200 - keep for contrast)
+ borderAccent: "#e4e4e7", // (was zinc-300, now zinc-200)
+
+ // Brand
+ accent: "#20744A",
+ accentForeground: "#ffffff",
+
+ // Semantic
+ destructive: "#dc2626",
+ destructiveForeground: "#ffffff",
+ success: "#20744A",
+ successForeground: "#ffffff",
+
+ // Legacy aliases (for gradual migration)
+ background: "#ffffff",
card: "#ffffff",
cardForeground: "#09090b",
popover: "#ffffff",
@@ -114,25 +138,44 @@ const lightSemanticColors = {
primary: "#18181b",
primaryForeground: "#fafafa",
secondary: "#f4f4f5",
- secondaryForeground: "#18181b",
+ secondaryForeground: "#09090b",
muted: "#f4f4f5",
mutedForeground: "#71717a",
- accent: "#f4f4f5",
- accentForeground: "#18181b",
- destructive: "#ef4444",
- destructiveForeground: "#fafafa",
- border: "#e4e4e7",
- accentBorder: "#3f3f46",
- input: "#e4e4e7",
+ accentBorder: "#e4e4e7",
+ input: "#f4f4f5",
ring: "#18181b",
} as const;
const darkSemanticColors = {
- background: "#09090b",
+ // Surfaces (layers)
+ surface0: "#18181c", // App background
+ surface1: "#1f1f23", // Subtle hover
+ surface2: "#27272a", // Elevated: badges, inputs, sheets
+ surface3: "#3f3f46", // Highest elevation
+
+ // Text
foreground: "#fafafa",
- card: "#09090b",
+ foregroundMuted: "#a1a1aa",
+
+ // Borders
+ border: "#27272a",
+ borderAccent: "#3f3f46",
+
+ // Brand
+ accent: "#20744A",
+ accentForeground: "#ffffff",
+
+ // Semantic
+ destructive: "#ef4444",
+ destructiveForeground: "#ffffff",
+ success: "#20744A",
+ successForeground: "#ffffff",
+
+ // Legacy aliases (for gradual migration)
+ background: "#18181c",
+ card: "#27272a",
cardForeground: "#fafafa",
- popover: "#18181b",
+ popover: "#27272a",
popoverForeground: "#fafafa",
primary: "#fafafa",
primaryForeground: "#18181b",
@@ -140,12 +183,7 @@ const darkSemanticColors = {
secondaryForeground: "#fafafa",
muted: "#27272a",
mutedForeground: "#a1a1aa",
- accent: "#27272a",
- accentForeground: "#fafafa",
- destructive: "#7f1d1d",
- destructiveForeground: "#fafafa",
- border: "#27272e",
- accentBorder: "#414146",
+ accentBorder: "#3f3f46",
input: "#27272a",
ring: "#d4d4d8",
} as const;
diff --git a/packages/relay/package.json b/packages/relay/package.json
new file mode 100644
index 000000000..31205c40a
--- /dev/null
+++ b/packages/relay/package.json
@@ -0,0 +1,26 @@
+{
+ "name": "@paseo/relay",
+ "version": "0.1.0",
+ "description": "Paseo relay for bridging daemon and client connections",
+ "type": "module",
+ "exports": {
+ ".": "./src/index.ts",
+ "./node": "./src/node-adapter.ts",
+ "./cloudflare": "./src/cloudflare-adapter.ts"
+ },
+ "scripts": {
+ "typecheck": "tsc --noEmit",
+ "test": "vitest run",
+ "test:watch": "vitest"
+ },
+ "dependencies": {
+ "ws": "^8.14.2"
+ },
+ "devDependencies": {
+ "@cloudflare/workers-types": "^4.20241230.0",
+ "@types/node": "^20.9.0",
+ "@types/ws": "^8.5.8",
+ "typescript": "^5.2.2",
+ "vitest": "^3.2.4"
+ }
+}
diff --git a/packages/relay/src/cloudflare-adapter.ts b/packages/relay/src/cloudflare-adapter.ts
new file mode 100644
index 000000000..edc5891cf
--- /dev/null
+++ b/packages/relay/src/cloudflare-adapter.ts
@@ -0,0 +1,206 @@
+/**
+ * Cloudflare Durable Objects adapter for the relay.
+ *
+ * This module provides a Durable Object class that can be deployed to
+ * Cloudflare Workers. It uses WebSocket hibernation for cost efficiency.
+ *
+ * Each session gets its own Durable Object instance, identified by session ID.
+ *
+ * Wrangler config:
+ * ```jsonc
+ * {
+ * "durable_objects": {
+ * "bindings": [{ "name": "RELAY", "class_name": "RelayDurableObject" }]
+ * },
+ * "migrations": [{ "tag": "v1", "new_classes": ["RelayDurableObject"] }]
+ * }
+ * ```
+ */
+
+import type { ConnectionRole, RelaySessionAttachment } from "./types.js";
+
+type WebSocketPair = {
+ 0: WebSocket;
+ 1: WebSocket;
+};
+
+interface DurableObjectState {
+ acceptWebSocket(ws: WebSocket, tags?: string[]): void;
+ getWebSockets(tag?: string): WebSocket[];
+}
+
+interface WebSocketWithAttachment extends WebSocket {
+ serializeAttachment(value: unknown): void;
+ deserializeAttachment(): unknown;
+}
+
+interface Env {
+ RELAY: DurableObjectNamespace;
+}
+
+interface DurableObjectNamespace {
+ idFromName(name: string): DurableObjectId;
+ get(id: DurableObjectId): DurableObjectStub;
+}
+
+interface DurableObjectId {
+ toString(): string;
+}
+
+interface DurableObjectStub {
+ fetch(request: Request): Promise;
+}
+
+/**
+ * Durable Object that handles WebSocket relay for a single session.
+ *
+ * Two WebSockets connect to this DO:
+ * - role=server: The Paseo daemon
+ * - role=client: The mobile/web app
+ *
+ * Messages are forwarded between them. The DO hibernates when idle.
+ */
+interface CFResponseInit extends ResponseInit {
+ webSocket?: WebSocket;
+}
+
+export class RelayDurableObject {
+ private state: DurableObjectState;
+
+ constructor(state: DurableObjectState) {
+ this.state = state;
+
+ }
+
+ async fetch(request: Request): Promise {
+ const url = new URL(request.url);
+ const role = url.searchParams.get("role") as ConnectionRole | null;
+ const sessionId = url.searchParams.get("session");
+
+ if (!role || (role !== "server" && role !== "client")) {
+ return new Response("Missing or invalid role parameter", { status: 400 });
+ }
+
+ if (!sessionId) {
+ return new Response("Missing session parameter", { status: 400 });
+ }
+
+ const upgradeHeader = request.headers.get("Upgrade");
+ if (!upgradeHeader || upgradeHeader.toLowerCase() !== "websocket") {
+ return new Response("Expected WebSocket upgrade", { status: 426 });
+ }
+
+ // Close any existing connection with the same role
+ const existingConnections = this.state.getWebSockets(role);
+ for (const ws of existingConnections) {
+ ws.close(1008, "Replaced by new connection");
+ }
+
+ // Create WebSocket pair
+ const pair = new (globalThis as unknown as { WebSocketPair: new () => WebSocketPair }).WebSocketPair();
+ const [client, server] = [pair[0], pair[1]];
+
+ // Accept with hibernation support, tagged by role
+ this.state.acceptWebSocket(server, [role]);
+
+ // Store attachment for hibernation recovery
+ const attachment: RelaySessionAttachment = {
+ sessionId,
+ role,
+ createdAt: Date.now(),
+ };
+ (server as WebSocketWithAttachment).serializeAttachment(attachment);
+
+ console.log(`[Relay DO] ${role} connected to session ${sessionId}`);
+
+ return new Response(null, {
+ status: 101,
+ webSocket: client,
+ } as CFResponseInit);
+ }
+
+ /**
+ * Called when a WebSocket message is received (wakes from hibernation).
+ */
+ webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): void {
+ const attachment = (ws as WebSocketWithAttachment).deserializeAttachment() as RelaySessionAttachment | null;
+ if (!attachment) {
+ console.error("[Relay DO] Message from WebSocket without attachment");
+ return;
+ }
+
+ const { role } = attachment;
+ const targetRole = role === "server" ? "client" : "server";
+ const targets = this.state.getWebSockets(targetRole);
+
+ for (const target of targets) {
+ try {
+ target.send(message);
+ } catch (error) {
+ console.error(`[Relay DO] Failed to forward to ${targetRole}:`, error);
+ }
+ }
+ }
+
+ /**
+ * Called when a WebSocket closes (wakes from hibernation).
+ */
+ webSocketClose(
+ ws: WebSocket,
+ code: number,
+ reason: string,
+ _wasClean: boolean
+ ): void {
+ const attachment = (ws as WebSocketWithAttachment).deserializeAttachment() as RelaySessionAttachment | null;
+ if (!attachment) return;
+
+ console.log(
+ `[Relay DO] ${attachment.role} disconnected from session ${attachment.sessionId} (${code}: ${reason})`
+ );
+
+ // Note: The other connection remains open.
+ // The client/server will detect the peer is gone when they try to send.
+ }
+
+ /**
+ * Called on WebSocket error.
+ */
+ webSocketError(ws: WebSocket, error: unknown): void {
+ const attachment = (ws as WebSocketWithAttachment).deserializeAttachment() as RelaySessionAttachment | null;
+ console.error(
+ `[Relay DO] WebSocket error for ${attachment?.role ?? "unknown"}:`,
+ error
+ );
+ }
+}
+
+/**
+ * Worker entry point that routes requests to the appropriate Durable Object.
+ */
+export default {
+ async fetch(request: Request, env: Env): Promise {
+ const url = new URL(request.url);
+
+ // Health check
+ if (url.pathname === "/health") {
+ return new Response(JSON.stringify({ status: "ok" }), {
+ headers: { "Content-Type": "application/json" },
+ });
+ }
+
+ // Relay endpoint
+ if (url.pathname === "/ws") {
+ const sessionId = url.searchParams.get("session");
+ if (!sessionId) {
+ return new Response("Missing session parameter", { status: 400 });
+ }
+
+ // Route to Durable Object instance for this session
+ const id = env.RELAY.idFromName(sessionId);
+ const stub = env.RELAY.get(id);
+ return stub.fetch(request);
+ }
+
+ return new Response("Not found", { status: 404 });
+ },
+};
diff --git a/packages/relay/src/crypto.test.ts b/packages/relay/src/crypto.test.ts
new file mode 100644
index 000000000..1b6ebf3db
--- /dev/null
+++ b/packages/relay/src/crypto.test.ts
@@ -0,0 +1,196 @@
+import { describe, it, expect } from "vitest";
+import {
+ generateKeyPair,
+ exportPublicKey,
+ importPublicKey,
+ deriveSharedKey,
+ encrypt,
+ decrypt,
+} from "./crypto.js";
+
+describe("crypto", () => {
+ describe("generateKeyPair", () => {
+ it("generates a valid ECDH keypair", async () => {
+ const keypair = await generateKeyPair();
+ expect(keypair.privateKey).toBeDefined();
+ expect(keypair.publicKey).toBeDefined();
+ });
+ });
+
+ describe("exportPublicKey / importPublicKey", () => {
+ it("roundtrips public key through base64", async () => {
+ const keypair = await generateKeyPair();
+ const exported = await exportPublicKey(keypair.publicKey);
+
+ expect(typeof exported).toBe("string");
+ expect(exported.length).toBeGreaterThan(0);
+
+ const imported = await importPublicKey(exported);
+ expect(imported).toBeDefined();
+
+ // Re-export should match
+ const reExported = await exportPublicKey(imported);
+ expect(reExported).toBe(exported);
+ });
+ });
+
+ describe("deriveSharedKey", () => {
+ it("derives the same key on both sides", async () => {
+ // Simulate daemon and client
+ const daemonKeyPair = await generateKeyPair();
+ const clientKeyPair = await generateKeyPair();
+
+ // Export public keys (what would go over the wire)
+ const daemonPubKeyB64 = await exportPublicKey(daemonKeyPair.publicKey);
+ const clientPubKeyB64 = await exportPublicKey(clientKeyPair.publicKey);
+
+ // Import peer's public key
+ const daemonSeesClientPubKey = await importPublicKey(clientPubKeyB64);
+ const clientSeesDaemonPubKey = await importPublicKey(daemonPubKeyB64);
+
+ // Derive shared keys
+ const daemonSharedKey = await deriveSharedKey(
+ daemonKeyPair.privateKey,
+ daemonSeesClientPubKey
+ );
+ const clientSharedKey = await deriveSharedKey(
+ clientKeyPair.privateKey,
+ clientSeesDaemonPubKey
+ );
+
+ // Both should derive the same key - test by encrypting with one, decrypting with other
+ const testMessage = "Hello, encrypted world!";
+ const encrypted = await encrypt(daemonSharedKey, testMessage);
+ const decrypted = await decrypt(clientSharedKey, encrypted);
+
+ expect(decrypted).toBe(testMessage);
+ });
+ });
+
+ describe("encrypt / decrypt", () => {
+ it("roundtrips a string message", async () => {
+ const daemonKeyPair = await generateKeyPair();
+ const clientKeyPair = await generateKeyPair();
+ const sharedKey = await deriveSharedKey(
+ daemonKeyPair.privateKey,
+ clientKeyPair.publicKey
+ );
+
+ const plaintext = "Test message with unicode: 你好世界 🎉";
+ const ciphertext = await encrypt(sharedKey, plaintext);
+
+ expect(ciphertext).toBeInstanceOf(ArrayBuffer);
+ expect(ciphertext.byteLength).toBeGreaterThan(plaintext.length);
+
+ const decrypted = await decrypt(sharedKey, ciphertext);
+ expect(decrypted).toBe(plaintext);
+ });
+
+ it("roundtrips binary data", async () => {
+ const daemonKeyPair = await generateKeyPair();
+ const clientKeyPair = await generateKeyPair();
+ const sharedKey = await deriveSharedKey(
+ daemonKeyPair.privateKey,
+ clientKeyPair.publicKey
+ );
+
+ const binary = new Uint8Array([0, 1, 2, 255, 254, 253]);
+ const ciphertext = await encrypt(sharedKey, binary.buffer);
+
+ const decrypted = await decrypt(sharedKey, ciphertext);
+ expect(new Uint8Array(decrypted as ArrayBuffer)).toEqual(binary);
+ });
+
+ it("fails to decrypt with wrong key", async () => {
+ const keypair1 = await generateKeyPair();
+ const keypair2 = await generateKeyPair();
+ const keypair3 = await generateKeyPair();
+
+ const correctKey = await deriveSharedKey(
+ keypair1.privateKey,
+ keypair2.publicKey
+ );
+ const wrongKey = await deriveSharedKey(
+ keypair1.privateKey,
+ keypair3.publicKey
+ );
+
+ const ciphertext = await encrypt(correctKey, "secret");
+
+ await expect(decrypt(wrongKey, ciphertext)).rejects.toThrow();
+ });
+
+ it("produces different ciphertext for same plaintext (random IV)", async () => {
+ const keypair1 = await generateKeyPair();
+ const keypair2 = await generateKeyPair();
+ const sharedKey = await deriveSharedKey(
+ keypair1.privateKey,
+ keypair2.publicKey
+ );
+
+ const plaintext = "Same message";
+ const ciphertext1 = await encrypt(sharedKey, plaintext);
+ const ciphertext2 = await encrypt(sharedKey, plaintext);
+
+ // Should be different due to random IV
+ const arr1 = new Uint8Array(ciphertext1);
+ const arr2 = new Uint8Array(ciphertext2);
+ expect(arr1).not.toEqual(arr2);
+
+ // But both should decrypt to same plaintext
+ expect(await decrypt(sharedKey, ciphertext1)).toBe(plaintext);
+ expect(await decrypt(sharedKey, ciphertext2)).toBe(plaintext);
+ });
+ });
+
+ describe("full handshake simulation", () => {
+ it("simulates complete daemon<->client key exchange", async () => {
+ // === DAEMON SIDE (generates session) ===
+ const daemonKeyPair = await generateKeyPair();
+ const daemonPubKeyB64 = await exportPublicKey(daemonKeyPair.publicKey);
+
+ // QR code would contain: { s: sessionId, k: daemonPubKeyB64, c: [...urls] }
+
+ // === CLIENT SIDE (scans QR) ===
+ const clientKeyPair = await generateKeyPair();
+ const clientPubKeyB64 = await exportPublicKey(clientKeyPair.publicKey);
+
+ // Client imports daemon's public key from QR
+ const daemonPubKeyOnClient = await importPublicKey(daemonPubKeyB64);
+
+ // Client derives shared key
+ const clientSharedKey = await deriveSharedKey(
+ clientKeyPair.privateKey,
+ daemonPubKeyOnClient
+ );
+
+ // Client sends hello: { type: "hello", key: clientPubKeyB64 }
+
+ // === DAEMON SIDE (receives hello) ===
+ // Daemon imports client's public key from hello message
+ const clientPubKeyOnDaemon = await importPublicKey(clientPubKeyB64);
+
+ // Daemon derives shared key
+ const daemonSharedKey = await deriveSharedKey(
+ daemonKeyPair.privateKey,
+ clientPubKeyOnDaemon
+ );
+
+ // === VERIFY BOTH HAVE SAME KEY ===
+ const testFromDaemon = "Message from daemon";
+ const testFromClient = "Message from client";
+
+ // Daemon encrypts, client decrypts
+ const encryptedFromDaemon = await encrypt(daemonSharedKey, testFromDaemon);
+ expect(await decrypt(clientSharedKey, encryptedFromDaemon)).toBe(
+ testFromDaemon
+ );
+
+ // Client encrypts, daemon decrypts
+ const encryptedFromClient = await encrypt(clientSharedKey, testFromClient);
+ expect(await decrypt(daemonSharedKey, encryptedFromClient)).toBe(
+ testFromClient
+ );
+ });
+ });
+});
diff --git a/packages/relay/src/crypto.ts b/packages/relay/src/crypto.ts
new file mode 100644
index 000000000..8176b0e34
--- /dev/null
+++ b/packages/relay/src/crypto.ts
@@ -0,0 +1,149 @@
+/**
+ * E2EE crypto primitives using WebCrypto API.
+ *
+ * - ECDH P-256 for key exchange
+ * - HKDF for key derivation
+ * - AES-256-GCM for authenticated encryption
+ */
+
+const ECDH_ALGORITHM = { name: "ECDH", namedCurve: "P-256" };
+const AES_ALGORITHM = { name: "AES-GCM", length: 256 };
+const IV_LENGTH = 12;
+
+/**
+ * Generate an ECDH P-256 keypair for key exchange.
+ */
+export async function generateKeyPair(): Promise {
+ return crypto.subtle.generateKey(ECDH_ALGORITHM, true, ["deriveBits"]);
+}
+
+/**
+ * Export a public key to base64 string (for transmission).
+ */
+export async function exportPublicKey(key: CryptoKey): Promise {
+ const raw = await crypto.subtle.exportKey("raw", key);
+ return arrayBufferToBase64(raw);
+}
+
+/**
+ * Import a public key from base64 string.
+ */
+export async function importPublicKey(base64: string): Promise {
+ const raw = base64ToArrayBuffer(base64);
+ return crypto.subtle.importKey("raw", raw, ECDH_ALGORITHM, true, []);
+}
+
+/**
+ * Derive a shared AES-256-GCM key from ECDH key exchange.
+ *
+ * Uses HKDF with SHA-256 to derive the final key.
+ */
+export async function deriveSharedKey(
+ privateKey: CryptoKey,
+ peerPublicKey: CryptoKey
+): Promise {
+ // Perform ECDH to get shared secret bits
+ const sharedBits = await crypto.subtle.deriveBits(
+ { name: "ECDH", public: peerPublicKey },
+ privateKey,
+ 256
+ );
+
+ // Import shared bits as HKDF key material
+ const hkdfKey = await crypto.subtle.importKey(
+ "raw",
+ sharedBits,
+ "HKDF",
+ false,
+ ["deriveKey"]
+ );
+
+ // Derive AES-256-GCM key using HKDF
+ return crypto.subtle.deriveKey(
+ {
+ name: "HKDF",
+ hash: "SHA-256",
+ salt: new Uint8Array(0),
+ info: new TextEncoder().encode("paseo-e2ee-v1"),
+ },
+ hkdfKey,
+ AES_ALGORITHM,
+ false,
+ ["encrypt", "decrypt"]
+ );
+}
+
+/**
+ * Encrypt data with AES-256-GCM.
+ *
+ * Returns: [IV (12 bytes)][ciphertext + auth tag]
+ */
+export async function encrypt(
+ key: CryptoKey,
+ data: string | ArrayBuffer
+): Promise {
+ const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
+ const plaintext =
+ typeof data === "string" ? new TextEncoder().encode(data) : data;
+
+ const ciphertext = await crypto.subtle.encrypt(
+ { name: "AES-GCM", iv },
+ key,
+ plaintext
+ );
+
+ // Prepend IV to ciphertext
+ const result = new Uint8Array(IV_LENGTH + ciphertext.byteLength);
+ result.set(iv, 0);
+ result.set(new Uint8Array(ciphertext), IV_LENGTH);
+
+ return result.buffer;
+}
+
+/**
+ * Decrypt data with AES-256-GCM.
+ *
+ * Input format: [IV (12 bytes)][ciphertext + auth tag]
+ * Returns string if original was string, ArrayBuffer if binary.
+ */
+export async function decrypt(
+ key: CryptoKey,
+ data: ArrayBuffer
+): Promise {
+ const dataArray = new Uint8Array(data);
+ const iv = dataArray.slice(0, IV_LENGTH);
+ const ciphertext = dataArray.slice(IV_LENGTH);
+
+ const plaintext = await crypto.subtle.decrypt(
+ { name: "AES-GCM", iv },
+ key,
+ ciphertext
+ );
+
+ // Try to decode as UTF-8 string, fall back to ArrayBuffer
+ try {
+ return new TextDecoder("utf-8", { fatal: true }).decode(plaintext);
+ } catch {
+ return plaintext;
+ }
+}
+
+// --- Helpers ---
+
+function arrayBufferToBase64(buffer: ArrayBuffer): string {
+ const bytes = new Uint8Array(buffer);
+ let binary = "";
+ for (let i = 0; i < bytes.byteLength; i++) {
+ binary += String.fromCharCode(bytes[i]);
+ }
+ return btoa(binary);
+}
+
+function base64ToArrayBuffer(base64: string): ArrayBuffer {
+ const binary = atob(base64);
+ const bytes = new Uint8Array(binary.length);
+ for (let i = 0; i < binary.length; i++) {
+ bytes[i] = binary.charCodeAt(i);
+ }
+ return bytes.buffer;
+}
diff --git a/packages/relay/src/e2e.test.ts b/packages/relay/src/e2e.test.ts
new file mode 100644
index 000000000..0fcc46280
--- /dev/null
+++ b/packages/relay/src/e2e.test.ts
@@ -0,0 +1,246 @@
+import { describe, it, expect, beforeAll, afterAll } from "vitest";
+import { WebSocket } from "ws";
+import { createRelayServer, RelayServer } from "./node-adapter.js";
+import {
+ generateKeyPair,
+ exportPublicKey,
+ importPublicKey,
+ deriveSharedKey,
+ encrypt,
+ decrypt,
+} from "./crypto.js";
+
+const TEST_PORT = 19999;
+
+describe("E2E Relay with E2EE", () => {
+ let relay: RelayServer;
+
+ beforeAll(async () => {
+ relay = createRelayServer({ port: TEST_PORT, host: "127.0.0.1" });
+ await relay.start();
+ });
+
+ afterAll(async () => {
+ await relay.stop();
+ });
+
+ it("full flow: daemon and client exchange encrypted messages through relay", async () => {
+ const sessionId = "test-session-" + Date.now();
+
+ // === DAEMON SIDE ===
+ // Generate keypair (public key goes in QR)
+ const daemonKeyPair = await generateKeyPair();
+ const daemonPubKeyB64 = await exportPublicKey(daemonKeyPair.publicKey);
+
+ // QR would contain: { s: sessionId, k: daemonPubKeyB64, c: [...urls] }
+
+ // Daemon connects to relay as "server" role
+ const daemonWs = new WebSocket(
+ `ws://127.0.0.1:${TEST_PORT}/ws?session=${sessionId}&role=server`
+ );
+
+ await new Promise((resolve, reject) => {
+ daemonWs.on("open", resolve);
+ daemonWs.on("error", reject);
+ });
+
+ // === CLIENT SIDE ===
+ // Client scans QR, gets daemon's public key and session ID
+ // Client generates own keypair
+ const clientKeyPair = await generateKeyPair();
+ const clientPubKeyB64 = await exportPublicKey(clientKeyPair.publicKey);
+
+ // Client imports daemon's public key and derives shared secret
+ const daemonPubKeyOnClient = await importPublicKey(daemonPubKeyB64);
+ const clientSharedKey = await deriveSharedKey(
+ clientKeyPair.privateKey,
+ daemonPubKeyOnClient
+ );
+
+ // Client connects to relay as "client" role
+ const clientWs = new WebSocket(
+ `ws://127.0.0.1:${TEST_PORT}/ws?session=${sessionId}&role=client`
+ );
+
+ await new Promise((resolve, reject) => {
+ clientWs.on("open", resolve);
+ clientWs.on("error", reject);
+ });
+
+ // Client sends hello with its public key (this message is NOT encrypted - it's the handshake)
+ const helloMsg = JSON.stringify({ type: "hello", key: clientPubKeyB64 });
+ clientWs.send(helloMsg);
+
+ // === DAEMON RECEIVES HELLO ===
+ const daemonReceivedHello = await new Promise((resolve) => {
+ daemonWs.once("message", (data) => resolve(data.toString()));
+ });
+
+ const hello = JSON.parse(daemonReceivedHello);
+ expect(hello.type).toBe("hello");
+ expect(hello.key).toBe(clientPubKeyB64);
+
+ // Daemon imports client's public key and derives shared secret
+ const clientPubKeyOnDaemon = await importPublicKey(hello.key);
+ const daemonSharedKey = await deriveSharedKey(
+ daemonKeyPair.privateKey,
+ clientPubKeyOnDaemon
+ );
+
+ // === VERIFY BOTH HAVE SAME KEY - Exchange encrypted messages ===
+
+ // Daemon sends encrypted "ready" message
+ const readyPlaintext = JSON.stringify({ type: "ready" });
+ const readyCiphertext = await encrypt(daemonSharedKey, readyPlaintext);
+ daemonWs.send(Buffer.from(readyCiphertext));
+
+ // Client receives and decrypts
+ const clientReceivedReady = await new Promise((resolve) => {
+ clientWs.once("message", (data) => resolve(data as Buffer));
+ });
+ const decryptedReady = await decrypt(
+ clientSharedKey,
+ clientReceivedReady.buffer.slice(
+ clientReceivedReady.byteOffset,
+ clientReceivedReady.byteOffset + clientReceivedReady.byteLength
+ )
+ );
+ expect(JSON.parse(decryptedReady as string)).toEqual({ type: "ready" });
+
+ // Client sends encrypted message
+ const clientMessage = "Hello from client!";
+ const clientCiphertext = await encrypt(clientSharedKey, clientMessage);
+ clientWs.send(Buffer.from(clientCiphertext));
+
+ // Daemon receives and decrypts
+ const daemonReceivedMsg = await new Promise((resolve) => {
+ daemonWs.once("message", (data) => resolve(data as Buffer));
+ });
+ const decryptedClientMsg = await decrypt(
+ daemonSharedKey,
+ daemonReceivedMsg.buffer.slice(
+ daemonReceivedMsg.byteOffset,
+ daemonReceivedMsg.byteOffset + daemonReceivedMsg.byteLength
+ )
+ );
+ expect(decryptedClientMsg).toBe(clientMessage);
+
+ // Daemon sends encrypted response
+ const daemonMessage = "Hello from daemon!";
+ const daemonCiphertext = await encrypt(daemonSharedKey, daemonMessage);
+ daemonWs.send(Buffer.from(daemonCiphertext));
+
+ // Client receives and decrypts
+ const clientReceivedMsg = await new Promise((resolve) => {
+ clientWs.once("message", (data) => resolve(data as Buffer));
+ });
+ const decryptedDaemonMsg = await decrypt(
+ clientSharedKey,
+ clientReceivedMsg.buffer.slice(
+ clientReceivedMsg.byteOffset,
+ clientReceivedMsg.byteOffset + clientReceivedMsg.byteLength
+ )
+ );
+ expect(decryptedDaemonMsg).toBe(daemonMessage);
+
+ // Cleanup
+ daemonWs.close();
+ clientWs.close();
+ });
+
+ it("relay only sees opaque bytes after handshake", async () => {
+ const sessionId = "opaque-test-" + Date.now();
+
+ // Setup keys
+ const daemonKeyPair = await generateKeyPair();
+ const clientKeyPair = await generateKeyPair();
+
+ const daemonPubKeyB64 = await exportPublicKey(daemonKeyPair.publicKey);
+ const clientPubKeyB64 = await exportPublicKey(clientKeyPair.publicKey);
+
+ const clientPubKey = await importPublicKey(clientPubKeyB64);
+ const daemonPubKey = await importPublicKey(daemonPubKeyB64);
+
+ const daemonSharedKey = await deriveSharedKey(
+ daemonKeyPair.privateKey,
+ clientPubKey
+ );
+ const clientSharedKey = await deriveSharedKey(
+ clientKeyPair.privateKey,
+ daemonPubKey
+ );
+
+ // Connect both
+ const daemonWs = new WebSocket(
+ `ws://127.0.0.1:${TEST_PORT}/ws?session=${sessionId}&role=server`
+ );
+ const clientWs = new WebSocket(
+ `ws://127.0.0.1:${TEST_PORT}/ws?session=${sessionId}&role=client`
+ );
+
+ await Promise.all([
+ new Promise((r) => daemonWs.on("open", r)),
+ new Promise((r) => clientWs.on("open", r)),
+ ]);
+
+ // Send encrypted secret
+ const secret = "This is a secret that relay cannot read";
+ const ciphertext = await encrypt(clientSharedKey, secret);
+ clientWs.send(Buffer.from(ciphertext));
+
+ // Daemon receives
+ const received = await new Promise((resolve) => {
+ daemonWs.once("message", (data) => resolve(data as Buffer));
+ });
+
+ // The raw bytes don't contain the plaintext
+ const rawString = received.toString("utf-8");
+ expect(rawString).not.toContain(secret);
+
+ // But daemon can decrypt
+ const decrypted = await decrypt(
+ daemonSharedKey,
+ received.buffer.slice(
+ received.byteOffset,
+ received.byteOffset + received.byteLength
+ )
+ );
+ expect(decrypted).toBe(secret);
+
+ daemonWs.close();
+ clientWs.close();
+ });
+
+ it("wrong key cannot decrypt", async () => {
+ const sessionId = "wrong-key-test-" + Date.now();
+
+ // Setup - daemon and client with correct keys
+ const daemonKeyPair = await generateKeyPair();
+ const clientKeyPair = await generateKeyPair();
+ const attackerKeyPair = await generateKeyPair();
+
+ const clientPubKey = await importPublicKey(
+ await exportPublicKey(clientKeyPair.publicKey)
+ );
+ const daemonSharedKey = await deriveSharedKey(
+ daemonKeyPair.privateKey,
+ clientPubKey
+ );
+
+ // Attacker tries to derive key with their own keypair
+ const attackerPubKey = await importPublicKey(
+ await exportPublicKey(attackerKeyPair.publicKey)
+ );
+ const attackerKey = await deriveSharedKey(
+ attackerKeyPair.privateKey,
+ attackerPubKey
+ );
+
+ // Encrypt with daemon's key
+ const secret = "Top secret message";
+ const ciphertext = await encrypt(daemonSharedKey, secret);
+
+ // Attacker cannot decrypt
+ await expect(decrypt(attackerKey, ciphertext)).rejects.toThrow();
+ });
+});
diff --git a/packages/relay/src/encrypted-channel.test.ts b/packages/relay/src/encrypted-channel.test.ts
new file mode 100644
index 000000000..917dc0833
--- /dev/null
+++ b/packages/relay/src/encrypted-channel.test.ts
@@ -0,0 +1,155 @@
+import { describe, it, expect, vi } from "vitest";
+import {
+ createClientChannel,
+ createDaemonChannel,
+ Transport,
+} from "./encrypted-channel.js";
+import { generateKeyPair, exportPublicKey } from "./crypto.js";
+
+/**
+ * Creates a pair of connected mock transports.
+ * Messages sent on one are received on the other.
+ */
+function createMockTransportPair(): [Transport, Transport] {
+ const transportA: Transport = {
+ send: vi.fn(),
+ close: vi.fn(),
+ onmessage: null,
+ onclose: null,
+ onerror: null,
+ };
+
+ const transportB: Transport = {
+ send: vi.fn(),
+ close: vi.fn(),
+ onmessage: null,
+ onclose: null,
+ onerror: null,
+ };
+
+ // Wire them together
+ (transportA.send as ReturnType).mockImplementation(
+ (data: string | ArrayBuffer) => {
+ setTimeout(() => transportB.onmessage?.(data), 0);
+ }
+ );
+
+ (transportB.send as ReturnType).mockImplementation(
+ (data: string | ArrayBuffer) => {
+ setTimeout(() => transportA.onmessage?.(data), 0);
+ }
+ );
+
+ return [transportA, transportB];
+}
+
+describe("EncryptedChannel", () => {
+ it("establishes encrypted channel between daemon and client", async () => {
+ const [daemonTransport, clientTransport] = createMockTransportPair();
+
+ // Daemon generates keypair (public key goes in QR)
+ const daemonKeyPair = await generateKeyPair();
+ const daemonPubKeyB64 = await exportPublicKey(daemonKeyPair.publicKey);
+
+ // Start daemon waiting for client
+ const daemonChannelPromise = createDaemonChannel(daemonTransport, daemonKeyPair);
+
+ // Client connects (scanned QR, got daemon's public key)
+ const clientChannel = await createClientChannel(
+ clientTransport,
+ daemonPubKeyB64
+ );
+
+ // Daemon receives hello and completes handshake
+ const daemonChannel = await daemonChannelPromise;
+
+ expect(clientChannel.isOpen()).toBe(true);
+ expect(daemonChannel.isOpen()).toBe(true);
+ });
+
+ it("exchanges encrypted messages bidirectionally", async () => {
+ const [daemonTransport, clientTransport] = createMockTransportPair();
+
+ const daemonKeyPair = await generateKeyPair();
+ const daemonPubKeyB64 = await exportPublicKey(daemonKeyPair.publicKey);
+
+ const daemonMessages: (string | ArrayBuffer)[] = [];
+ const clientMessages: (string | ArrayBuffer)[] = [];
+
+ const daemonChannelPromise = createDaemonChannel(
+ daemonTransport,
+ daemonKeyPair,
+ { onmessage: (data) => daemonMessages.push(data) }
+ );
+
+ const clientChannel = await createClientChannel(
+ clientTransport,
+ daemonPubKeyB64,
+ { onmessage: (data) => clientMessages.push(data) }
+ );
+
+ const daemonChannel = await daemonChannelPromise;
+
+ // Send messages both directions
+ await clientChannel.send("Hello from client");
+ await daemonChannel.send("Hello from daemon");
+ await clientChannel.send("Second message from client");
+
+ // Wait for async delivery
+ await new Promise((r) => setTimeout(r, 50));
+
+ expect(daemonMessages).toEqual([
+ "Hello from client",
+ "Second message from client",
+ ]);
+ expect(clientMessages).toEqual(["Hello from daemon"]);
+ });
+
+ it("encrypted messages are opaque to transport", async () => {
+ const [daemonTransport, clientTransport] = createMockTransportPair();
+
+ const daemonKeyPair = await generateKeyPair();
+ const daemonPubKeyB64 = await exportPublicKey(daemonKeyPair.publicKey);
+
+ const daemonChannelPromise = createDaemonChannel(daemonTransport, daemonKeyPair);
+ const clientChannel = await createClientChannel(
+ clientTransport,
+ daemonPubKeyB64
+ );
+ await daemonChannelPromise;
+
+ // Clear mock call history
+ (clientTransport.send as ReturnType).mockClear();
+
+ // Send a plaintext message
+ const plaintext = "Secret message";
+ await clientChannel.send(plaintext);
+
+ // Check what was actually sent over the transport
+ expect(clientTransport.send).toHaveBeenCalledTimes(1);
+ const sentData = (clientTransport.send as ReturnType).mock
+ .calls[0][0];
+
+ // Should be base64 string (encrypted)
+ expect(typeof sentData).toBe("string");
+ // Should NOT contain the plaintext
+ expect(sentData).not.toContain(plaintext);
+ // Should be significantly longer than plaintext (IV + auth tag overhead)
+ expect(sentData.length).toBeGreaterThan(plaintext.length + 20);
+ });
+
+ it("fails handshake on invalid hello", async () => {
+ const [daemonTransport] = createMockTransportPair();
+
+ const daemonKeyPair = await generateKeyPair();
+
+ const daemonChannelPromise = createDaemonChannel(daemonTransport, daemonKeyPair);
+
+ // Send invalid hello
+ setTimeout(() => {
+ daemonTransport.onmessage?.('{"type":"invalid"}');
+ }, 0);
+
+ await expect(daemonChannelPromise).rejects.toThrow("Invalid hello message");
+ });
+});
diff --git a/packages/relay/src/encrypted-channel.ts b/packages/relay/src/encrypted-channel.ts
new file mode 100644
index 000000000..03e9d1fea
--- /dev/null
+++ b/packages/relay/src/encrypted-channel.ts
@@ -0,0 +1,215 @@
+/**
+ * Encrypted channel that wraps a WebSocket-like transport.
+ *
+ * Handles ECDH handshake and encrypts/decrypts all messages.
+ * Works identically for daemon and client sides.
+ */
+
+import {
+ generateKeyPair,
+ exportPublicKey,
+ importPublicKey,
+ deriveSharedKey,
+ encrypt,
+ decrypt,
+} from "./crypto.js";
+
+export interface Transport {
+ send(data: string | ArrayBuffer): void;
+ close(code?: number, reason?: string): void;
+ onmessage: ((data: string | ArrayBuffer) => void) | null;
+ onclose: ((code: number, reason: string) => void) | null;
+ onerror: ((error: Error) => void) | null;
+}
+
+export interface EncryptedChannelEvents {
+ onopen?: () => void;
+ onmessage?: (data: string | ArrayBuffer) => void;
+ onclose?: (code: number, reason: string) => void;
+ onerror?: (error: Error) => void;
+}
+
+type ChannelState = "connecting" | "handshaking" | "open" | "closed";
+
+interface HelloMessage {
+ type: "hello";
+ key: string;
+}
+
+/**
+ * Creates an encrypted channel as the initiator (client).
+ *
+ * The client:
+ * 1. Receives daemon's public key via QR code
+ * 2. Generates own keypair
+ * 3. Sends hello with own public key
+ * 4. Derives shared key and starts encrypted communication
+ */
+export async function createClientChannel(
+ transport: Transport,
+ daemonPublicKeyB64: string,
+ events: EncryptedChannelEvents = {}
+): Promise {
+ const keyPair = await generateKeyPair();
+ const daemonPublicKey = await importPublicKey(daemonPublicKeyB64);
+ const sharedKey = await deriveSharedKey(keyPair.privateKey, daemonPublicKey);
+
+ const channel = new EncryptedChannel(transport, sharedKey, events);
+
+ // Send hello with our public key
+ const ourPublicKeyB64 = await exportPublicKey(keyPair.publicKey);
+ const hello: HelloMessage = { type: "hello", key: ourPublicKeyB64 };
+ transport.send(JSON.stringify(hello));
+
+ channel.setState("open");
+ events.onopen?.();
+
+ return channel;
+}
+
+/**
+ * Creates an encrypted channel as the responder (daemon).
+ *
+ * The daemon:
+ * 1. Has pre-generated keypair (public key was in QR)
+ * 2. Waits for client's hello with their public key
+ * 3. Derives shared key and starts encrypted communication
+ */
+export async function createDaemonChannel(
+ transport: Transport,
+ daemonKeyPair: CryptoKeyPair,
+ events: EncryptedChannelEvents = {}
+): Promise {
+ return new Promise((resolve, reject) => {
+ const timeout = setTimeout(() => {
+ reject(new Error("Handshake timeout"));
+ }, 10000);
+
+ transport.onmessage = async (data) => {
+ try {
+ if (typeof data !== "string") {
+ throw new Error("Expected string hello message");
+ }
+
+ const msg = JSON.parse(data) as HelloMessage;
+ if (msg.type !== "hello" || !msg.key) {
+ throw new Error("Invalid hello message");
+ }
+
+ clearTimeout(timeout);
+
+ const clientPublicKey = await importPublicKey(msg.key);
+ const sharedKey = await deriveSharedKey(
+ daemonKeyPair.privateKey,
+ clientPublicKey
+ );
+
+ const channel = new EncryptedChannel(transport, sharedKey, events);
+ channel.setState("open");
+ events.onopen?.();
+
+ resolve(channel);
+ } catch (error) {
+ clearTimeout(timeout);
+ reject(error);
+ }
+ };
+
+ transport.onerror = (error) => {
+ clearTimeout(timeout);
+ reject(error);
+ };
+
+ transport.onclose = (code, reason) => {
+ clearTimeout(timeout);
+ reject(new Error(`Connection closed during handshake: ${code} ${reason}`));
+ };
+ });
+}
+
+/**
+ * Encrypted channel that wraps a transport with E2EE.
+ */
+export class EncryptedChannel {
+ private transport: Transport;
+ private sharedKey: CryptoKey;
+ private state: ChannelState = "handshaking";
+ private events: EncryptedChannelEvents;
+
+ constructor(
+ transport: Transport,
+ sharedKey: CryptoKey,
+ events: EncryptedChannelEvents = {}
+ ) {
+ this.transport = transport;
+ this.sharedKey = sharedKey;
+ this.events = events;
+
+ transport.onmessage = (data) => this.handleMessage(data);
+ transport.onclose = (code, reason) => {
+ this.state = "closed";
+ this.events.onclose?.(code, reason);
+ };
+ transport.onerror = (error) => {
+ this.events.onerror?.(error);
+ };
+ }
+
+ setState(state: ChannelState): void {
+ this.state = state;
+ }
+
+ private async handleMessage(data: string | ArrayBuffer): Promise {
+ if (this.state !== "open") return;
+
+ try {
+ const ciphertext =
+ typeof data === "string" ? base64ToArrayBuffer(data) : data;
+ const plaintext = await decrypt(this.sharedKey, ciphertext);
+ this.events.onmessage?.(plaintext);
+ } catch (error) {
+ this.events.onerror?.(
+ error instanceof Error ? error : new Error(String(error))
+ );
+ }
+ }
+
+ async send(data: string | ArrayBuffer): Promise {
+ if (this.state !== "open") {
+ throw new Error("Channel not open");
+ }
+
+ const ciphertext = await encrypt(this.sharedKey, data);
+ // Send as base64 for WebSocket text compatibility
+ this.transport.send(arrayBufferToBase64(ciphertext));
+ }
+
+ close(code = 1000, reason = "Normal closure"): void {
+ this.state = "closed";
+ this.transport.close(code, reason);
+ }
+
+ isOpen(): boolean {
+ return this.state === "open";
+ }
+}
+
+// --- Helpers ---
+
+function arrayBufferToBase64(buffer: ArrayBuffer): string {
+ const bytes = new Uint8Array(buffer);
+ let binary = "";
+ for (let i = 0; i < bytes.byteLength; i++) {
+ binary += String.fromCharCode(bytes[i]);
+ }
+ return btoa(binary);
+}
+
+function base64ToArrayBuffer(base64: string): ArrayBuffer {
+ const binary = atob(base64);
+ const bytes = new Uint8Array(binary.length);
+ for (let i = 0; i < binary.length; i++) {
+ bytes[i] = binary.charCodeAt(i);
+ }
+ return bytes.buffer;
+}
diff --git a/packages/relay/src/index.ts b/packages/relay/src/index.ts
new file mode 100644
index 000000000..8ae9211d7
--- /dev/null
+++ b/packages/relay/src/index.ts
@@ -0,0 +1,24 @@
+export { Relay } from "./relay.js";
+export type {
+ ConnectionRole,
+ RelayConnection,
+ RelayEvents,
+ RelaySession,
+ RelaySessionAttachment,
+} from "./types.js";
+
+export {
+ generateKeyPair,
+ exportPublicKey,
+ importPublicKey,
+ deriveSharedKey,
+ encrypt,
+ decrypt,
+} from "./crypto.js";
+
+export {
+ createClientChannel,
+ createDaemonChannel,
+ EncryptedChannel,
+} from "./encrypted-channel.js";
+export type { Transport, EncryptedChannelEvents } from "./encrypted-channel.js";
diff --git a/packages/relay/src/node-adapter.ts b/packages/relay/src/node-adapter.ts
new file mode 100644
index 000000000..f20b5e489
--- /dev/null
+++ b/packages/relay/src/node-adapter.ts
@@ -0,0 +1,135 @@
+import http from "http";
+import { WebSocketServer, WebSocket as NodeWebSocket } from "ws";
+import { Relay } from "./relay.js";
+import type { ConnectionRole, RelayConnection } from "./types.js";
+
+export interface NodeRelayServerConfig {
+ port: number;
+ host?: string;
+}
+
+/**
+ * Standalone Node.js relay server for self-hosting.
+ *
+ * This is a separate process that bridges daemon↔client connections.
+ * Use this when you want to self-host a relay instead of using Cloudflare.
+ *
+ * Usage:
+ * ```ts
+ * const server = createRelayServer({ port: 8080 });
+ * await server.start();
+ * ```
+ *
+ * Clients connect via:
+ * - ws://host:port/ws?session=abc&role=server
+ * - ws://host:port/ws?session=abc&role=client
+ */
+export interface RelayServer {
+ start(): Promise;
+ stop(): Promise;
+ getRelay(): Relay;
+}
+
+export function createRelayServer(config: NodeRelayServerConfig): RelayServer {
+ const { port, host = "0.0.0.0" } = config;
+
+ const relay = new Relay({
+ onSessionCreated: (id) => console.log(`[Relay] Session created: ${id}`),
+ onSessionBridged: (id) => console.log(`[Relay] Session bridged: ${id}`),
+ onSessionClosed: (id) => console.log(`[Relay] Session closed: ${id}`),
+ onError: (id, err) => console.error(`[Relay] Session ${id} error:`, err),
+ });
+
+ const httpServer = http.createServer((req, res) => {
+ if (req.url === "/health") {
+ res.writeHead(200, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ status: "ok" }));
+ return;
+ }
+ res.writeHead(404);
+ res.end("Not found");
+ });
+
+ const wss = new WebSocketServer({
+ server: httpServer,
+ path: "/ws",
+ verifyClient: ({ req }, callback) => {
+ const url = new URL(req.url ?? "", `http://${req.headers.host}`);
+ const session = url.searchParams.get("session");
+ const role = url.searchParams.get("role");
+
+ if (!session || !role || (role !== "server" && role !== "client")) {
+ callback(false, 400, "Missing or invalid session/role parameters");
+ return;
+ }
+
+ callback(true);
+ },
+ });
+
+ wss.on("connection", (ws, req) => {
+ const url = new URL(req.url ?? "", `http://${req.headers.host}`);
+ const sessionId = url.searchParams.get("session")!;
+ const role = url.searchParams.get("role") as ConnectionRole;
+
+ const connection = wrapWebSocket(ws);
+ relay.addConnection(sessionId, role, connection);
+
+ ws.on("message", (data) => {
+ const message =
+ data instanceof Buffer
+ ? data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength)
+ : String(data);
+ relay.forward(sessionId, role, message as string | ArrayBuffer);
+ });
+
+ ws.on("close", () => {
+ relay.removeConnection(sessionId, role);
+ });
+
+ ws.on("error", (error) => {
+ console.error(`[Relay] WebSocket error for ${sessionId}/${role}:`, error);
+ });
+ });
+
+ return {
+ start() {
+ return new Promise((resolve, reject) => {
+ httpServer.on("error", reject);
+ httpServer.listen(port, host, () => {
+ console.log(`[Relay] Listening on ${host}:${port}`);
+ resolve();
+ });
+ });
+ },
+
+ stop() {
+ return new Promise((resolve) => {
+ for (const session of relay.listSessions()) {
+ relay.closeSession(session.id, 1001, "Server shutting down");
+ }
+ wss.close(() => {
+ httpServer.close(() => resolve());
+ });
+ });
+ },
+
+ getRelay() {
+ return relay;
+ },
+ };
+}
+
+function wrapWebSocket(ws: NodeWebSocket): RelayConnection {
+ return {
+ role: "server",
+ send: (data) => {
+ if (ws.readyState === NodeWebSocket.OPEN) {
+ ws.send(data);
+ }
+ },
+ close: (code, reason) => {
+ ws.close(code, reason);
+ },
+ };
+}
diff --git a/packages/relay/src/relay.ts b/packages/relay/src/relay.ts
new file mode 100644
index 000000000..5cf676250
--- /dev/null
+++ b/packages/relay/src/relay.ts
@@ -0,0 +1,132 @@
+import type {
+ ConnectionRole,
+ RelayConnection,
+ RelayEvents,
+ RelaySession,
+} from "./types.js";
+
+/**
+ * Core relay logic for bridging server and client WebSocket connections.
+ *
+ * This class is platform-agnostic and works with any WebSocket implementation
+ * that conforms to the RelayConnection interface.
+ */
+export class Relay {
+ private sessions = new Map();
+ private events: RelayEvents;
+
+ constructor(events: RelayEvents = {}) {
+ this.events = events;
+ }
+
+ /**
+ * Register a connection for a session.
+ * If both server and client are connected, messages are bridged.
+ */
+ addConnection(
+ sessionId: string,
+ role: ConnectionRole,
+ connection: RelayConnection
+ ): void {
+ let session = this.sessions.get(sessionId);
+
+ if (!session) {
+ session = {
+ id: sessionId,
+ server: null,
+ client: null,
+ createdAt: Date.now(),
+ };
+ this.sessions.set(sessionId, session);
+ this.events.onSessionCreated?.(sessionId);
+ }
+
+ const existingConnection = session[role];
+ if (existingConnection) {
+ existingConnection.close(1008, "Replaced by new connection");
+ }
+
+ session[role] = connection;
+
+ if (session.server && session.client) {
+ this.events.onSessionBridged?.(sessionId);
+ }
+ }
+
+ /**
+ * Remove a connection from a session.
+ * If both connections are gone, the session is cleaned up.
+ */
+ removeConnection(sessionId: string, role: ConnectionRole): void {
+ const session = this.sessions.get(sessionId);
+ if (!session) return;
+
+ session[role] = null;
+
+ if (!session.server && !session.client) {
+ this.sessions.delete(sessionId);
+ this.events.onSessionClosed?.(sessionId);
+ }
+ }
+
+ /**
+ * Forward a message from one side to the other.
+ */
+ forward(
+ sessionId: string,
+ fromRole: ConnectionRole,
+ data: string | ArrayBuffer
+ ): void {
+ const session = this.sessions.get(sessionId);
+ if (!session) return;
+
+ const target = fromRole === "server" ? session.client : session.server;
+ if (target) {
+ try {
+ target.send(data);
+ } catch (error) {
+ this.events.onError?.(
+ sessionId,
+ error instanceof Error ? error : new Error(String(error))
+ );
+ }
+ }
+ }
+
+ /**
+ * Get session info for debugging/monitoring.
+ */
+ getSession(sessionId: string): RelaySession | undefined {
+ return this.sessions.get(sessionId);
+ }
+
+ /**
+ * List all active sessions.
+ */
+ listSessions(): RelaySession[] {
+ return Array.from(this.sessions.values());
+ }
+
+ /**
+ * Close a session and both connections.
+ */
+ closeSession(sessionId: string, code = 1000, reason = "Session closed"): void {
+ const session = this.sessions.get(sessionId);
+ if (!session) return;
+
+ session.server?.close(code, reason);
+ session.client?.close(code, reason);
+
+ this.sessions.delete(sessionId);
+ this.events.onSessionClosed?.(sessionId);
+ }
+
+ /**
+ * Restore sessions from persisted state (for Durable Objects hibernation).
+ */
+ restoreSessions(sessions: RelaySession[]): void {
+ for (const session of sessions) {
+ this.sessions.set(session.id, session);
+ }
+ }
+}
diff --git a/packages/relay/src/types.ts b/packages/relay/src/types.ts
new file mode 100644
index 000000000..2cd3aa444
--- /dev/null
+++ b/packages/relay/src/types.ts
@@ -0,0 +1,37 @@
+/**
+ * Relay connection types and interfaces.
+ *
+ * The relay bridges two WebSocket connections:
+ * - Server (daemon): The Paseo server connecting to the relay
+ * - Client (app): The mobile/web app connecting to the relay
+ *
+ * Messages are forwarded bidirectionally without modification.
+ */
+
+export type ConnectionRole = "server" | "client";
+
+export interface RelaySession {
+ id: string;
+ server: RelayConnection | null;
+ client: RelayConnection | null;
+ createdAt: number;
+}
+
+export interface RelayConnection {
+ role: ConnectionRole;
+ send(data: string | ArrayBuffer): void;
+ close(code?: number, reason?: string): void;
+}
+
+export interface RelaySessionAttachment {
+ sessionId: string;
+ role: ConnectionRole;
+ createdAt: number;
+}
+
+export interface RelayEvents {
+ onSessionCreated?(sessionId: string): void;
+ onSessionBridged?(sessionId: string): void;
+ onSessionClosed?(sessionId: string): void;
+ onError?(sessionId: string, error: Error): void;
+}
diff --git a/packages/relay/tsconfig.json b/packages/relay/tsconfig.json
new file mode 100644
index 000000000..5485126b1
--- /dev/null
+++ b/packages/relay/tsconfig.json
@@ -0,0 +1,22 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "module": "NodeNext",
+ "moduleResolution": "NodeNext",
+ "lib": ["ES2020", "DOM"],
+ "skipLibCheck": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "esModuleInterop": true,
+ "allowSyntheticDefaultImports": true,
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "declaration": true,
+ "declarationMap": true,
+ "sourceMap": true
+ },
+ "include": ["src/**/*"],
+ "exclude": ["node_modules", "dist", "src/**/*.test.ts"]
+}
diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts
index c28565c2a..cfd337160 100644
--- a/packages/server/src/server/bootstrap.ts
+++ b/packages/server/src/server/bootstrap.ts
@@ -1,12 +1,42 @@
-import express, { type Express } from "express";
+import express from "express";
import basicAuth from "express-basic-auth";
-import { createServer as createHTTPServer, type Server as HTTPServer } from "http";
-import { createReadStream } from "fs";
+import { createServer as createHTTPServer } from "http";
+import { createReadStream, unlinkSync, existsSync } from "fs";
import { stat } from "fs/promises";
import { randomUUID } from "node:crypto";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
+type ListenTarget =
+ | { type: "tcp"; host: string; port: number }
+ | { type: "socket"; path: string };
+
+function parseListenString(listen: string): ListenTarget {
+ // Unix socket: starts with / or ~ or contains .sock
+ if (listen.startsWith("/") || listen.startsWith("~") || listen.includes(".sock")) {
+ return { type: "socket", path: listen };
+ }
+ // Explicit unix:// prefix
+ if (listen.startsWith("unix://")) {
+ return { type: "socket", path: listen.slice(7) };
+ }
+ // TCP: host:port or just port
+ if (listen.includes(":")) {
+ const [host, portStr] = listen.split(":");
+ const port = parseInt(portStr, 10);
+ if (!Number.isFinite(port)) {
+ throw new Error(`Invalid port in listen string: ${listen}`);
+ }
+ return { type: "tcp", host: host || "127.0.0.1", port };
+ }
+ // Just a port number
+ const port = parseInt(listen, 10);
+ if (Number.isFinite(port)) {
+ return { type: "tcp", host: "127.0.0.1", port };
+ }
+ throw new Error(`Invalid listen string: ${listen}`);
+}
+
import { VoiceAssistantWebSocketServer } from "./websocket-server.js";
import { DownloadTokenStore } from "./file-download/token-store.js";
import { initializeSTT, type STTConfig } from "./agent/stt-openai.js";
@@ -40,8 +70,9 @@ export type PaseoOpenAIConfig = {
};
export type PaseoDaemonConfig = {
- port: number;
+ listen: string;
paseoHome: string;
+ corsAllowedOrigins: string[];
agentMcpRoute: string;
agentMcpAllowedHosts: string[];
auth: PaseoAuthConfig;
@@ -54,18 +85,17 @@ export type PaseoDaemonConfig = {
downloadTokenTtlMs?: number;
};
-export type PaseoDaemonHandles = {
- httpServer: HTTPServer;
- app: Express;
- wsServer: VoiceAssistantWebSocketServer;
+export interface PaseoDaemon {
+ config: PaseoDaemonConfig;
agentManager: AgentManager;
agentRegistry: AgentRegistry;
- close: () => Promise;
-};
+ start(): Promise;
+ stop(): Promise;
+}
export async function createPaseoDaemon(
config: PaseoDaemonConfig
-): Promise {
+): Promise {
const agentMcpRoute = config.agentMcpRoute;
const basicAuthUsers = config.auth.basicUsers;
const staticDir = config.staticDir;
@@ -76,8 +106,38 @@ export async function createPaseoDaemon(
const downloadTokenStore = new DownloadTokenStore({ ttlMs: downloadTokenTtlMs });
+ const listenTarget = parseListenString(config.listen);
+
const app = express();
+ // CORS - allow same-origin + configured origins
+ const allowedOrigins = new Set([
+ ...config.corsAllowedOrigins,
+ // For TCP, add localhost variants
+ ...(listenTarget.type === "tcp"
+ ? [
+ `http://${listenTarget.host}:${listenTarget.port}`,
+ `http://localhost:${listenTarget.port}`,
+ `http://127.0.0.1:${listenTarget.port}`,
+ ]
+ : []),
+ ]);
+
+ app.use((req, res, next) => {
+ const origin = req.headers.origin;
+ if (origin && allowedOrigins.has(origin)) {
+ res.setHeader("Access-Control-Allow-Origin", origin);
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
+ res.setHeader("Access-Control-Allow-Credentials", "true");
+ }
+ if (req.method === "OPTIONS") {
+ res.status(204).end();
+ return;
+ }
+ next();
+ });
+
// Serve static files from public directory (no auth required for APK downloads)
app.use("/public", express.static(staticDir));
@@ -314,7 +374,8 @@ export async function createPaseoDaemon(
{
agentMcpUrl: config.agentControlMcp.url,
agentMcpHeaders: config.agentControlMcp.headers,
- }
+ },
+ { allowedOrigins }
);
const openaiApiKey = config.openai?.apiKey;
@@ -349,21 +410,56 @@ export async function createPaseoDaemon(
);
}
- const close = async () => {
+ const start = async () => {
+ await new Promise((resolve, reject) => {
+ const onError = (err: Error) => {
+ httpServer.off("listening", onListening);
+ reject(err);
+ };
+ const onListening = () => {
+ httpServer.off("error", onError);
+ if (listenTarget.type === "tcp") {
+ console.log(
+ `✓ Server listening on http://${listenTarget.host}:${listenTarget.port}`
+ );
+ } else {
+ console.log(`✓ Server listening on ${listenTarget.path}`);
+ }
+ resolve();
+ };
+ httpServer.once("error", onError);
+ httpServer.once("listening", onListening);
+
+ if (listenTarget.type === "tcp") {
+ httpServer.listen(listenTarget.port, listenTarget.host);
+ } else {
+ // Remove stale socket file if it exists
+ if (existsSync(listenTarget.path)) {
+ unlinkSync(listenTarget.path);
+ }
+ httpServer.listen(listenTarget.path);
+ }
+ });
+ };
+
+ const stop = async () => {
await closeAllAgents(agentManager);
await wsServer.close();
await new Promise((resolve) => {
httpServer.close(() => resolve());
});
+ // Clean up socket file
+ if (listenTarget.type === "socket" && existsSync(listenTarget.path)) {
+ unlinkSync(listenTarget.path);
+ }
};
return {
- httpServer,
- app,
- wsServer,
+ config,
agentManager,
agentRegistry,
- close,
+ start,
+ stop,
};
}
diff --git a/packages/server/src/server/config.ts b/packages/server/src/server/config.ts
index 5100eb89e..4da6e607a 100644
--- a/packages/server/src/server/config.ts
+++ b/packages/server/src/server/config.ts
@@ -1,46 +1,17 @@
-import os from "node:os";
import path from "node:path";
-import { mkdirSync } from "node:fs";
import type { PaseoDaemonConfig } from "./bootstrap.js";
import type { STTConfig } from "./agent/stt-openai.js";
import type { TTSConfig } from "./agent/tts-openai.js";
+import { loadPersistedConfig } from "./persisted-config.js";
-function expandHomeDir(input: string): string {
- if (input.startsWith("~/")) {
- return path.join(os.homedir(), input.slice(2));
- }
- if (input === "~") {
- return os.homedir();
- }
- return input;
-}
-
-export function readPaseoHomeFromEnv(
- env: NodeJS.ProcessEnv = process.env
-): string {
- const raw = env.PASEO_HOME ?? env.PASEO_HOME_DIR ?? "~/.paseo";
- const expanded = path.resolve(expandHomeDir(raw));
- mkdirSync(expanded, { recursive: true });
- return expanded;
-}
-
-export function readPaseoPortFromEnv(
- env: NodeJS.ProcessEnv = process.env
-): number {
- const raw = env.PASEO_PORT ?? env.PORT ?? "6767";
- const parsed = Number.parseInt(raw, 10);
- return Number.isFinite(parsed) ? parsed : 6767;
-}
-
+const DEFAULT_LISTEN = "127.0.0.1:6767";
const DEFAULT_BASIC_AUTH_USERS = { mo: "bo" } as const;
const DEFAULT_AGENT_MCP_ROUTE = "/mcp/agents";
-function readOpenAIConfigFromEnv(env: NodeJS.ProcessEnv = process.env) {
+function parseOpenAIConfig(env: NodeJS.ProcessEnv) {
const apiKey = env.OPENAI_API_KEY;
- if (!apiKey) {
- return undefined;
- }
+ if (!apiKey) return undefined;
const sttConfidenceThreshold = env.STT_CONFIDENCE_THRESHOLD
? parseFloat(env.STT_CONFIDENCE_THRESHOLD)
@@ -71,15 +42,34 @@ function readOpenAIConfigFromEnv(env: NodeJS.ProcessEnv = process.env) {
};
}
-export function buildPaseoDaemonConfigFromEnv(
+function getListenForMcp(listen: string): string {
+ // For Unix sockets, MCP still needs HTTP - use localhost
+ if (listen.startsWith("/") || listen.startsWith("~") || listen.includes(".sock")) {
+ return "127.0.0.1:6767";
+ }
+ if (listen.startsWith("unix://")) {
+ return "127.0.0.1:6767";
+ }
+ // TCP: extract host:port
+ if (listen.includes(":")) {
+ const [host, port] = listen.split(":");
+ return `${host || "127.0.0.1"}:${port}`;
+ }
+ // Just port
+ return `127.0.0.1:${listen}`;
+}
+
+export function loadConfig(
+ paseoHome: string,
env: NodeJS.ProcessEnv = process.env
): PaseoDaemonConfig {
- const paseoHome = readPaseoHomeFromEnv(env);
- const port = readPaseoPortFromEnv(env);
+ const persisted = loadPersistedConfig(paseoHome);
+
+ const listen = env.PASEO_LISTEN ?? persisted.listen ?? DEFAULT_LISTEN;
+ const mcpListen = getListenForMcp(listen);
const basicUsers = DEFAULT_BASIC_AUTH_USERS;
- const [agentMcpUser, agentMcpPassword] =
- Object.entries(basicUsers)[0] ?? [];
+ const [agentMcpUser, agentMcpPassword] = Object.entries(basicUsers)[0] ?? [];
const agentMcpAuthHeader =
agentMcpUser && agentMcpPassword
? `Basic ${Buffer.from(`${agentMcpUser}:${agentMcpPassword}`).toString("base64")}`
@@ -89,13 +79,12 @@ export function buildPaseoDaemonConfigFromEnv(
? Buffer.from(`${agentMcpUser}:${agentMcpPassword}`).toString("base64")
: undefined;
- const openai = readOpenAIConfigFromEnv(env);
-
return {
- port,
+ listen,
paseoHome,
+ corsAllowedOrigins: persisted.cors.allowedOrigins,
agentMcpRoute: DEFAULT_AGENT_MCP_ROUTE,
- agentMcpAllowedHosts: [`127.0.0.1:${port}`, `localhost:${port}`],
+ agentMcpAllowedHosts: [mcpListen, `localhost:${mcpListen.split(":")[1]}`],
auth: {
basicUsers,
agentMcpAuthHeader,
@@ -104,7 +93,7 @@ export function buildPaseoDaemonConfigFromEnv(
},
mcpDebug: env.MCP_DEBUG === "1",
agentControlMcp: {
- url: `http://127.0.0.1:${port}${DEFAULT_AGENT_MCP_ROUTE}`,
+ url: `http://${mcpListen}${DEFAULT_AGENT_MCP_ROUTE}`,
...(agentMcpAuthHeader
? { headers: { Authorization: agentMcpAuthHeader } }
: {}),
@@ -112,6 +101,6 @@ export function buildPaseoDaemonConfigFromEnv(
agentRegistryPath: path.join(paseoHome, "agents.json"),
staticDir: "public",
agentClients: {},
- openai,
+ openai: parseOpenAIConfig(env),
};
}
diff --git a/packages/server/src/server/index.ts b/packages/server/src/server/index.ts
index beba9ec77..0c2522c89 100644
--- a/packages/server/src/server/index.ts
+++ b/packages/server/src/server/index.ts
@@ -1,16 +1,15 @@
import "dotenv/config";
import { createPaseoDaemon } from "./bootstrap.js";
-import { buildPaseoDaemonConfigFromEnv } from "./config.js";
+import { loadConfig } from "./config.js";
+import { resolvePaseoHome } from "./paseo-home.js";
async function main() {
- const daemonConfig = buildPaseoDaemonConfigFromEnv();
- const daemon = await createPaseoDaemon(daemonConfig);
+ const paseoHome = resolvePaseoHome();
+ const config = loadConfig(paseoHome);
+ const daemon = await createPaseoDaemon(config);
+
+ await daemon.start();
- daemon.httpServer.listen(daemonConfig.port, () => {
- console.log(
- `\n✓ Voice Assistant server running on http://localhost:${daemonConfig.port}`
- );
- });
const handleShutdown = async (signal: string) => {
console.log(`\n${signal} received, shutting down gracefully...`);
@@ -20,7 +19,7 @@ async function main() {
}, 10000);
try {
- await daemon.close();
+ await daemon.stop();
clearTimeout(forceExit);
console.log("Server closed");
process.exit(0);
diff --git a/packages/server/src/server/paseo-home.ts b/packages/server/src/server/paseo-home.ts
new file mode 100644
index 000000000..02e159ec0
--- /dev/null
+++ b/packages/server/src/server/paseo-home.ts
@@ -0,0 +1,20 @@
+import os from "node:os";
+import path from "node:path";
+import { mkdirSync } from "node:fs";
+
+function expandHomeDir(input: string): string {
+ if (input.startsWith("~/")) {
+ return path.join(os.homedir(), input.slice(2));
+ }
+ if (input === "~") {
+ return os.homedir();
+ }
+ return input;
+}
+
+export function resolvePaseoHome(env: NodeJS.ProcessEnv = process.env): string {
+ const raw = env.PASEO_HOME ?? env.PASEO_HOME_DIR ?? "~/.paseo";
+ const resolved = path.resolve(expandHomeDir(raw));
+ mkdirSync(resolved, { recursive: true });
+ return resolved;
+}
diff --git a/packages/server/src/server/persisted-config.ts b/packages/server/src/server/persisted-config.ts
new file mode 100644
index 000000000..db6724747
--- /dev/null
+++ b/packages/server/src/server/persisted-config.ts
@@ -0,0 +1,79 @@
+import { existsSync, readFileSync, writeFileSync } from "node:fs";
+import path from "node:path";
+import { z } from "zod";
+
+const PersistedConfigSchema = z.object({
+ listen: z.string().optional(),
+ cors: z
+ .object({
+ allowedOrigins: z.array(z.string()).default([]),
+ })
+ .default({}),
+});
+
+export type PersistedConfig = z.infer;
+
+const CONFIG_FILENAME = "config.json";
+
+function getConfigPath(paseoHome: string): string {
+ return path.join(paseoHome, CONFIG_FILENAME);
+}
+
+export function loadPersistedConfig(paseoHome: string): PersistedConfig {
+ const configPath = getConfigPath(paseoHome);
+
+ if (!existsSync(configPath)) {
+ console.log(`[Config] No config file at ${configPath}, using defaults`);
+ return PersistedConfigSchema.parse({});
+ }
+
+ let raw: string;
+ try {
+ raw = readFileSync(configPath, "utf-8");
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ throw new Error(`[Config] Failed to read ${configPath}: ${message}`);
+ }
+
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(raw);
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ throw new Error(`[Config] Invalid JSON in ${configPath}: ${message}`);
+ }
+
+ const result = PersistedConfigSchema.safeParse(parsed);
+ if (!result.success) {
+ const issues = result.error.issues
+ .map((i) => ` - ${i.path.join(".")}: ${i.message}`)
+ .join("\n");
+ throw new Error(`[Config] Invalid config in ${configPath}:\n${issues}`);
+ }
+
+ console.log(`[Config] Loaded from ${configPath}`);
+ return result.data;
+}
+
+export function savePersistedConfig(
+ paseoHome: string,
+ config: PersistedConfig
+): void {
+ const configPath = getConfigPath(paseoHome);
+
+ const result = PersistedConfigSchema.safeParse(config);
+ if (!result.success) {
+ const issues = result.error.issues
+ .map((i) => ` - ${i.path.join(".")}: ${i.message}`)
+ .join("\n");
+ throw new Error(`[Config] Invalid config to save:\n${issues}`);
+ }
+
+ try {
+ writeFileSync(configPath, JSON.stringify(result.data, null, 2) + "\n");
+ console.log(`[Config] Saved to ${configPath}`);
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ throw new Error(`[Config] Failed to write ${configPath}: ${message}`);
+ }
+}
diff --git a/packages/server/src/server/test-utils/paseo-daemon.ts b/packages/server/src/server/test-utils/paseo-daemon.ts
index 8e2588192..f3c2c2da3 100644
--- a/packages/server/src/server/test-utils/paseo-daemon.ts
+++ b/packages/server/src/server/test-utils/paseo-daemon.ts
@@ -59,8 +59,9 @@ export async function createTestPaseoDaemon(
const port = await getAvailablePort();
const config: PaseoDaemonConfig = {
- port,
+ listen: `127.0.0.1:${port}`,
paseoHome,
+ corsAllowedOrigins: [],
agentMcpRoute: "/mcp/agents",
agentMcpAllowedHosts: [`127.0.0.1:${port}`, `localhost:${port}`],
auth: {
@@ -85,11 +86,10 @@ export async function createTestPaseoDaemon(
const daemon = await createPaseoDaemon(config);
try {
- await listenOnPort(daemon.httpServer, port);
+ await daemon.start();
const close = async (): Promise => {
- await daemon.close().catch(() => undefined);
- // Wait a bit for file handles to release
+ await daemon.stop().catch(() => undefined);
await new Promise((r) => setTimeout(r, 200));
await rm(paseoHome, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
await rm(staticDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
@@ -107,7 +107,7 @@ export async function createTestPaseoDaemon(
};
} catch (error) {
lastError = error;
- await daemon.close().catch(() => undefined);
+ await daemon.stop().catch(() => undefined);
await rm(paseoHome, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
await rm(staticDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
@@ -120,30 +120,6 @@ export async function createTestPaseoDaemon(
throw lastError ?? new Error("Failed to start test daemon");
}
-async function listenOnPort(server: net.Server, port: number): Promise {
- await new Promise((resolve, reject) => {
- const onError = (error: Error) => {
- server.off("listening", onListening);
- reject(error);
- };
- const onListening = () => {
- server.off("error", onError);
- resolve();
- };
-
- server.once("error", onError);
- server.once("listening", onListening);
-
- try {
- server.listen(port);
- } catch (error) {
- server.off("error", onError);
- server.off("listening", onListening);
- reject(error instanceof Error ? error : new Error("Failed to listen"));
- }
- });
-}
-
function isAddressInUseError(error: unknown): boolean {
if (!error || typeof error !== "object") {
return false;
diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts
index e9dddc325..eadad0613 100644
--- a/packages/server/src/server/websocket-server.ts
+++ b/packages/server/src/server/websocket-server.ts
@@ -22,6 +22,10 @@ type AgentMcpClientConfig = {
agentMcpHeaders?: Record;
};
+type WebSocketServerConfig = {
+ allowedOrigins: Set;
+};
+
/**
* WebSocket server that routes messages between clients and their sessions.
* This is a thin transport layer with no business logic.
@@ -43,7 +47,8 @@ export class VoiceAssistantWebSocketServer {
agentManager: AgentManager,
agentRegistry: AgentRegistry,
downloadTokenStore: DownloadTokenStore,
- agentMcpConfig: AgentMcpClientConfig
+ agentMcpConfig: AgentMcpClientConfig,
+ wsConfig: WebSocketServerConfig
) {
this.agentManager = agentManager;
this.agentRegistry = agentRegistry;
@@ -51,7 +56,23 @@ export class VoiceAssistantWebSocketServer {
this.pushTokenStore = new PushTokenStore();
this.pushService = new PushService(this.pushTokenStore);
this.agentMcpConfig = agentMcpConfig;
- this.wss = new WebSocketServer({ server, path: "/ws" });
+
+ const { allowedOrigins } = wsConfig;
+ this.wss = new WebSocketServer({
+ server,
+ path: "/ws",
+ verifyClient: ({ req }, callback) => {
+ const origin = req.headers.origin;
+ // Allow connections with no origin (native apps, curl, etc.)
+ // or from allowed origins (browsers)
+ if (!origin || allowedOrigins.has(origin)) {
+ callback(true);
+ } else {
+ console.warn(`[WebSocket] Rejected connection from origin: ${origin}`);
+ callback(false, 403, "Origin not allowed");
+ }
+ },
+ });
this.wss.on("connection", (ws, request) => {
this.handleConnection(ws, request);