diff --git a/.changeset/gentle-wolves-copy.md b/.changeset/gentle-wolves-copy.md
new file mode 100644
index 0000000..dd38389
--- /dev/null
+++ b/.changeset/gentle-wolves-copy.md
@@ -0,0 +1,5 @@
+---
+"@codex-relay/mobile": patch
+---
+
+Make the relay setup and phone approval commands selectable and copyable.
diff --git a/apps/mobile/src/components/chat/ChatScreen.tsx b/apps/mobile/src/components/chat/ChatScreen.tsx
index 77f952b..70a081d 100644
--- a/apps/mobile/src/components/chat/ChatScreen.tsx
+++ b/apps/mobile/src/components/chat/ChatScreen.tsx
@@ -50,6 +50,7 @@ import { SafeAreaView } from "react-native-safe-area-context";
import { StyleSheet } from "react-native-unistyles";
import { ThemedText } from "@/components/themed-text";
+import { CopyableCommand } from "@/components/ui/copyable-command";
import { AppToast } from "@/components/ui/toast";
import { Colors, Fonts, Spacing } from "@/constants/theme";
import { activeThreadAfterRefresh } from "@/lib/active-thread-selection";
@@ -152,6 +153,7 @@ import {
import { ChatShell } from "./ChatShell";
import type { ChatShellAction } from "./ChatShellHeader";
import { ConnectionBanner } from "./ConnectionBanner";
+import { approvalCommand } from "./pairing-commands";
import {
EXPANDED_DRAWER_BREAKPOINT,
THREE_PANE_LAYOUT_BREAKPOINT,
@@ -2418,13 +2420,17 @@ export function ChatScreen({ initialPairingUrl }: ChatScreenProps = {}) {
{pasteApprovalCode ? (
Approval code
- {pasteApprovalCode}
+
+ {pasteApprovalCode}
+
Run this on your computer:
-
- {approvalCommand(pasteApprovalCode, pasteApprovalServerUrl)}
-
+
) : null}
@@ -2811,29 +2817,10 @@ function delay(ms: number) {
});
}
-function approvalCommand(approvalCode: string, serverUrl?: string) {
- const port = approvalPort(serverUrl);
- return port && port !== "8787"
- ? `PORT=${port} npx codex-relay@latest approve ${approvalCode}`
- : `npx codex-relay@latest approve ${approvalCode}`;
-}
-
function approvalMessage(approvalCode: string, serverUrl?: string) {
return `Run ${approvalCommand(approvalCode, serverUrl)} in the server terminal.`;
}
-function approvalPort(serverUrl?: string) {
- if (!serverUrl) {
- return undefined;
- }
-
- try {
- return new URL(serverUrl).port;
- } catch {
- return undefined;
- }
-}
-
function readScannedPayload(result: unknown) {
if (!result || typeof result !== "object") {
return undefined;
diff --git a/apps/mobile/src/components/chat/ConnectionBanner.tsx b/apps/mobile/src/components/chat/ConnectionBanner.tsx
index be502e1..318aec2 100644
--- a/apps/mobile/src/components/chat/ConnectionBanner.tsx
+++ b/apps/mobile/src/components/chat/ConnectionBanner.tsx
@@ -4,10 +4,12 @@ import { StyleSheet } from "react-native-unistyles";
import { ThemedText } from "@/components/themed-text";
import { Button } from "@/components/ui/button";
+import { CopyableCommand } from "@/components/ui/copyable-command";
import { Icon } from "@/components/ui/icon";
-import { Fonts } from "@/constants/theme";
import { workspaceName } from "@/lib/workspace-name";
+import { relayStartCommand } from "./pairing-commands";
+
const tailscaleAppStoreUrl = "https://apps.apple.com/us/app/tailscale/id1470499037";
export function ConnectionBanner({
@@ -113,7 +115,7 @@ export function ConnectionBanner({
label="1"
title="Start the relay"
body="Open Terminal on your computer and run:"
- command="npx codex-relay@latest"
+ command={relayStartCommand}
/>
{command ? (
-
-
- {command}
-
-
+
) : null}
{actionLabel && onAction ? (
{
+ it("uses the latest relay package for setup and default-port approval", () => {
+ expect(relayStartCommand).toBe("npx codex-relay@latest");
+ expect(approvalCommand("ABCD-1234")).toBe("npx codex-relay@latest approve ABCD-1234");
+ expect(approvalCommand("ABCD-1234", "http://192.168.1.4:8787")).toBe(
+ "npx codex-relay@latest approve ABCD-1234",
+ );
+ });
+
+ it("includes a non-default relay port in the approval command", () => {
+ expect(approvalCommand("ABCD-1234", "http://192.168.1.4:9123")).toBe(
+ "PORT=9123 npx codex-relay@latest approve ABCD-1234",
+ );
+ });
+
+ it("falls back to the default command for an invalid server URL", () => {
+ expect(approvalCommand("ABCD-1234", "not a URL")).toBe(
+ "npx codex-relay@latest approve ABCD-1234",
+ );
+ });
+});
diff --git a/apps/mobile/src/components/chat/pairing-commands.ts b/apps/mobile/src/components/chat/pairing-commands.ts
new file mode 100644
index 0000000..d60b76c
--- /dev/null
+++ b/apps/mobile/src/components/chat/pairing-commands.ts
@@ -0,0 +1,20 @@
+export const relayStartCommand = "npx codex-relay@latest";
+
+export function approvalCommand(approvalCode: string, serverUrl?: string) {
+ const port = approvalPort(serverUrl);
+ return port && port !== "8787"
+ ? `PORT=${port} ${relayStartCommand} approve ${approvalCode}`
+ : `${relayStartCommand} approve ${approvalCode}`;
+}
+
+function approvalPort(serverUrl?: string) {
+ if (!serverUrl) {
+ return undefined;
+ }
+
+ try {
+ return new URL(serverUrl).port;
+ } catch {
+ return undefined;
+ }
+}
diff --git a/apps/mobile/src/components/ui/copyable-command.tsx b/apps/mobile/src/components/ui/copyable-command.tsx
new file mode 100644
index 0000000..04378bc
--- /dev/null
+++ b/apps/mobile/src/components/ui/copyable-command.tsx
@@ -0,0 +1,129 @@
+import * as Clipboard from "expo-clipboard";
+import { useCallback, useEffect, useState } from "react";
+import {
+ Alert,
+ Pressable,
+ View,
+ type StyleProp,
+ type TextStyle,
+ type ViewStyle,
+} from "react-native";
+import { StyleSheet } from "react-native-unistyles";
+
+import { ThemedText } from "@/components/themed-text";
+import { Icon } from "@/components/ui/icon";
+import { Fonts } from "@/constants/theme";
+import { hapticSelection } from "@/lib/haptics";
+
+const copiedIndicatorDurationMs = 1400;
+
+export function CopyableCommand({
+ command,
+ copyAccessibilityLabel = "Copy command",
+ style,
+ textStyle,
+}: {
+ command: string;
+ copyAccessibilityLabel?: string;
+ style?: StyleProp;
+ textStyle?: StyleProp;
+}) {
+ const [isCopied, setCopied] = useState(false);
+ const handleCopyPress = useCallback(() => {
+ hapticSelection();
+ setCopied(true);
+ Clipboard.setStringAsync(command).catch((error: unknown) => {
+ setCopied(false);
+ Alert.alert("Copy failed", copyFailureMessage(error));
+ });
+ }, [command]);
+
+ useEffect(() => {
+ if (!isCopied) {
+ return;
+ }
+
+ const timer = setTimeout(() => setCopied(false), copiedIndicatorDurationMs);
+ return () => clearTimeout(timer);
+ }, [isCopied]);
+
+ return (
+
+
+ {command}
+
+ [
+ styles.copyButton,
+ isCopied && styles.copyButtonCopied,
+ pressed && styles.pressed,
+ ]}
+ >
+
+
+ {isCopied ? "Copied" : "Copy"}
+
+
+
+ );
+}
+
+function copyFailureMessage(error: unknown) {
+ return error instanceof Error ? error.message : "The command could not be copied.";
+}
+
+const styles = StyleSheet.create({
+ container: {
+ alignItems: "center",
+ backgroundColor: "rgba(0, 0, 0, 0.24)",
+ borderColor: "rgba(255, 255, 255, 0.08)",
+ borderRadius: 7,
+ borderWidth: 1,
+ flexDirection: "row",
+ gap: 8,
+ minWidth: 0,
+ paddingBottom: 5,
+ paddingLeft: 9,
+ paddingRight: 5,
+ paddingTop: 5,
+ },
+ commandText: {
+ flex: 1,
+ fontFamily: Fonts.mono,
+ fontSize: 12,
+ lineHeight: 16,
+ minWidth: 0,
+ },
+ copyButton: {
+ alignItems: "center",
+ alignSelf: "stretch",
+ backgroundColor: "rgba(255, 255, 255, 0.08)",
+ borderColor: "rgba(255, 255, 255, 0.1)",
+ borderRadius: 6,
+ borderWidth: 1,
+ flexDirection: "row",
+ gap: 4,
+ justifyContent: "center",
+ minHeight: 30,
+ minWidth: 68,
+ paddingHorizontal: 7,
+ paddingVertical: 5,
+ },
+ copyButtonCopied: {
+ backgroundColor: "rgba(86, 196, 134, 0.16)",
+ borderColor: "rgba(86, 196, 134, 0.3)",
+ },
+ copyButtonText: {
+ color: "#F2F2F2",
+ fontSize: 11,
+ lineHeight: 14,
+ },
+ pressed: {
+ opacity: 0.7,
+ },
+});