OpenCode Integration commited on
Commit
2c19166
·
1 Parent(s): fc72de6

feat: copy all files from Opencode-Cli

Browse files
Dockerfile ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM debian:bookworm-slim
2
+
3
+ # /data is mounted as a persistent HF dataset bucket (Jishnupg/Opencode-Cli-storage)
4
+ # All opencode data (sessions, DB, config) goes here so it survives container restarts
5
+ ENV XDG_DATA_HOME=/data/share
6
+ ENV XDG_CONFIG_HOME=/data/config
7
+ ENV XDG_CACHE_HOME=/data/cache
8
+ ENV XDG_STATE_HOME=/data/state
9
+
10
+ ARG OPENCODE_VERSION=1.18.3
11
+
12
+ # Install dependencies (including git for project initialization and python3 for DB self-healing)
13
+ RUN apt-get update && apt-get install -y --no-install-recommends \
14
+ curl \
15
+ ca-certificates \
16
+ git \
17
+ python3 \
18
+ && rm -rf /var/lib/apt/lists/*
19
+
20
+ # Download opencode binary
21
+ RUN curl -fsSL "https://github.com/anomalyco/opencode/releases/download/v${OPENCODE_VERSION}/opencode-linux-x64.tar.gz" \
22
+ | tar -xz -C /usr/local/bin opencode
23
+
24
+ # Working directory for projects
25
+ RUN mkdir -p /projects/default
26
+
27
+ COPY cleaner.py /cleaner.py
28
+ COPY entrypoint.sh /entrypoint.sh
29
+ RUN chmod +x /entrypoint.sh
30
+
31
+ WORKDIR /projects/default
32
+
33
+ EXPOSE 4096
34
+
35
+ ENTRYPOINT ["/entrypoint.sh"]
README.md ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Opencode CLI
3
+ emoji: 🖥️
4
+ colorFrom: green
5
+ colorTo: blue
6
+ sdk: docker
7
+ app_port: 4096
8
+ pinned: false
9
+ ---
10
+
11
+ # Opencode CLI Server
12
+
13
+ This Hugging Face Space hosts the backend `opencode serve` API server on port 4096.
14
+
15
+ ## Connection Setup for Mobile App
16
+
17
+ To connect your OpenCode mobile client to this server:
18
+ 1. Open the mobile app and go to **Settings**.
19
+ 2. Under **Server URL**, enter the Space's direct domain:
20
+ `https://jishnupg-opencode-cli.hf.space` (or your corresponding Space URL)
21
+ 3. Set your **Auth Type** and credentials if you configured `OPENCODE_SERVER_USERNAME`/`OPENCODE_SERVER_PASSWORD` secrets.
cleaner.py ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sqlite3
2
+ import time
3
+ import os
4
+ from datetime import datetime
5
+
6
+ def log(msg):
7
+ print(f"[{datetime.now().strftime('%H:%M:%S')}] {msg}", flush=True)
8
+
9
+ log("OpenCode Self-Healing Daemon: started")
10
+
11
+ # Priority order: /data is the persistent HF bucket mount
12
+ DB_PATHS = [
13
+ "/data/share/opencode/opencode.db",
14
+ "/projects/.opencode/share/opencode/opencode.db",
15
+ "/root/.local/share/opencode/opencode.db",
16
+ "/home/opencode/.local/share/opencode/opencode.db",
17
+ ]
18
+
19
+ def is_corrupted(s):
20
+ """Check if a string contains Unicode replacement characters from bad base64 decoding."""
21
+ return s and ("\ufffd" in s or "??#y" in s or "\xef\xbf\xbd" in s)
22
+
23
+ cycle = 0
24
+ while True:
25
+ time.sleep(3)
26
+ cycle += 1
27
+
28
+ for db_path in DB_PATHS:
29
+ if not os.path.exists(db_path):
30
+ continue
31
+ try:
32
+ conn = sqlite3.connect(db_path, timeout=5)
33
+ cursor = conn.cursor()
34
+
35
+ # Check if session table exists
36
+ cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='session'")
37
+ if not cursor.fetchone():
38
+ conn.close()
39
+ continue
40
+
41
+
42
+ # Log session count periodically
43
+ if cycle % 20 == 0: # Every ~60 seconds
44
+ cursor.execute("SELECT COUNT(*) FROM session")
45
+ count = cursor.fetchone()[0]
46
+ log(f"DB {db_path}: {count} sessions")
47
+
48
+ # Fix corrupted directory/path in session table
49
+ cursor.execute("SELECT id, directory, path FROM session")
50
+ for row_id, directory, path in cursor.fetchall():
51
+ need_update = False
52
+ new_directory = directory
53
+ new_path = path
54
+
55
+ if is_corrupted(directory):
56
+ new_directory = "/projects/default"
57
+ need_update = True
58
+
59
+ if is_corrupted(path):
60
+ new_path = None
61
+ need_update = True
62
+
63
+ if need_update:
64
+ log(f"[Healed] Session '{row_id}': dir={directory!r} -> {new_directory!r}, path={path!r} -> {new_path!r}")
65
+ cursor.execute(
66
+ "UPDATE session SET directory = ?, path = ? WHERE id = ?",
67
+ (new_directory, new_path, row_id)
68
+ )
69
+ conn.commit()
70
+
71
+ # Fix corrupted entries in project_directory table
72
+ cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='project_directory'")
73
+ if cursor.fetchone():
74
+ cursor.execute("SELECT project_id, directory FROM project_directory")
75
+ for p_id, directory in cursor.fetchall():
76
+ if is_corrupted(directory):
77
+ log(f"[Healed] project_directory '{p_id}': deleting corrupted dir={directory!r}")
78
+ cursor.execute(
79
+ "DELETE FROM project_directory WHERE project_id = ? AND directory = ?",
80
+ (p_id, directory)
81
+ )
82
+ conn.commit()
83
+
84
+ conn.close()
85
+ except sqlite3.OperationalError:
86
+ pass # DB locked by opencode, retry next cycle
87
+ except Exception as e:
88
+ log(f"[Healer Error] {db_path}: {e}")
docker-compose.yml ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ version: "3.8"
2
+
3
+ services:
4
+ opencode:
5
+ build: .
6
+ ports:
7
+ - "4096:4096"
8
+ environment:
9
+ # Set your API keys here
10
+ - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
11
+ - OPENAI_API_KEY=${OPENAI_API_KEY:-}
12
+ # Optional: enable HTTP basic auth
13
+ - OPENCODE_SERVER_USERNAME=${OPENCODE_SERVER_USERNAME:-}
14
+ - OPENCODE_SERVER_PASSWORD=${OPENCODE_SERVER_PASSWORD:-}
15
+ volumes:
16
+ # Mount a directory for projects to persist data
17
+ - opencode-data:/projects
18
+ restart: unless-stopped
19
+ healthcheck:
20
+ test: ["CMD", "curl", "-f", "http://localhost:4096/api/health"]
21
+ interval: 30s
22
+ timeout: 5s
23
+ retries: 3
24
+
25
+ volumes:
26
+ opencode-data:
entrypoint.sh ADDED
@@ -0,0 +1,155 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/sh
2
+
3
+ echo "============================================"
4
+ echo "=== OpenCode Space starting up ==="
5
+ echo "Time: $(date)"
6
+ echo "============================================"
7
+
8
+ # Configure Git to trust all directories to prevent ownership errors
9
+ git config --global --add safe.directory '*' 2>/dev/null || true
10
+
11
+ # /data is the persistent HF dataset bucket mount
12
+ echo "[INIT] Setting up /data directories..."
13
+ mkdir -p /data/share/opencode 2>/dev/null || echo "[WARN] Could not create /data/share/opencode"
14
+ mkdir -p /data/config/opencode 2>/dev/null || echo "[WARN] Could not create /data/config/opencode"
15
+ mkdir -p /data/cache/opencode 2>/dev/null || echo "[WARN] Could not create /data/cache/opencode"
16
+ mkdir -p /data/state/opencode 2>/dev/null || echo "[WARN] Could not create /data/state/opencode"
17
+
18
+ # Ensure config exists with correct model setting
19
+ # Model IDs MUST use "provider/model" format per OpenCode docs (e.g. "opencode/big-pickle")
20
+ echo "[CONFIG] Setting up default configuration..."
21
+
22
+ # Remove stale config that may have wrong model format
23
+ python3 -c "
24
+ import json, os
25
+ p = '/data/config/opencode/opencode.json'
26
+ stale_models = ['big-pickle', 'mimo-v2.5-free', 'opencode/mimo-v2.5-free']
27
+ try:
28
+ d = json.load(open(p)) if os.path.exists(p) else {}
29
+ if d.get('model') in stale_models:
30
+ print(f'[CONFIG] Removing stale model {d[\"model\"]!r}, will regenerate')
31
+ del d['model']
32
+ json.dump(d, open(p, 'w'), indent=2)
33
+ except Exception as e:
34
+ print(f'[CONFIG] Error normalizing: {e}')
35
+ " 2>/dev/null || true
36
+
37
+ python3 -c "
38
+ import json, os
39
+ p = '/data/config/opencode/opencode.json'
40
+ try:
41
+ d = json.load(open(p)) if os.path.exists(p) else {}
42
+ except Exception:
43
+ d = {}
44
+ d['\$schema'] = 'https://opencode.ai/config.json'
45
+ d['server'] = d.get('server', {'port': 4096, 'hostname': '0.0.0.0'})
46
+ # Model IDs require provider/model format: 'opencode/big-pickle' for Zen free models
47
+ if not os.environ.get('ANTHROPIC_API_KEY') and not os.environ.get('OPENAI_API_KEY'):
48
+ # Use opencode Zen free model with correct provider prefix
49
+ d['model'] = 'opencode/big-pickle'
50
+ elif not d.get('model'):
51
+ d['model'] = 'opencode/big-pickle'
52
+ json.dump(d, open(p, 'w'), indent=2)
53
+ print('[CONFIG] Wrote config with model:', d.get('model'))
54
+ " 2>/dev/null || true
55
+ echo "[CONFIG] Current configuration:"
56
+ cat /data/config/opencode/opencode.json 2>/dev/null || echo "{}"
57
+
58
+ # Log disk space
59
+ echo "[DISK] /data usage:"
60
+ df -h /data 2>/dev/null || echo "[WARN] Could not check /data disk space"
61
+
62
+ # Check existing database
63
+ echo "[DB] Checking database..."
64
+ DB_PATH="/data/share/opencode/opencode.db"
65
+ if [ -f "$DB_PATH" ]; then
66
+ echo "[DB] Found database at $DB_PATH ($(du -h "$DB_PATH" | cut -f1))"
67
+ # Count sessions
68
+ python3 -c "
69
+ import sqlite3
70
+ try:
71
+ conn = sqlite3.connect('$DB_PATH', timeout=5)
72
+ c = conn.cursor()
73
+ # List all tables
74
+ c.execute(\"SELECT name FROM sqlite_master WHERE type='table'\")
75
+ tables = [r[0] for r in c.fetchall()]
76
+ print(f'[DB] Tables: {tables}')
77
+ if 'session' in tables:
78
+ c.execute('PRAGMA table_info(session)')
79
+ cols = [r[1] for r in c.fetchall()]
80
+ print(f'[DB] Session columns: {cols}')
81
+ c.execute('SELECT COUNT(*) FROM session')
82
+ count = c.fetchone()[0]
83
+ print(f'[DB] Total sessions: {count}')
84
+ c.execute('SELECT id, title, directory FROM session LIMIT 5')
85
+ for row in c.fetchall():
86
+ print(f'[DB] Session: {row[0]} title={row[1]!r} dir={row[2]!r}')
87
+ conn.close()
88
+ except Exception as e:
89
+ print(f'[DB] Error: {e}')
90
+ " 2>/dev/null || echo "[DB] Could not read database"
91
+ else
92
+ echo "[DB] No database found"
93
+ fi
94
+
95
+ # Start the SQLite self-healing daemon in the background
96
+ echo "[INIT] Starting self-healing daemon..."
97
+ python3 /cleaner.py &
98
+
99
+ # Always ensure /projects/default exists
100
+ mkdir -p /projects/default
101
+
102
+ # Navigate to the default projects directory
103
+ cd /projects/default
104
+
105
+ # If GITHUB_REPO is not set, use the default OpenCode Drive repo
106
+ GITHUB_REPO="${GITHUB_REPO:-https://github.com/JishnuPG-tech/OpenCode-Drive.git}"
107
+
108
+ # Check current state
109
+ HAS_GIT=$(test -d ".git" && echo "yes" || echo "no")
110
+ HAS_REMOTE=$(git remote -v 2>/dev/null | grep -c origin || echo "0")
111
+ IS_EMPTY=$(test -z "$(ls -A . 2>/dev/null | grep -v '^\.')" && echo "yes" || echo "no")
112
+
113
+ echo "[GIT] State: has_git=$HAS_GIT has_remote=$HAS_REMOTE is_empty=$IS_EMPTY"
114
+
115
+ if [ "$HAS_GIT" = "no" ] && [ "$IS_EMPTY" = "yes" ]; then
116
+ echo "[GIT] Empty directory. Cloning $GITHUB_REPO ..."
117
+ git clone "$GITHUB_REPO" . 2>&1 || echo "[GIT] Clone FAILED"
118
+ elif [ "$HAS_GIT" = "yes" ] && [ "$HAS_REMOTE" -gt "0" ]; then
119
+ echo "[GIT] Repo exists. Pulling latest..."
120
+ git pull origin HEAD 2>/dev/null || echo "[GIT] Pull failed"
121
+ else
122
+ echo "[GIT] Using existing files."
123
+ fi
124
+
125
+ # Final safety: ensure a git repo exists
126
+ if [ ! -d .git ]; then
127
+ echo "[GIT] No .git found. Initializing..."
128
+ git init
129
+ git config user.email 'opencode@local.com'
130
+ git config user.name 'OpenCode'
131
+ git commit --allow-empty -m 'Initial commit'
132
+ fi
133
+
134
+ echo "[GIT] Files: $(ls -1 . 2>/dev/null | grep -v '^\.git$' | tr '\n' ', ')"
135
+
136
+ # Log key env vars
137
+ echo "[ENV] API keys: ANTHROPIC=$(if [ -n \"$ANTHROPIC_API_KEY\" ]; then echo SET; else echo NOT_SET; fi) OPENAI=$(if [ -n \"$OPENAI_API_KEY\" ]; then echo SET; else echo NOT_SET; fi)"
138
+ echo "[ENV] Auth: USER=$(if [ -n \"$OPENCODE_SERVER_USERNAME\" ]; then echo SET; else echo NOT_SET; fi) PASS=$(if [ -n \"$OPENCODE_SERVER_PASSWORD\" ]; then echo SET; else echo NOT_SET; fi)"
139
+
140
+ # Test connectivity to OpenCode Zen API
141
+ echo "[NET] Testing connectivity to opencode.ai/zen..."
142
+ ZEN_STATUS=$(curl -s -o /dev/null -w "%{http_code}" --connect-timeout 5 "https://opencode.ai/zen/v1/models" 2>/dev/null || echo "000")
143
+ echo "[NET] OpenCode Zen API status: $ZEN_STATUS"
144
+
145
+ if [ "$ZEN_STATUS" = "200" ]; then
146
+ echo "[NET] OpenCode Zen API is reachable"
147
+ else
148
+ echo "[NET] WARNING: OpenCode Zen API may not be reachable from this container"
149
+ echo "[NET] This means free models may not work - AI responses will fail silently"
150
+ fi
151
+
152
+ echo "============================================"
153
+ echo "=== Launching OpenCode server ==="
154
+ echo "============================================"
155
+ exec opencode serve --port 4096 --hostname 0.0.0.0
mobile/app.json ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "expo": {
3
+ "name": "OpenCode Mobile",
4
+ "slug": "opencode-mobile",
5
+ "version": "1.0.0",
6
+ "orientation": "portrait",
7
+ "scheme": "opencode",
8
+ "platforms": ["android"],
9
+ "android": {
10
+ "package": "com.opencode.mobile",
11
+ "adaptiveIcon": {
12
+ "backgroundColor": "#1a1b26"
13
+ }
14
+ },
15
+ "plugins": [
16
+ "expo-asset",
17
+ "expo-font",
18
+ "expo-router",
19
+ "expo-secure-store"
20
+ ]
21
+ }
22
+ }
mobile/app/(tabs)/_layout.tsx ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Tabs } from "expo-router";
2
+ import { Home, MessageSquare, FolderOpen, Search, Terminal } from "lucide-react-native";
3
+ import { getTheme } from "../../lib/storage";
4
+ import { themes } from "../../constants/themes";
5
+
6
+ export default function TabLayout() {
7
+ const theme = themes[getTheme()];
8
+
9
+ const icons = {
10
+ index: Home,
11
+ sessions: MessageSquare,
12
+ files: FolderOpen,
13
+ search: Search,
14
+ terminal: Terminal,
15
+ };
16
+
17
+ return (
18
+ <Tabs
19
+ screenOptions={{
20
+ headerShown: false,
21
+ tabBarStyle: {
22
+ backgroundColor: theme.colors.bgSecondary,
23
+ borderTopColor: theme.colors.border,
24
+ height: 60,
25
+ paddingBottom: 8,
26
+ paddingTop: 8,
27
+ },
28
+ tabBarActiveTintColor: theme.colors.primary,
29
+ tabBarInactiveTintColor: theme.colors.textMuted,
30
+ }}
31
+ >
32
+ <Tabs.Screen
33
+ name="index"
34
+ options={{
35
+ title: "Home",
36
+ tabBarIcon: ({ color, size }) => <icons.index size={size} color={color} />,
37
+ }}
38
+ />
39
+ <Tabs.Screen
40
+ name="sessions"
41
+ options={{
42
+ title: "Sessions",
43
+ tabBarIcon: ({ color, size }) => <icons.sessions size={size} color={color} />,
44
+ }}
45
+ />
46
+ <Tabs.Screen
47
+ name="files"
48
+ options={{
49
+ title: "Files",
50
+ tabBarIcon: ({ color, size }) => <icons.files size={size} color={color} />,
51
+ }}
52
+ />
53
+ <Tabs.Screen
54
+ name="search"
55
+ options={{
56
+ title: "Search",
57
+ tabBarIcon: ({ color, size }) => <icons.search size={size} color={color} />,
58
+ }}
59
+ />
60
+ <Tabs.Screen
61
+ name="terminal"
62
+ options={{
63
+ title: "Terminal",
64
+ tabBarIcon: ({ color, size }) => <icons.terminal size={size} color={color} />,
65
+ }}
66
+ />
67
+ </Tabs>
68
+ );
69
+ }
mobile/app/(tabs)/files.tsx ADDED
@@ -0,0 +1,153 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { View, Text, TextInput, TouchableOpacity, FlatList, StyleSheet } from "react-native";
2
+ import { useState } from "react";
3
+ import { useSafeAreaInsets } from "react-native-safe-area-context";
4
+ import { Search, ChevronRight, Folder, File, ArrowLeft } from "lucide-react-native";
5
+ import { useListFiles } from "../../hooks/useApi";
6
+ import { getTheme } from "../../lib/storage";
7
+ import { themes } from "../../constants/themes";
8
+
9
+ export default function FilesScreen() {
10
+ const insets = useSafeAreaInsets();
11
+ const theme = themes[getTheme()];
12
+ const [currentDir, setCurrentDir] = useState(".");
13
+ const [search, setSearch] = useState("");
14
+ const { data: files, isLoading } = useListFiles(currentDir);
15
+
16
+ const filtered = (files || []).filter(
17
+ (f) => !search || f.name.toLowerCase().includes(search.toLowerCase())
18
+ );
19
+
20
+ const dirs = currentDir === "." ? [] : currentDir.split("/");
21
+ const goBack = () => {
22
+ if (dirs.length <= 1) {
23
+ setCurrentDir(".");
24
+ } else {
25
+ setCurrentDir(dirs.slice(0, -1).join("/"));
26
+ }
27
+ };
28
+
29
+ return (
30
+ <View style={[styles.container, { backgroundColor: theme.colors.bg }]}>
31
+ <View style={[styles.header, { paddingTop: insets.top + 12 }]}>
32
+ {currentDir !== "." && (
33
+ <TouchableOpacity onPress={goBack} style={styles.backBtn}>
34
+ <ArrowLeft size={20} color={theme.colors.text} />
35
+ </TouchableOpacity>
36
+ )}
37
+ <Text style={[styles.title, { color: theme.colors.text }]} numberOfLines={1}>
38
+ {currentDir === "." ? "Files" : dirs[dirs.length - 1]}
39
+ </Text>
40
+ </View>
41
+
42
+ <View style={styles.breadcrumb}>
43
+ {dirs.map((d, i) => (
44
+ <TouchableOpacity
45
+ key={i}
46
+ onPress={() => setCurrentDir(dirs.slice(0, i + 1).join("/"))}
47
+ style={styles.breadcrumbItem}
48
+ >
49
+ <Text style={[styles.breadcrumbText, { color: theme.colors.primary }]}>{d}</Text>
50
+ <ChevronRight size={12} color={theme.colors.textMuted} />
51
+ </TouchableOpacity>
52
+ ))}
53
+ {dirs.length === 0 && (
54
+ <Text style={[styles.breadcrumbText, { color: theme.colors.textMuted }]}>root</Text>
55
+ )}
56
+ </View>
57
+
58
+ <View style={[styles.searchBar, { backgroundColor: theme.colors.surface, borderColor: theme.colors.border }]}>
59
+ <Search size={16} color={theme.colors.textMuted} />
60
+ <TextInput
61
+ style={[styles.searchInput, { color: theme.colors.text }]}
62
+ placeholder="Filter files..."
63
+ placeholderTextColor={theme.colors.textMuted}
64
+ value={search}
65
+ onChangeText={setSearch}
66
+ />
67
+ </View>
68
+
69
+ <FlatList
70
+ data={filtered}
71
+ keyExtractor={(item) => item.path}
72
+ contentContainerStyle={{ paddingBottom: insets.bottom + 100, paddingHorizontal: 20 }}
73
+ showsVerticalScrollIndicator={false}
74
+ ListEmptyComponent={
75
+ <View style={styles.empty}>
76
+ <Folder size={40} color={theme.colors.textMuted} />
77
+ <Text style={[styles.emptyText, { color: theme.colors.textMuted }]}>
78
+ {isLoading ? "Loading..." : "Empty"}
79
+ </Text>
80
+ </View>
81
+ }
82
+ renderItem={({ item }) => (
83
+ <TouchableOpacity
84
+ style={[styles.fileItem, { backgroundColor: theme.colors.surface }]}
85
+ onPress={() => {
86
+ if (item.type === "directory") {
87
+ setCurrentDir(item.path);
88
+ }
89
+ }}
90
+ >
91
+ {item.type === "directory" ? (
92
+ <Folder size={18} color={theme.colors.warning} />
93
+ ) : (
94
+ <File size={18} color={theme.colors.textMuted} />
95
+ )}
96
+ <Text style={[styles.fileName, { color: theme.colors.text }]} numberOfLines={1}>
97
+ {item.name}
98
+ </Text>
99
+ {item.type === "directory" && (
100
+ <ChevronRight size={16} color={theme.colors.textMuted} />
101
+ )}
102
+ </TouchableOpacity>
103
+ )}
104
+ />
105
+ </View>
106
+ );
107
+ }
108
+
109
+ const styles = StyleSheet.create({
110
+ container: { flex: 1 },
111
+ header: {
112
+ flexDirection: "row",
113
+ alignItems: "center",
114
+ gap: 10,
115
+ paddingHorizontal: 20,
116
+ paddingBottom: 12,
117
+ },
118
+ backBtn: { padding: 4 },
119
+ title: { fontSize: 28, fontWeight: "700", letterSpacing: -0.5, flex: 1 },
120
+ breadcrumb: {
121
+ flexDirection: "row",
122
+ alignItems: "center",
123
+ flexWrap: "wrap",
124
+ paddingHorizontal: 20,
125
+ marginBottom: 12,
126
+ gap: 4,
127
+ },
128
+ breadcrumbItem: { flexDirection: "row", alignItems: "center", gap: 4 },
129
+ breadcrumbText: { fontSize: 13 },
130
+ searchBar: {
131
+ flexDirection: "row",
132
+ alignItems: "center",
133
+ gap: 8,
134
+ marginHorizontal: 20,
135
+ marginBottom: 16,
136
+ paddingHorizontal: 14,
137
+ paddingVertical: 10,
138
+ borderRadius: 10,
139
+ borderWidth: 1,
140
+ },
141
+ searchInput: { flex: 1, fontSize: 14, padding: 0 },
142
+ fileItem: {
143
+ flexDirection: "row",
144
+ alignItems: "center",
145
+ gap: 10,
146
+ padding: 12,
147
+ borderRadius: 10,
148
+ marginBottom: 4,
149
+ },
150
+ fileName: { flex: 1, fontSize: 14 },
151
+ empty: { alignItems: "center", marginTop: 80, gap: 12 },
152
+ emptyText: { fontSize: 14 },
153
+ });
mobile/app/(tabs)/index.tsx ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { View, Text, TouchableOpacity, ScrollView, StyleSheet } from "react-native";
2
+ import { useRouter } from "expo-router";
3
+ import { useSafeAreaInsets } from "react-native-safe-area-context";
4
+ import { Plus, Wifi, WifiOff, Settings } from "lucide-react-native";
5
+ import { useSessions, useCreateSession } from "../../hooks/useApi";
6
+ import { useConnection } from "../../hooks/useConnection";
7
+ import { getTheme } from "../../lib/storage";
8
+ import { themes } from "../../constants/themes";
9
+
10
+ export default function HomeScreen() {
11
+ const router = useRouter();
12
+ const insets = useSafeAreaInsets();
13
+ const theme = themes[getTheme()];
14
+ const { isConnected, state: connState } = useConnection();
15
+ const { data: sessions } = useSessions();
16
+ const createSession = useCreateSession();
17
+
18
+ const handleNewSession = async () => {
19
+ try {
20
+ const session = await createSession.mutateAsync({ title: "New Chat", directory: "default" });
21
+ router.push(`/session/${session.id}`);
22
+ } catch {}
23
+ };
24
+
25
+ const recentSessions = (sessions || []).slice(0, 5);
26
+
27
+ return (
28
+ <View style={[styles.container, { backgroundColor: theme.colors.bg }]}>
29
+ <View style={[styles.header, { paddingTop: insets.top + 12 }]}>
30
+ <Text style={[styles.title, { color: theme.colors.text }]}>OpenCode</Text>
31
+ <TouchableOpacity onPress={() => router.push("/settings")}>
32
+ <Settings size={22} color={theme.colors.textMuted} />
33
+ </TouchableOpacity>
34
+ </View>
35
+
36
+ <ScrollView
37
+ style={styles.scroll}
38
+ contentContainerStyle={{ paddingBottom: insets.bottom + 100 }}
39
+ showsVerticalScrollIndicator={false}
40
+ >
41
+ <View
42
+ style={[
43
+ styles.statusCard,
44
+ {
45
+ backgroundColor: isConnected ? theme.colors.bgTertiary : theme.colors.surface,
46
+ borderColor: isConnected ? theme.colors.success : theme.colors.danger,
47
+ },
48
+ ]}
49
+ >
50
+ {isConnected ? (
51
+ <Wifi size={18} color={theme.colors.success} />
52
+ ) : (
53
+ <WifiOff size={18} color={theme.colors.danger} />
54
+ )}
55
+ <Text style={[styles.statusText, { color: theme.colors.text }]}>
56
+ {isConnected ? "Connected" : connState === "reconnecting" ? "Reconnecting..." : "Disconnected"}
57
+ </Text>
58
+ </View>
59
+
60
+ <TouchableOpacity
61
+ style={[styles.newSessionBtn, { backgroundColor: theme.colors.primary }]}
62
+ onPress={handleNewSession}
63
+ activeOpacity={0.8}
64
+ >
65
+ <Plus size={20} color={theme.colors.primaryText} />
66
+ <Text style={[styles.newSessionText, { color: theme.colors.primaryText }]}>
67
+ New Session
68
+ </Text>
69
+ </TouchableOpacity>
70
+
71
+ {recentSessions.length > 0 && (
72
+ <View style={styles.section}>
73
+ <Text style={[styles.sectionTitle, { color: theme.colors.textSecondary }]}>
74
+ Recent Sessions
75
+ </Text>
76
+ {recentSessions.map((s) => (
77
+ <TouchableOpacity
78
+ key={s.id}
79
+ style={[styles.sessionItem, { backgroundColor: theme.colors.surface }]}
80
+ onPress={() => router.push(`/session/${s.id}`)}
81
+ >
82
+ <Text style={[styles.sessionTitle, { color: theme.colors.text }]} numberOfLines={1}>
83
+ {s.title || "Untitled"}
84
+ </Text>
85
+ <Text style={[styles.sessionTime, { color: theme.colors.textMuted }]}>
86
+ {new Date(s.updatedAt).toLocaleDateString()}
87
+ </Text>
88
+ </TouchableOpacity>
89
+ ))}
90
+ </View>
91
+ )}
92
+ </ScrollView>
93
+ </View>
94
+ );
95
+ }
96
+
97
+ const styles = StyleSheet.create({
98
+ container: { flex: 1 },
99
+ header: {
100
+ flexDirection: "row",
101
+ justifyContent: "space-between",
102
+ alignItems: "center",
103
+ paddingHorizontal: 20,
104
+ paddingBottom: 12,
105
+ },
106
+ title: { fontSize: 28, fontWeight: "700", letterSpacing: -0.5 },
107
+ scroll: { flex: 1, paddingHorizontal: 20 },
108
+ statusCard: {
109
+ flexDirection: "row",
110
+ alignItems: "center",
111
+ gap: 10,
112
+ padding: 14,
113
+ borderRadius: 12,
114
+ marginBottom: 20,
115
+ borderWidth: 1,
116
+ },
117
+ statusText: { fontSize: 14, fontWeight: "500" },
118
+ newSessionBtn: {
119
+ flexDirection: "row",
120
+ alignItems: "center",
121
+ justifyContent: "center",
122
+ gap: 8,
123
+ padding: 16,
124
+ borderRadius: 14,
125
+ marginBottom: 28,
126
+ },
127
+ newSessionText: { fontSize: 16, fontWeight: "600" },
128
+ section: { marginBottom: 24 },
129
+ sectionTitle: { fontSize: 13, fontWeight: "600", textTransform: "uppercase", letterSpacing: 0.5, marginBottom: 10 },
130
+ sessionItem: {
131
+ flexDirection: "row",
132
+ justifyContent: "space-between",
133
+ alignItems: "center",
134
+ padding: 14,
135
+ borderRadius: 10,
136
+ marginBottom: 6,
137
+ },
138
+ sessionTitle: { fontSize: 15, fontWeight: "500", flex: 1, marginRight: 10 },
139
+ sessionTime: { fontSize: 12 },
140
+ });
mobile/app/(tabs)/search.tsx ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { View, Text, TextInput, FlatList, StyleSheet } from "react-native";
2
+ import { useState } from "react";
3
+ import { useSafeAreaInsets } from "react-native-safe-area-context";
4
+ import { Search as SearchIcon } from "lucide-react-native";
5
+ import { useFindFiles } from "../../hooks/useApi";
6
+ import { getTheme } from "../../lib/storage";
7
+ import { themes } from "../../constants/themes";
8
+
9
+ export default function SearchScreen() {
10
+ const insets = useSafeAreaInsets();
11
+ const theme = themes[getTheme()];
12
+ const [query, setQuery] = useState("");
13
+ const { data: results, isLoading } = useFindFiles(query);
14
+
15
+ return (
16
+ <View style={[styles.container, { backgroundColor: theme.colors.bg }]}>
17
+ <View style={[styles.header, { paddingTop: insets.top + 12 }]}>
18
+ <Text style={[styles.title, { color: theme.colors.text }]}>Search</Text>
19
+ </View>
20
+
21
+ <View style={[styles.searchBar, { backgroundColor: theme.colors.surface, borderColor: theme.colors.border }]}>
22
+ <SearchIcon size={16} color={theme.colors.textMuted} />
23
+ <TextInput
24
+ style={[styles.searchInput, { color: theme.colors.text }]}
25
+ placeholder="Search files, code, text..."
26
+ placeholderTextColor={theme.colors.textMuted}
27
+ value={query}
28
+ onChangeText={setQuery}
29
+ autoFocus
30
+ />
31
+ </View>
32
+
33
+ <FlatList
34
+ data={results || []}
35
+ keyExtractor={(_, i) => String(i)}
36
+ contentContainerStyle={{ paddingBottom: insets.bottom + 100, paddingHorizontal: 20 }}
37
+ showsVerticalScrollIndicator={false}
38
+ ListEmptyComponent={
39
+ <View style={styles.empty}>
40
+ <SearchIcon size={40} color={theme.colors.textMuted} />
41
+ <Text style={[styles.emptyText, { color: theme.colors.textMuted }]}>
42
+ {query.length === 0
43
+ ? "Type to search"
44
+ : isLoading
45
+ ? "Searching..."
46
+ : "No results"}
47
+ </Text>
48
+ </View>
49
+ }
50
+ renderItem={({ item }) => (
51
+ <View style={[styles.resultCard, { backgroundColor: theme.colors.surface }]}>
52
+ <View style={styles.resultHeader}>
53
+ <Text style={[styles.resultFile, { color: theme.colors.primary }]} numberOfLines={1}>
54
+ {item.file}
55
+ </Text>
56
+ <Text style={[styles.resultLine, { color: theme.colors.textMuted }]}>
57
+ L{item.line}
58
+ </Text>
59
+ </View>
60
+ <Text style={[styles.resultMatch, { color: theme.colors.text }]} numberOfLines={2}>
61
+ {item.context}
62
+ </Text>
63
+ </View>
64
+ )}
65
+ />
66
+ </View>
67
+ );
68
+ }
69
+
70
+ const styles = StyleSheet.create({
71
+ container: { flex: 1 },
72
+ header: {
73
+ paddingHorizontal: 20,
74
+ paddingBottom: 12,
75
+ },
76
+ title: { fontSize: 28, fontWeight: "700", letterSpacing: -0.5 },
77
+ searchBar: {
78
+ flexDirection: "row",
79
+ alignItems: "center",
80
+ gap: 8,
81
+ marginHorizontal: 20,
82
+ marginBottom: 16,
83
+ paddingHorizontal: 14,
84
+ paddingVertical: 10,
85
+ borderRadius: 10,
86
+ borderWidth: 1,
87
+ },
88
+ searchInput: { flex: 1, fontSize: 14, padding: 0 },
89
+ resultCard: {
90
+ padding: 12,
91
+ borderRadius: 10,
92
+ marginBottom: 6,
93
+ },
94
+ resultHeader: {
95
+ flexDirection: "row",
96
+ justifyContent: "space-between",
97
+ alignItems: "center",
98
+ marginBottom: 4,
99
+ },
100
+ resultFile: { fontSize: 13, fontWeight: "500", flex: 1, marginRight: 8 },
101
+ resultLine: { fontSize: 12 },
102
+ resultMatch: { fontSize: 13 },
103
+ empty: { alignItems: "center", marginTop: 80, gap: 12 },
104
+ emptyText: { fontSize: 14 },
105
+ });
mobile/app/(tabs)/sessions.tsx ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { View, Text, TextInput, TouchableOpacity, FlatList, Alert, StyleSheet } from "react-native";
2
+ import { useRouter } from "expo-router";
3
+ import { useSafeAreaInsets } from "react-native-safe-area-context";
4
+ import { Plus, Search, Trash2, MessageSquare } from "lucide-react-native";
5
+ import { useState } from "react";
6
+ import { useSessions, useCreateSession, useDeleteSession } from "../../hooks/useApi";
7
+ import { getTheme } from "../../lib/storage";
8
+ import { themes } from "../../constants/themes";
9
+
10
+ export default function SessionsScreen() {
11
+ const router = useRouter();
12
+ const insets = useSafeAreaInsets();
13
+ const theme = themes[getTheme()];
14
+ const { data: sessions, isLoading } = useSessions();
15
+ const createSession = useCreateSession();
16
+ const deleteSession = useDeleteSession();
17
+ const [search, setSearch] = useState("");
18
+
19
+ const filtered = (sessions || []).filter(
20
+ (s) => !search || (s.title || "").toLowerCase().includes(search.toLowerCase())
21
+ );
22
+
23
+ const handleCreate = async () => {
24
+ try {
25
+ const session = await createSession.mutateAsync({ title: "New Chat", directory: "default" });
26
+ router.push(`/session/${session.id}`);
27
+ } catch {}
28
+ };
29
+
30
+ const handleDelete = (id: string, title: string) => {
31
+ Alert.alert("Delete Session", `Delete "${title || "Untitled"}"?`, [
32
+ { text: "Cancel", style: "cancel" },
33
+ { text: "Delete", style: "destructive", onPress: () => deleteSession.mutateAsync(id) },
34
+ ]);
35
+ };
36
+
37
+ return (
38
+ <View style={[styles.container, { backgroundColor: theme.colors.bg }]}>
39
+ <View style={[styles.header, { paddingTop: insets.top + 12 }]}>
40
+ <Text style={[styles.title, { color: theme.colors.text }]}>Sessions</Text>
41
+ <TouchableOpacity onPress={handleCreate}>
42
+ <Plus size={22} color={theme.colors.primary} />
43
+ </TouchableOpacity>
44
+ </View>
45
+
46
+ <View style={[styles.searchBar, { backgroundColor: theme.colors.surface, borderColor: theme.colors.border }]}>
47
+ <Search size={16} color={theme.colors.textMuted} />
48
+ <TextInput
49
+ style={[styles.searchInput, { color: theme.colors.text }]}
50
+ placeholder="Search sessions..."
51
+ placeholderTextColor={theme.colors.textMuted}
52
+ value={search}
53
+ onChangeText={setSearch}
54
+ />
55
+ </View>
56
+
57
+ <FlatList
58
+ data={filtered}
59
+ keyExtractor={(item) => item.id}
60
+ contentContainerStyle={{ paddingBottom: insets.bottom + 100, paddingHorizontal: 20 }}
61
+ showsVerticalScrollIndicator={false}
62
+ ListEmptyComponent={
63
+ <View style={styles.empty}>
64
+ <MessageSquare size={40} color={theme.colors.textMuted} />
65
+ <Text style={[styles.emptyText, { color: theme.colors.textMuted }]}>
66
+ {isLoading ? "Loading..." : "No sessions yet"}
67
+ </Text>
68
+ </View>
69
+ }
70
+ renderItem={({ item }) => (
71
+ <TouchableOpacity
72
+ style={[styles.sessionCard, { backgroundColor: theme.colors.surface }]}
73
+ onPress={() => router.push(`/session/${item.id}`)}
74
+ >
75
+ <View style={styles.sessionInfo}>
76
+ <Text style={[styles.sessionTitle, { color: theme.colors.text }]} numberOfLines={1}>
77
+ {item.title || "Untitled"}
78
+ </Text>
79
+ <Text style={[styles.sessionDate, { color: theme.colors.textMuted }]}>
80
+ {new Date(item.updatedAt).toLocaleString()}
81
+ </Text>
82
+ </View>
83
+ <TouchableOpacity onPress={() => handleDelete(item.id, item.title)}>
84
+ <Trash2 size={16} color={theme.colors.textMuted} />
85
+ </TouchableOpacity>
86
+ </TouchableOpacity>
87
+ )}
88
+ />
89
+ </View>
90
+ );
91
+ }
92
+
93
+ const styles = StyleSheet.create({
94
+ container: { flex: 1 },
95
+ header: {
96
+ flexDirection: "row",
97
+ justifyContent: "space-between",
98
+ alignItems: "center",
99
+ paddingHorizontal: 20,
100
+ paddingBottom: 12,
101
+ },
102
+ title: { fontSize: 28, fontWeight: "700", letterSpacing: -0.5 },
103
+ searchBar: {
104
+ flexDirection: "row",
105
+ alignItems: "center",
106
+ gap: 8,
107
+ marginHorizontal: 20,
108
+ marginBottom: 16,
109
+ paddingHorizontal: 14,
110
+ paddingVertical: 10,
111
+ borderRadius: 10,
112
+ borderWidth: 1,
113
+ },
114
+ searchInput: { flex: 1, fontSize: 14, padding: 0 },
115
+ sessionCard: {
116
+ flexDirection: "row",
117
+ alignItems: "center",
118
+ justifyContent: "space-between",
119
+ padding: 14,
120
+ borderRadius: 10,
121
+ marginBottom: 6,
122
+ },
123
+ sessionInfo: { flex: 1, marginRight: 12 },
124
+ sessionTitle: { fontSize: 15, fontWeight: "500" },
125
+ sessionDate: { fontSize: 12, marginTop: 2 },
126
+ empty: { alignItems: "center", marginTop: 80, gap: 12 },
127
+ emptyText: { fontSize: 14 },
128
+ });
mobile/app/(tabs)/terminal.tsx ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { View, Text, StyleSheet } from "react-native";
2
+ import { useSafeAreaInsets } from "react-native-safe-area-context";
3
+ import { Terminal } from "lucide-react-native";
4
+ import { getTheme } from "../../lib/storage";
5
+ import { themes } from "../../constants/themes";
6
+
7
+ export default function TerminalScreen() {
8
+ const insets = useSafeAreaInsets();
9
+ const theme = themes[getTheme()];
10
+
11
+ return (
12
+ <View style={[styles.container, { backgroundColor: theme.colors.bg }]}>
13
+ <View style={[styles.header, { paddingTop: insets.top + 12 }]}>
14
+ <Text style={[styles.title, { color: theme.colors.text }]}>Terminal</Text>
15
+ </View>
16
+ <View style={styles.empty}>
17
+ <Terminal size={48} color={theme.colors.textMuted} />
18
+ <Text style={[styles.emptyTitle, { color: theme.colors.text }]}>Terminal</Text>
19
+ <Text style={[styles.emptyText, { color: theme.colors.textMuted }]}>
20
+ Terminal sessions will appear here.
21
+ </Text>
22
+ </View>
23
+ </View>
24
+ );
25
+ }
26
+
27
+ const styles = StyleSheet.create({
28
+ container: { flex: 1 },
29
+ header: {
30
+ paddingHorizontal: 20,
31
+ paddingBottom: 12,
32
+ },
33
+ title: { fontSize: 28, fontWeight: "700", letterSpacing: -0.5 },
34
+ empty: { flex: 1, alignItems: "center", justifyContent: "center", gap: 12 },
35
+ emptyTitle: { fontSize: 18, fontWeight: "600" },
36
+ emptyText: { fontSize: 14 },
37
+ });
mobile/app/_layout.tsx ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect } from "react";
2
+ import { Stack } from "expo-router";
3
+ import { StatusBar } from "expo-status-bar";
4
+ import { QueryProvider } from "../lib/query-provider";
5
+ import { getTheme } from "../lib/storage";
6
+ import { themes } from "../constants/themes";
7
+
8
+ export default function RootLayout() {
9
+ const theme = themes[getTheme()];
10
+
11
+ useEffect(() => {
12
+ const { SystemUI } = require("expo-system-ui");
13
+ SystemUI.setBackgroundColorAsync(theme.colors.bg);
14
+ }, []);
15
+
16
+ return (
17
+ <QueryProvider>
18
+ <StatusBar style="light" />
19
+ <Stack
20
+ screenOptions={{
21
+ headerShown: false,
22
+ contentStyle: { backgroundColor: theme.colors.bg },
23
+ }}
24
+ />
25
+ </QueryProvider>
26
+ );
27
+ }
mobile/app/session/[id].tsx ADDED
@@ -0,0 +1,271 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { View, Text, TextInput, TouchableOpacity, FlatList, KeyboardAvoidingView, Platform, StyleSheet } from "react-native";
2
+ import { useLocalSearchParams, useRouter } from "expo-router";
3
+ import { useSafeAreaInsets } from "react-native-safe-area-context";
4
+ import { useState, useEffect, useRef } from "react";
5
+ import { ArrowLeft, Send, Loader, AlertTriangle } from "lucide-react-native";
6
+ import { useMessages, useSession, useSendMessage, useReplyPermission, useReplyQuestion } from "../../hooks/useApi";
7
+ import { useSSE, getMessagesFromEvents } from "../../hooks/useSSE";
8
+ import { MarkdownRenderer } from "../../components/MarkdownRenderer";
9
+ import { ToolTimeline } from "../../components/ToolTimeline";
10
+ import { PermissionCard } from "../../components/PermissionCard";
11
+ import { QuestionCard } from "../../components/QuestionCard";
12
+ import { Message, MessagePart, ToolPart, PermissionRequest, QuestionRequest } from "../../constants/types";
13
+ import { getTheme } from "../../lib/storage";
14
+ import { themes } from "../../constants/themes";
15
+
16
+ export default function ChatScreen() {
17
+ const { id } = useLocalSearchParams<{ id: string }>();
18
+ const router = useRouter();
19
+ const insets = useSafeAreaInsets();
20
+ const theme = themes[getTheme()];
21
+ const [input, setInput] = useState("");
22
+ const flatListRef = useRef<FlatList>(null);
23
+
24
+ const { data: session } = useSession(id || "");
25
+ const { data: serverMessages } = useMessages(id || "");
26
+ const { connect, disconnect, streaming, events, error } = useSSE(id);
27
+ const sendMessage = useSendMessage();
28
+ const replyPermission = useReplyPermission();
29
+ const replyQuestion = useReplyQuestion();
30
+
31
+ // Build messages from server + SSE events
32
+ const sseMessages = getMessagesFromEvents(events);
33
+ const allMessages: Message[] = [...(serverMessages || []), ...sseMessages];
34
+
35
+ // Deduplicate by id
36
+ const seen = new Set<string>();
37
+ const uniqueMessages = allMessages.filter((m) => {
38
+ if (seen.has(m.id)) return false;
39
+ seen.add(m.id);
40
+ return true;
41
+ });
42
+
43
+ // Collect pending permissions and questions
44
+ const pendingPermissions: PermissionRequest[] = [];
45
+ const pendingQuestions: QuestionRequest[] = [];
46
+ for (const ev of events) {
47
+ if (ev.type === "permission.requested") {
48
+ const perm = ev.properties as PermissionRequest;
49
+ if (perm.state === "pending") pendingPermissions.push(perm);
50
+ }
51
+ if (ev.type === "question.asked") {
52
+ const q = ev.properties as QuestionRequest;
53
+ if (q.state === "pending") pendingQuestions.push(q);
54
+ }
55
+ }
56
+
57
+ useEffect(() => {
58
+ connect();
59
+ return () => disconnect();
60
+ }, [id]);
61
+
62
+ const handleSend = () => {
63
+ if (!input.trim() || !id) return;
64
+ sendMessage.mutate({ sessionId: id, content: input.trim() });
65
+ setInput("");
66
+ };
67
+
68
+ useEffect(() => {
69
+ if (uniqueMessages.length > 0 || events.length > 0) {
70
+ setTimeout(() => flatListRef.current?.scrollToEnd({ animated: true }), 100);
71
+ }
72
+ }, [uniqueMessages.length, events.length]);
73
+
74
+ const renderPart = (part: MessagePart, index: number) => {
75
+ if (part.type === "text" && part.text) {
76
+ return <MarkdownRenderer key={index} content={part.text} />;
77
+ }
78
+ if (part.type === "tool") {
79
+ const toolPart = part as ToolPart;
80
+ return (
81
+ <ToolTimeline
82
+ key={toolPart.callID || index}
83
+ parts={[toolPart]}
84
+ />
85
+ );
86
+ }
87
+ if (part.type === "reasoning") {
88
+ return (
89
+ <View key={index} style={[styles.reasoning, { backgroundColor: theme.colors.bgTertiary, borderColor: theme.colors.border }]}>
90
+ <Text style={[styles.reasoningLabel, { color: theme.colors.textMuted }]}>Thinking...</Text>
91
+ <Text style={[styles.reasoningText, { color: theme.colors.textMuted }]} numberOfLines={3}>
92
+ {part.text}
93
+ </Text>
94
+ </View>
95
+ );
96
+ }
97
+ return null;
98
+ };
99
+
100
+ const renderMessage = (item: Message) => {
101
+ const isUser = item.role === "user";
102
+
103
+ // Group consecutive tool parts
104
+ const toolParts: ToolPart[] = [];
105
+ const otherParts: MessagePart[] = [];
106
+ for (const part of item.parts) {
107
+ if (part.type === "tool") toolParts.push(part as ToolPart);
108
+ else otherParts.push(part);
109
+ }
110
+
111
+ return (
112
+ <View
113
+ style={[
114
+ styles.bubble,
115
+ isUser
116
+ ? { backgroundColor: theme.colors.primary, alignSelf: "flex-end", maxWidth: "85%" }
117
+ : { backgroundColor: theme.colors.surface, alignSelf: "flex-start", maxWidth: "90%" },
118
+ ]}
119
+ >
120
+ {otherParts.map((part, i) => renderPart(part, i))}
121
+ {toolParts.length > 0 && <ToolTimeline parts={toolParts} />}
122
+ </View>
123
+ );
124
+ };
125
+
126
+ return (
127
+ <KeyboardAvoidingView
128
+ style={[styles.container, { backgroundColor: theme.colors.bg }]}
129
+ behavior={Platform.OS === "ios" ? "padding" : undefined}
130
+ keyboardVerticalOffset={Platform.OS === "ios" ? 0 : 0}
131
+ >
132
+ <View style={[styles.header, { paddingTop: insets.top + 8, backgroundColor: theme.colors.bgSecondary, borderBottomColor: theme.colors.border }]}>
133
+ <TouchableOpacity onPress={() => router.back()} style={styles.backBtn}>
134
+ <ArrowLeft size={20} color={theme.colors.text} />
135
+ </TouchableOpacity>
136
+ <Text style={[styles.headerTitle, { color: theme.colors.text }]} numberOfLines={1}>
137
+ {session?.title || "Chat"}
138
+ </Text>
139
+ </View>
140
+
141
+ <FlatList
142
+ ref={flatListRef}
143
+ data={uniqueMessages}
144
+ keyExtractor={(item) => item.id}
145
+ contentContainerStyle={{ padding: 20, paddingBottom: 20, gap: 12 }}
146
+ showsVerticalScrollIndicator={false}
147
+ onContentSizeChange={() => flatListRef.current?.scrollToEnd({ animated: true })}
148
+ renderItem={({ item }) => renderMessage(item)}
149
+ ListFooterComponent={
150
+ <>
151
+ {/* Pending permissions */}
152
+ {pendingPermissions.map((perm) => (
153
+ <PermissionCard
154
+ key={perm.id}
155
+ request={perm}
156
+ onAllow={() => replyPermission.mutate({ sessionId: id!, requestId: perm.id, allow: true })}
157
+ onDeny={() => replyPermission.mutate({ sessionId: id!, requestId: perm.id, allow: false })}
158
+ />
159
+ ))}
160
+
161
+ {/* Pending questions */}
162
+ {pendingQuestions.map((q) => (
163
+ <QuestionCard
164
+ key={q.id}
165
+ request={q}
166
+ onReply={(answer) => replyQuestion.mutate({ sessionId: id!, requestId: q.id, answer })}
167
+ />
168
+ ))}
169
+
170
+ {/* Streaming indicator */}
171
+ {streaming && (
172
+ <View style={[styles.bubble, { backgroundColor: theme.colors.surface, alignSelf: "flex-start" }]}>
173
+ <View style={styles.typing}>
174
+ <Loader size={14} color={theme.colors.textMuted} />
175
+ <Text style={[styles.typingText, { color: theme.colors.textMuted }]}>Thinking...</Text>
176
+ </View>
177
+ </View>
178
+ )}
179
+ </>
180
+ }
181
+ />
182
+
183
+ {error && (
184
+ <View style={[styles.errorBar, { backgroundColor: theme.colors.danger }]}>
185
+ <AlertTriangle size={14} color="#fff" />
186
+ <Text style={[styles.errorText, { color: "#fff" }]}>{error}</Text>
187
+ </View>
188
+ )}
189
+
190
+ <View style={[styles.inputBar, { borderTopColor: theme.colors.border, backgroundColor: theme.colors.bgSecondary, paddingBottom: insets.bottom + 8 }]}>
191
+ <TextInput
192
+ style={[styles.textInput, { color: theme.colors.text, backgroundColor: theme.colors.surface, borderColor: theme.colors.border }]}
193
+ placeholder="Type a message..."
194
+ placeholderTextColor={theme.colors.textMuted}
195
+ value={input}
196
+ onChangeText={setInput}
197
+ onSubmitEditing={handleSend}
198
+ multiline
199
+ maxLength={4000}
200
+ />
201
+ <TouchableOpacity
202
+ style={[styles.sendBtn, { backgroundColor: input.trim() ? theme.colors.primary : theme.colors.surface }]}
203
+ onPress={handleSend}
204
+ disabled={!input.trim() || streaming}
205
+ >
206
+ <Send size={18} color={input.trim() ? theme.colors.primaryText : theme.colors.textMuted} />
207
+ </TouchableOpacity>
208
+ </View>
209
+ </KeyboardAvoidingView>
210
+ );
211
+ }
212
+
213
+ const styles = StyleSheet.create({
214
+ container: { flex: 1 },
215
+ header: {
216
+ flexDirection: "row",
217
+ alignItems: "center",
218
+ gap: 10,
219
+ paddingHorizontal: 16,
220
+ paddingBottom: 10,
221
+ borderBottomWidth: 1,
222
+ },
223
+ backBtn: { padding: 4 },
224
+ headerTitle: { fontSize: 17, fontWeight: "600", flex: 1 },
225
+ bubble: {
226
+ padding: 14,
227
+ borderRadius: 14,
228
+ },
229
+ reasoning: {
230
+ padding: 10,
231
+ borderRadius: 8,
232
+ borderWidth: 1,
233
+ marginTop: 4,
234
+ },
235
+ reasoningLabel: { fontSize: 11, fontWeight: "600", textTransform: "uppercase", marginBottom: 4 },
236
+ reasoningText: { fontSize: 12, lineHeight: 18, fontStyle: "italic" },
237
+ typing: { flexDirection: "row", alignItems: "center", gap: 6 },
238
+ typingText: { fontSize: 14 },
239
+ errorBar: {
240
+ flexDirection: "row",
241
+ alignItems: "center",
242
+ gap: 8,
243
+ paddingHorizontal: 16,
244
+ paddingVertical: 8,
245
+ },
246
+ errorText: { fontSize: 13, flex: 1 },
247
+ inputBar: {
248
+ flexDirection: "row",
249
+ alignItems: "flex-end",
250
+ gap: 8,
251
+ paddingHorizontal: 16,
252
+ paddingTop: 10,
253
+ borderTopWidth: 1,
254
+ },
255
+ textInput: {
256
+ flex: 1,
257
+ fontSize: 15,
258
+ paddingHorizontal: 14,
259
+ paddingVertical: 10,
260
+ borderRadius: 12,
261
+ borderWidth: 1,
262
+ maxHeight: 120,
263
+ },
264
+ sendBtn: {
265
+ width: 40,
266
+ height: 40,
267
+ borderRadius: 12,
268
+ alignItems: "center",
269
+ justifyContent: "center",
270
+ },
271
+ });
mobile/app/settings.tsx ADDED
@@ -0,0 +1,281 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { View, Text, TextInput, TouchableOpacity, ScrollView, StyleSheet } from "react-native";
2
+ import { useRouter } from "expo-router";
3
+ import { useSafeAreaInsets } from "react-native-safe-area-context";
4
+ import { useState, useEffect } from "react";
5
+ import { ArrowLeft, Check, Shield, Globe, Key, Lock } from "lucide-react-native";
6
+ import { getHealth } from "../lib/api";
7
+ import {
8
+ getServerUrl,
9
+ setServerUrl as persistUrl,
10
+ getAuthType,
11
+ setAuthType,
12
+ getAuthValue,
13
+ setAuthValue,
14
+ clearAuth,
15
+ getTheme,
16
+ setTheme as persistTheme,
17
+ } from "../lib/storage";
18
+ import type { AuthType } from "../lib/storage";
19
+ import { themes } from "../constants/themes";
20
+ import { ThemeName } from "../constants/types";
21
+
22
+ const themeNames = Object.keys(themes) as ThemeName[];
23
+
24
+ const authTypes: { type: AuthType; label: string; description: string; icon: typeof Shield }[] = [
25
+ { type: "none", label: "None", description: "No authentication", icon: Globe },
26
+ { type: "basic", label: "Basic Auth", description: "Username & password", icon: Lock },
27
+ { type: "bearer", label: "Bearer Token", description: "Authorization header", icon: Key },
28
+ { type: "apikey", label: "API Key", description: "X-API-Key header", icon: Shield },
29
+ ];
30
+
31
+ export default function SettingsScreen() {
32
+ const router = useRouter();
33
+ const insets = useSafeAreaInsets();
34
+ const currentTheme = getTheme();
35
+ const theme = themes[currentTheme];
36
+
37
+ const [serverInput, setServerInput] = useState(getServerUrl());
38
+ const [authType, setAuthTypeState] = useState<AuthType>(getAuthType());
39
+ const [authValue, setAuthValueState] = useState(getAuthValue());
40
+ const [connected, setConnected] = useState<boolean | null>(null);
41
+ const [serverVersion, setServerVersion] = useState<string | null>(null);
42
+
43
+ // Basic auth split
44
+ const [basicUser, setBasicUser] = useState(() => {
45
+ const val = getAuthValue();
46
+ return getAuthType() === "basic" && val.includes(":") ? val.split(":")[0] : "";
47
+ });
48
+ const [basicPass, setBasicPass] = useState(() => {
49
+ const val = getAuthValue();
50
+ return getAuthType() === "basic" && val.includes(":") ? val.split(":").slice(1).join(":") : "";
51
+ });
52
+
53
+ const testConnection = async () => {
54
+ persistUrl(serverInput);
55
+
56
+ // Save auth
57
+ if (authType === "basic") {
58
+ setAuthValue(`${basicUser}:${basicPass}`);
59
+ } else {
60
+ setAuthValue(authValue);
61
+ }
62
+ setAuthType(authType);
63
+
64
+ try {
65
+ const health = await getHealth();
66
+ setConnected(health.healthy);
67
+ setServerVersion(health.version || null);
68
+ } catch {
69
+ setConnected(false);
70
+ setServerVersion(null);
71
+ }
72
+ };
73
+
74
+ const handleThemeSelect = (name: ThemeName) => {
75
+ persistTheme(name);
76
+ };
77
+
78
+ return (
79
+ <View style={[styles.container, { backgroundColor: theme.colors.bg }]}>
80
+ <View style={[styles.header, { paddingTop: insets.top + 12, borderBottomColor: theme.colors.border }]}>
81
+ <TouchableOpacity onPress={() => router.back()} style={styles.backBtn}>
82
+ <ArrowLeft size={20} color={theme.colors.text} />
83
+ </TouchableOpacity>
84
+ <Text style={[styles.title, { color: theme.colors.text }]}>Settings</Text>
85
+ </View>
86
+
87
+ <ScrollView
88
+ contentContainerStyle={{ padding: 20, paddingBottom: insets.bottom + 40 }}
89
+ showsVerticalScrollIndicator={false}
90
+ >
91
+ {/* Server */}
92
+ <Text style={[styles.sectionLabel, { color: theme.colors.textSecondary }]}>Server</Text>
93
+ <View style={[styles.card, { backgroundColor: theme.colors.surface, borderColor: theme.colors.border }]}>
94
+ <Text style={[styles.label, { color: theme.colors.textMuted }]}>Server URL</Text>
95
+ <TextInput
96
+ style={[styles.input, { color: theme.colors.text, backgroundColor: theme.colors.bgTertiary, borderColor: theme.colors.border }]}
97
+ value={serverInput}
98
+ onChangeText={setServerInput}
99
+ placeholder="http://127.0.0.1:4096"
100
+ placeholderTextColor={theme.colors.textMuted}
101
+ autoCapitalize="none"
102
+ autoCorrect={false}
103
+ />
104
+
105
+ {/* Auth Type */}
106
+ <Text style={[styles.label, { color: theme.colors.textMuted, marginTop: 8 }]}>Authentication</Text>
107
+ <View style={styles.authGrid}>
108
+ {authTypes.map((a) => {
109
+ const Icon = a.icon;
110
+ const selected = authType === a.type;
111
+ return (
112
+ <TouchableOpacity
113
+ key={a.type}
114
+ style={[
115
+ styles.authCard,
116
+ {
117
+ backgroundColor: selected ? theme.colors.bgTertiary : theme.colors.bg,
118
+ borderColor: selected ? theme.colors.primary : theme.colors.border,
119
+ },
120
+ ]}
121
+ onPress={() => setAuthTypeState(a.type)}
122
+ >
123
+ <Icon size={16} color={selected ? theme.colors.primary : theme.colors.textMuted} />
124
+ <Text style={[styles.authLabel, { color: selected ? theme.colors.text : theme.colors.textMuted }]}>
125
+ {a.label}
126
+ </Text>
127
+ </TouchableOpacity>
128
+ );
129
+ })}
130
+ </View>
131
+
132
+ {/* Auth Value Input */}
133
+ {authType === "basic" && (
134
+ <View style={{ gap: 8, marginTop: 8 }}>
135
+ <TextInput
136
+ style={[styles.input, { color: theme.colors.text, backgroundColor: theme.colors.bgTertiary, borderColor: theme.colors.border }]}
137
+ value={basicUser}
138
+ onChangeText={setBasicUser}
139
+ placeholder="Username"
140
+ placeholderTextColor={theme.colors.textMuted}
141
+ autoCapitalize="none"
142
+ />
143
+ <TextInput
144
+ style={[styles.input, { color: theme.colors.text, backgroundColor: theme.colors.bgTertiary, borderColor: theme.colors.border }]}
145
+ value={basicPass}
146
+ onChangeText={setBasicPass}
147
+ placeholder="Password"
148
+ placeholderTextColor={theme.colors.textMuted}
149
+ secureTextEntry
150
+ />
151
+ </View>
152
+ )}
153
+ {authType === "bearer" && (
154
+ <TextInput
155
+ style={[styles.input, { color: theme.colors.text, backgroundColor: theme.colors.bgTertiary, borderColor: theme.colors.border, marginTop: 8 }]}
156
+ value={authValue}
157
+ onChangeText={setAuthValueState}
158
+ placeholder="Bearer token"
159
+ placeholderTextColor={theme.colors.textMuted}
160
+ autoCapitalize="none"
161
+ secureTextEntry
162
+ />
163
+ )}
164
+ {authType === "apikey" && (
165
+ <TextInput
166
+ style={[styles.input, { color: theme.colors.text, backgroundColor: theme.colors.bgTertiary, borderColor: theme.colors.border, marginTop: 8 }]}
167
+ value={authValue}
168
+ onChangeText={setAuthValueState}
169
+ placeholder="API key"
170
+ placeholderTextColor={theme.colors.textMuted}
171
+ autoCapitalize="none"
172
+ secureTextEntry
173
+ />
174
+ )}
175
+
176
+ <TouchableOpacity style={[styles.btn, { backgroundColor: theme.colors.primary }]} onPress={testConnection}>
177
+ <Text style={[styles.btnText, { color: theme.colors.primaryText }]}>Test Connection</Text>
178
+ </TouchableOpacity>
179
+ {connected !== null && (
180
+ <Text style={[styles.status, { color: connected ? theme.colors.success : theme.colors.danger }]}>
181
+ {connected ? `Connected${serverVersion ? ` (v${serverVersion})` : ""}` : "Failed to connect"}
182
+ </Text>
183
+ )}
184
+ </View>
185
+
186
+ {/* Theme */}
187
+ <Text style={[styles.sectionLabel, { color: theme.colors.textSecondary, marginTop: 28 }]}>Theme</Text>
188
+ <View style={styles.themeGrid}>
189
+ {themeNames.map((name) => {
190
+ const t = themes[name];
191
+ const selected = currentTheme === name;
192
+ return (
193
+ <TouchableOpacity
194
+ key={name}
195
+ style={[
196
+ styles.themeCard,
197
+ {
198
+ backgroundColor: t.colors.bg,
199
+ borderColor: selected ? theme.colors.primary : t.colors.border,
200
+ borderWidth: selected ? 2 : 1,
201
+ },
202
+ ]}
203
+ onPress={() => handleThemeSelect(name)}
204
+ >
205
+ <View style={styles.themePreview}>
206
+ <View style={[styles.dot, { backgroundColor: t.colors.primary }]} />
207
+ <View style={[styles.dot, { backgroundColor: t.colors.secondary }]} />
208
+ <View style={[styles.dot, { backgroundColor: t.colors.danger }]} />
209
+ </View>
210
+ <Text style={[styles.themeName, { color: t.colors.text }]}>{t.label}</Text>
211
+ {selected && <Check size={14} color={theme.colors.primary} />}
212
+ </TouchableOpacity>
213
+ );
214
+ })}
215
+ </View>
216
+
217
+ {/* About */}
218
+ <Text style={[styles.sectionLabel, { color: theme.colors.textSecondary, marginTop: 28 }]}>About</Text>
219
+ <View style={[styles.card, { backgroundColor: theme.colors.surface, borderColor: theme.colors.border }]}>
220
+ <Text style={[styles.aboutText, { color: theme.colors.text }]}>OpenCode Mobile</Text>
221
+ <Text style={[styles.aboutVersion, { color: theme.colors.textMuted }]}>v1.0.0</Text>
222
+ </View>
223
+ </ScrollView>
224
+ </View>
225
+ );
226
+ }
227
+
228
+ const styles = StyleSheet.create({
229
+ container: { flex: 1 },
230
+ header: {
231
+ flexDirection: "row",
232
+ alignItems: "center",
233
+ gap: 10,
234
+ paddingHorizontal: 20,
235
+ paddingBottom: 12,
236
+ borderBottomWidth: 1,
237
+ },
238
+ backBtn: { padding: 4 },
239
+ title: { fontSize: 28, fontWeight: "700", letterSpacing: -0.5 },
240
+ sectionLabel: { fontSize: 13, fontWeight: "600", textTransform: "uppercase", letterSpacing: 0.5, marginBottom: 10 },
241
+ card: { padding: 16, borderRadius: 12, borderWidth: 1, gap: 10 },
242
+ label: { fontSize: 13, fontWeight: "500" },
243
+ input: {
244
+ fontSize: 14,
245
+ paddingHorizontal: 12,
246
+ paddingVertical: 10,
247
+ borderRadius: 8,
248
+ borderWidth: 1,
249
+ },
250
+ btn: { paddingVertical: 12, borderRadius: 10, alignItems: "center", marginTop: 4 },
251
+ btnText: { fontSize: 14, fontWeight: "600" },
252
+ status: { fontSize: 13, textAlign: "center" },
253
+ authGrid: {
254
+ flexDirection: "row",
255
+ flexWrap: "wrap",
256
+ gap: 8,
257
+ },
258
+ authCard: {
259
+ flexDirection: "row",
260
+ alignItems: "center",
261
+ gap: 6,
262
+ paddingHorizontal: 12,
263
+ paddingVertical: 8,
264
+ borderRadius: 8,
265
+ borderWidth: 1,
266
+ },
267
+ authLabel: { fontSize: 13, fontWeight: "500" },
268
+ themeGrid: { gap: 8 },
269
+ themeCard: {
270
+ flexDirection: "row",
271
+ alignItems: "center",
272
+ gap: 12,
273
+ padding: 14,
274
+ borderRadius: 10,
275
+ },
276
+ themePreview: { flexDirection: "row", gap: 4 },
277
+ dot: { width: 10, height: 10, borderRadius: 5 },
278
+ themeName: { flex: 1, fontSize: 14, fontWeight: "500" },
279
+ aboutText: { fontSize: 15, fontWeight: "600" },
280
+ aboutVersion: { fontSize: 13 },
281
+ });
mobile/components/MarkdownRenderer.tsx ADDED
@@ -0,0 +1,134 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from "react";
2
+ import { View, Text, StyleSheet, ScrollView } from "react-native";
3
+ import Markdown from "react-native-markdown-display";
4
+ import { themes } from "../constants/themes";
5
+ import { getTheme } from "../lib/storage";
6
+
7
+ interface MarkdownRendererProps {
8
+ content: string;
9
+ }
10
+
11
+ const codeStyle = (theme: { colors: Record<string, string> }) =>
12
+ StyleSheet.create({
13
+ body: {},
14
+ heading1: { fontSize: 22, fontWeight: "700", color: theme.colors.text, marginTop: 16, marginBottom: 8 },
15
+ heading2: { fontSize: 19, fontWeight: "700", color: theme.colors.text, marginTop: 14, marginBottom: 6 },
16
+ heading3: { fontSize: 16, fontWeight: "600", color: theme.colors.text, marginTop: 12, marginBottom: 4 },
17
+ paragraph: { fontSize: 15, lineHeight: 22, color: theme.colors.text, marginBottom: 8 },
18
+ link: { color: theme.colors.primary, textDecorationLine: "none" },
19
+ blockquote: {
20
+ borderLeftWidth: 3,
21
+ borderLeftColor: theme.colors.primary,
22
+ paddingLeft: 12,
23
+ marginLeft: 0,
24
+ marginVertical: 8,
25
+ opacity: 0.8,
26
+ },
27
+ code_inline: {
28
+ fontFamily: "monospace",
29
+ fontSize: 13,
30
+ color: theme.colors.accent,
31
+ backgroundColor: theme.colors.bgTertiary,
32
+ paddingHorizontal: 5,
33
+ paddingVertical: 2,
34
+ borderRadius: 4,
35
+ },
36
+ code_block: {
37
+ fontFamily: "monospace",
38
+ fontSize: 13,
39
+ color: theme.colors.text,
40
+ backgroundColor: theme.colors.bgTertiary,
41
+ padding: 12,
42
+ borderRadius: 8,
43
+ marginVertical: 8,
44
+ borderWidth: 1,
45
+ borderColor: theme.colors.border,
46
+ },
47
+ fence: {
48
+ fontFamily: "monospace",
49
+ fontSize: 13,
50
+ color: theme.colors.text,
51
+ backgroundColor: theme.colors.bgTertiary,
52
+ padding: 12,
53
+ borderRadius: 8,
54
+ marginVertical: 8,
55
+ borderWidth: 1,
56
+ borderColor: theme.colors.border,
57
+ },
58
+ list_item: { fontSize: 15, lineHeight: 22, color: theme.colors.text, marginBottom: 4 },
59
+ bullet_list: { marginVertical: 8 },
60
+ ordered_list: { marginVertical: 8 },
61
+ table: { borderWidth: 1, borderColor: theme.colors.border, marginVertical: 8 },
62
+ table_header_cell: {
63
+ backgroundColor: theme.colors.bgTertiary,
64
+ padding: 8,
65
+ borderBottomWidth: 1,
66
+ borderBottomColor: theme.colors.border,
67
+ },
68
+ table_cell: { padding: 8, borderBottomWidth: 1, borderBottomColor: theme.colors.border },
69
+ strong: { fontWeight: "700" },
70
+ em: { fontStyle: "italic" },
71
+ hr: { height: 1, backgroundColor: theme.colors.border, marginVertical: 16 },
72
+ image: { marginVertical: 8 },
73
+ });
74
+
75
+ export function MarkdownRenderer({ content }: MarkdownRendererProps) {
76
+ const theme = themes[getTheme()];
77
+ const styles = codeStyle(theme);
78
+
79
+ return (
80
+ <Markdown style={styles}>{content}</Markdown>
81
+ );
82
+ }
83
+
84
+ // ── Code block with language label ──
85
+
86
+ interface CodeBlockProps {
87
+ language?: string;
88
+ children: string;
89
+ }
90
+
91
+ export function CodeBlock({ language, children }: CodeBlockProps) {
92
+ const theme = themes[getTheme()];
93
+
94
+ return (
95
+ <View style={[codeStyles.container, { backgroundColor: theme.colors.bgTertiary, borderColor: theme.colors.border }]}>
96
+ {language ? (
97
+ <View style={[codeStyles.header, { borderBottomColor: theme.colors.border }]}>
98
+ <Text style={[codeStyles.language, { color: theme.colors.textMuted }]}>{language}</Text>
99
+ </View>
100
+ ) : null}
101
+ <ScrollView horizontal showsHorizontalScrollIndicator={false}>
102
+ <Text style={[codeStyles.code, { color: theme.colors.text }]} selectable>
103
+ {children}
104
+ </Text>
105
+ </ScrollView>
106
+ </View>
107
+ );
108
+ }
109
+
110
+ const codeStyles = StyleSheet.create({
111
+ container: {
112
+ borderRadius: 8,
113
+ borderWidth: 1,
114
+ marginVertical: 8,
115
+ overflow: "hidden",
116
+ },
117
+ header: {
118
+ paddingHorizontal: 12,
119
+ paddingVertical: 6,
120
+ borderBottomWidth: 1,
121
+ },
122
+ language: {
123
+ fontSize: 11,
124
+ fontWeight: "600",
125
+ textTransform: "uppercase",
126
+ letterSpacing: 0.5,
127
+ },
128
+ code: {
129
+ fontFamily: "monospace",
130
+ fontSize: 13,
131
+ lineHeight: 20,
132
+ padding: 12,
133
+ },
134
+ });
mobile/components/PermissionCard.tsx ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from "react";
2
+ import { View, Text, TouchableOpacity, StyleSheet } from "react-native";
3
+ import { Shield, Check, X } from "lucide-react-native";
4
+ import { themes } from "../constants/themes";
5
+ import { getTheme } from "../lib/storage";
6
+ import type { PermissionRequest } from "../constants/types";
7
+
8
+ interface PermissionCardProps {
9
+ request: PermissionRequest;
10
+ onAllow: () => void;
11
+ onDeny: () => void;
12
+ }
13
+
14
+ export function PermissionCard({ request, onAllow, onDeny }: PermissionCardProps) {
15
+ const theme = themes[getTheme()];
16
+
17
+ return (
18
+ <View style={[styles.card, { backgroundColor: theme.colors.surface, borderColor: theme.colors.border }]}>
19
+ <View style={styles.header}>
20
+ <Shield size={16} color={theme.colors.warning} />
21
+ <Text style={[styles.title, { color: theme.colors.text }]}>Permission Required</Text>
22
+ </View>
23
+
24
+ <Text style={[styles.permission, { color: theme.colors.textSecondary }]}>
25
+ {request.permission}
26
+ </Text>
27
+
28
+ {request.description ? (
29
+ <Text style={[styles.description, { color: theme.colors.textMuted }]}>
30
+ {request.description}
31
+ </Text>
32
+ ) : null}
33
+
34
+ <View style={styles.actions}>
35
+ <TouchableOpacity
36
+ style={[styles.denyBtn, { backgroundColor: theme.colors.bgTertiary, borderColor: theme.colors.border }]}
37
+ onPress={onDeny}
38
+ activeOpacity={0.7}
39
+ >
40
+ <X size={16} color={theme.colors.danger} />
41
+ <Text style={[styles.denyText, { color: theme.colors.danger }]}>Deny</Text>
42
+ </TouchableOpacity>
43
+
44
+ <TouchableOpacity
45
+ style={[styles.allowBtn, { backgroundColor: theme.colors.success }]}
46
+ onPress={onAllow}
47
+ activeOpacity={0.7}
48
+ >
49
+ <Check size={16} color="#fff" />
50
+ <Text style={[styles.allowText, { color: "#fff" }]}>Allow</Text>
51
+ </TouchableOpacity>
52
+ </View>
53
+ </View>
54
+ );
55
+ }
56
+
57
+ const styles = StyleSheet.create({
58
+ card: {
59
+ padding: 16,
60
+ borderRadius: 12,
61
+ borderWidth: 1,
62
+ gap: 10,
63
+ },
64
+ header: {
65
+ flexDirection: "row",
66
+ alignItems: "center",
67
+ gap: 8,
68
+ },
69
+ title: {
70
+ fontSize: 15,
71
+ fontWeight: "600",
72
+ },
73
+ permission: {
74
+ fontSize: 14,
75
+ fontWeight: "500",
76
+ },
77
+ description: {
78
+ fontSize: 13,
79
+ lineHeight: 18,
80
+ },
81
+ actions: {
82
+ flexDirection: "row",
83
+ gap: 10,
84
+ marginTop: 4,
85
+ },
86
+ denyBtn: {
87
+ flex: 1,
88
+ flexDirection: "row",
89
+ alignItems: "center",
90
+ justifyContent: "center",
91
+ gap: 6,
92
+ paddingVertical: 10,
93
+ borderRadius: 8,
94
+ borderWidth: 1,
95
+ },
96
+ denyText: {
97
+ fontSize: 14,
98
+ fontWeight: "600",
99
+ },
100
+ allowBtn: {
101
+ flex: 1,
102
+ flexDirection: "row",
103
+ alignItems: "center",
104
+ justifyContent: "center",
105
+ gap: 6,
106
+ paddingVertical: 10,
107
+ borderRadius: 8,
108
+ },
109
+ allowText: {
110
+ fontSize: 14,
111
+ fontWeight: "600",
112
+ },
113
+ });
mobile/components/QuestionCard.tsx ADDED
@@ -0,0 +1,176 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState } from "react";
2
+ import { View, Text, TouchableOpacity, TextInput, StyleSheet } from "react-native";
3
+ import { HelpCircle, Send } from "lucide-react-native";
4
+ import { themes } from "../constants/themes";
5
+ import { getTheme } from "../lib/storage";
6
+ import type { QuestionRequest } from "../constants/types";
7
+
8
+ interface QuestionCardProps {
9
+ request: QuestionRequest;
10
+ onReply: (answer: string) => void;
11
+ onReject?: () => void;
12
+ }
13
+
14
+ export function QuestionCard({ request, onReply, onReject }: QuestionCardProps) {
15
+ const theme = themes[getTheme()];
16
+ const [selected, setSelected] = useState<string | null>(null);
17
+ const [customAnswer, setCustomAnswer] = useState("");
18
+
19
+ const handleSubmit = () => {
20
+ if (selected) {
21
+ onReply(selected);
22
+ } else if (customAnswer.trim()) {
23
+ onReply(customAnswer.trim());
24
+ }
25
+ };
26
+
27
+ const hasOptions = request.options && request.options.length > 0;
28
+
29
+ return (
30
+ <View style={[styles.card, { backgroundColor: theme.colors.surface, borderColor: theme.colors.border }]}>
31
+ <View style={styles.header}>
32
+ <HelpCircle size={16} color={theme.colors.primary} />
33
+ <Text style={[styles.title, { color: theme.colors.text }]}>Question</Text>
34
+ </View>
35
+
36
+ <Text style={[styles.question, { color: theme.colors.textSecondary }]}>
37
+ {request.question}
38
+ </Text>
39
+
40
+ {hasOptions ? (
41
+ <View style={styles.options}>
42
+ {request.options!.map((option) => {
43
+ const isSelected = selected === option;
44
+ return (
45
+ <TouchableOpacity
46
+ key={option}
47
+ style={[
48
+ styles.option,
49
+ {
50
+ backgroundColor: isSelected ? theme.colors.bgTertiary : theme.colors.bg,
51
+ borderColor: isSelected ? theme.colors.primary : theme.colors.border,
52
+ },
53
+ ]}
54
+ onPress={() => setSelected(option)}
55
+ activeOpacity={0.7}
56
+ >
57
+ <View
58
+ style={[
59
+ styles.radio,
60
+ {
61
+ borderColor: isSelected ? theme.colors.primary : theme.colors.textMuted,
62
+ backgroundColor: isSelected ? theme.colors.primary : "transparent",
63
+ },
64
+ ]}
65
+ />
66
+ <Text
67
+ style={[
68
+ styles.optionText,
69
+ { color: isSelected ? theme.colors.text : theme.colors.textSecondary },
70
+ ]}
71
+ >
72
+ {option}
73
+ </Text>
74
+ </TouchableOpacity>
75
+ );
76
+ })}
77
+ </View>
78
+ ) : (
79
+ <TextInput
80
+ style={[styles.input, { color: theme.colors.text, backgroundColor: theme.colors.bgTertiary, borderColor: theme.colors.border }]}
81
+ value={customAnswer}
82
+ onChangeText={setCustomAnswer}
83
+ placeholder="Type your answer..."
84
+ placeholderTextColor={theme.colors.textMuted}
85
+ multiline
86
+ />
87
+ )}
88
+
89
+ <TouchableOpacity
90
+ style={[
91
+ styles.submitBtn,
92
+ {
93
+ backgroundColor: selected || customAnswer.trim() ? theme.colors.primary : theme.colors.bgTertiary,
94
+ },
95
+ ]}
96
+ onPress={handleSubmit}
97
+ disabled={!selected && !customAnswer.trim()}
98
+ activeOpacity={0.7}
99
+ >
100
+ <Send size={14} color={selected || customAnswer.trim() ? theme.colors.primaryText : theme.colors.textMuted} />
101
+ <Text
102
+ style={[
103
+ styles.submitText,
104
+ { color: selected || customAnswer.trim() ? theme.colors.primaryText : theme.colors.textMuted },
105
+ ]}
106
+ >
107
+ Submit
108
+ </Text>
109
+ </TouchableOpacity>
110
+ </View>
111
+ );
112
+ }
113
+
114
+ const styles = StyleSheet.create({
115
+ card: {
116
+ padding: 16,
117
+ borderRadius: 12,
118
+ borderWidth: 1,
119
+ gap: 10,
120
+ },
121
+ header: {
122
+ flexDirection: "row",
123
+ alignItems: "center",
124
+ gap: 8,
125
+ },
126
+ title: {
127
+ fontSize: 15,
128
+ fontWeight: "600",
129
+ },
130
+ question: {
131
+ fontSize: 14,
132
+ lineHeight: 20,
133
+ },
134
+ options: {
135
+ gap: 6,
136
+ },
137
+ option: {
138
+ flexDirection: "row",
139
+ alignItems: "center",
140
+ gap: 10,
141
+ padding: 12,
142
+ borderRadius: 8,
143
+ borderWidth: 1,
144
+ },
145
+ radio: {
146
+ width: 16,
147
+ height: 16,
148
+ borderRadius: 8,
149
+ borderWidth: 2,
150
+ },
151
+ optionText: {
152
+ fontSize: 14,
153
+ flex: 1,
154
+ },
155
+ input: {
156
+ fontSize: 14,
157
+ paddingHorizontal: 12,
158
+ paddingVertical: 10,
159
+ borderRadius: 8,
160
+ borderWidth: 1,
161
+ minHeight: 44,
162
+ },
163
+ submitBtn: {
164
+ flexDirection: "row",
165
+ alignItems: "center",
166
+ justifyContent: "center",
167
+ gap: 6,
168
+ paddingVertical: 10,
169
+ borderRadius: 8,
170
+ marginTop: 4,
171
+ },
172
+ submitText: {
173
+ fontSize: 14,
174
+ fontWeight: "600",
175
+ },
176
+ });
mobile/components/ToolTimeline.tsx ADDED
@@ -0,0 +1,165 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from "react";
2
+ import { View, Text, StyleSheet } from "react-native";
3
+ import { Loader, Check, X, FileText, Search, Terminal, Globe, Wrench } from "lucide-react-native";
4
+ import { themes } from "../constants/themes";
5
+ import { getTheme } from "../lib/storage";
6
+ import type { ToolPart } from "../constants/types";
7
+
8
+ interface ToolTimelineProps {
9
+ parts: ToolPart[];
10
+ }
11
+
12
+ const toolIcons: Record<string, typeof Wrench> = {
13
+ read: FileText,
14
+ write: FileText,
15
+ edit: FileText,
16
+ glob: Search,
17
+ grep: Search,
18
+ bash: Terminal,
19
+ webfetch: Globe,
20
+ websearch: Globe,
21
+ };
22
+
23
+ const toolLabels: Record<string, string> = {
24
+ read: "Reading file",
25
+ write: "Writing file",
26
+ edit: "Editing file",
27
+ glob: "Searching files",
28
+ grep: "Searching content",
29
+ bash: "Running command",
30
+ webfetch: "Fetching URL",
31
+ websearch: "Searching web",
32
+ };
33
+
34
+ export function ToolTimeline({ parts }: ToolTimelineProps) {
35
+ const theme = themes[getTheme()];
36
+
37
+ if (parts.length === 0) return null;
38
+
39
+ return (
40
+ <View style={styles.container}>
41
+ {parts.map((part, index) => {
42
+ const Icon = toolIcons[part.tool] || Wrench;
43
+ const label = toolLabels[part.tool] || part.tool;
44
+ const isRunning = part.state === "call";
45
+ const isError = part.state === "error";
46
+ const isDone = part.state === "result";
47
+
48
+ return (
49
+ <View key={part.callID || index} style={styles.row}>
50
+ {/* Status indicator */}
51
+ <View
52
+ style={[
53
+ styles.indicator,
54
+ {
55
+ backgroundColor: isRunning
56
+ ? theme.colors.warning
57
+ : isError
58
+ ? theme.colors.danger
59
+ : theme.colors.success,
60
+ },
61
+ ]}
62
+ />
63
+
64
+ {/* Content */}
65
+ <View style={styles.content}>
66
+ <View style={styles.header}>
67
+ <Icon
68
+ size={14}
69
+ color={
70
+ isRunning
71
+ ? theme.colors.warning
72
+ : isError
73
+ ? theme.colors.danger
74
+ : theme.colors.success
75
+ }
76
+ />
77
+ <Text
78
+ style={[
79
+ styles.label,
80
+ {
81
+ color: isRunning
82
+ ? theme.colors.text
83
+ : theme.colors.textMuted,
84
+ },
85
+ ]}
86
+ numberOfLines={1}
87
+ >
88
+ {label}
89
+ </Text>
90
+
91
+ {isRunning && <Loader size={12} color={theme.colors.warning} />}
92
+ {isDone && <Check size={12} color={theme.colors.success} />}
93
+ {isError && <X size={12} color={theme.colors.danger} />}
94
+ </View>
95
+
96
+ {/* Tool title (if provided) */}
97
+ {part.title ? (
98
+ <Text
99
+ style={[styles.title, { color: theme.colors.textSecondary }]}
100
+ numberOfLines={1}
101
+ >
102
+ {part.title}
103
+ </Text>
104
+ ) : null}
105
+
106
+ {/* Show truncated input for certain tools */}
107
+ {part.input && (part.tool === "bash" || part.tool === "webfetch") && (
108
+ <Text
109
+ style={[styles.detail, { color: theme.colors.textMuted }]}
110
+ numberOfLines={1}
111
+ >
112
+ {part.tool === "bash"
113
+ ? `$ ${part.input.command || JSON.stringify(part.input).slice(0, 60)}`
114
+ : String(part.input.url || "").slice(0, 80)}
115
+ </Text>
116
+ )}
117
+ </View>
118
+ </View>
119
+ );
120
+ })}
121
+ </View>
122
+ );
123
+ }
124
+
125
+ const styles = StyleSheet.create({
126
+ container: {
127
+ gap: 2,
128
+ marginVertical: 4,
129
+ },
130
+ row: {
131
+ flexDirection: "row",
132
+ alignItems: "flex-start",
133
+ gap: 10,
134
+ },
135
+ indicator: {
136
+ width: 6,
137
+ height: 6,
138
+ borderRadius: 3,
139
+ marginTop: 6,
140
+ },
141
+ content: {
142
+ flex: 1,
143
+ gap: 2,
144
+ },
145
+ header: {
146
+ flexDirection: "row",
147
+ alignItems: "center",
148
+ gap: 6,
149
+ },
150
+ label: {
151
+ fontSize: 13,
152
+ fontWeight: "500",
153
+ flex: 1,
154
+ },
155
+ title: {
156
+ fontSize: 12,
157
+ marginLeft: 20,
158
+ },
159
+ detail: {
160
+ fontSize: 12,
161
+ fontFamily: "monospace",
162
+ marginLeft: 20,
163
+ opacity: 0.7,
164
+ },
165
+ });
mobile/constants/themes.ts ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Theme, ThemeName } from "./types";
2
+
3
+ export const themes: Record<ThemeName, Theme> = {
4
+ tokyo: {
5
+ name: "tokyo",
6
+ label: "Tokyo Night",
7
+ colors: {
8
+ bg: "#1a1b26",
9
+ bgSecondary: "#16161e",
10
+ bgTertiary: "#24283b",
11
+ surface: "#292e42",
12
+ border: "#3b4261",
13
+ text: "#c0caf5",
14
+ textSecondary: "#a9b1d6",
15
+ textMuted: "#565f89",
16
+ primary: "#7aa2f7",
17
+ primaryText: "#1a1b26",
18
+ secondary: "#bb9af7",
19
+ danger: "#f7768e",
20
+ success: "#9ece6a",
21
+ warning: "#e0af68",
22
+ accent: "#7dcfff",
23
+ },
24
+ },
25
+ dracula: {
26
+ name: "dracula",
27
+ label: "Dracula",
28
+ colors: {
29
+ bg: "#282a36",
30
+ bgSecondary: "#21222c",
31
+ bgTertiary: "#343746",
32
+ surface: "#44475a",
33
+ border: "#6272a4",
34
+ text: "#f8f8f2",
35
+ textSecondary: "#bd93f9",
36
+ textMuted: "#6272a4",
37
+ primary: "#bd93f9",
38
+ primaryText: "#282a36",
39
+ secondary: "#ff79c6",
40
+ danger: "#ff5555",
41
+ success: "#50fa7b",
42
+ warning: "#f1fa8c",
43
+ accent: "#8be9fd",
44
+ },
45
+ },
46
+ nord: {
47
+ name: "nord",
48
+ label: "Nord",
49
+ colors: {
50
+ bg: "#2e3440",
51
+ bgSecondary: "#3b4252",
52
+ bgTertiary: "#434c5e",
53
+ surface: "#4c566a",
54
+ border: "#4c566a",
55
+ text: "#eceff4",
56
+ textSecondary: "#d8dee9",
57
+ textMuted: "#7b88a1",
58
+ primary: "#88c0d0",
59
+ primaryText: "#2e3440",
60
+ secondary: "#b48ead",
61
+ danger: "#bf616a",
62
+ success: "#a3be8c",
63
+ warning: "#ebcb8b",
64
+ accent: "#8fbcbb",
65
+ },
66
+ },
67
+ gruvbox: {
68
+ name: "gruvbox",
69
+ label: "Gruvbox",
70
+ colors: {
71
+ bg: "#282828",
72
+ bgSecondary: "#1d2021",
73
+ bgTertiary: "#3c3836",
74
+ surface: "#504945",
75
+ border: "#665c54",
76
+ text: "#ebdbb2",
77
+ textSecondary: "#d5c4a1",
78
+ textMuted: "#928374",
79
+ primary: "#d79921",
80
+ primaryText: "#282828",
81
+ secondary: "#b8bb26",
82
+ danger: "#cc241d",
83
+ success: "#98971a",
84
+ warning: "#d65d0e",
85
+ accent: "#83a598",
86
+ },
87
+ },
88
+ onedark: {
89
+ name: "onedark",
90
+ label: "One Dark",
91
+ colors: {
92
+ bg: "#282c34",
93
+ bgSecondary: "#21252b",
94
+ bgTertiary: "#333842",
95
+ surface: "#3e4451",
96
+ border: "#4b5263",
97
+ text: "#abb2bf",
98
+ textSecondary: "#c8ccd4",
99
+ textMuted: "#636d83",
100
+ primary: "#61afef",
101
+ primaryText: "#282c34",
102
+ secondary: "#c678dd",
103
+ danger: "#e06c75",
104
+ success: "#98c379",
105
+ warning: "#e5c07b",
106
+ accent: "#56b6c2",
107
+ },
108
+ },
109
+ catppuccin: {
110
+ name: "catppuccin",
111
+ label: "Catppuccin Mocha",
112
+ colors: {
113
+ bg: "#1e1e2e",
114
+ bgSecondary: "#181825",
115
+ bgTertiary: "#313244",
116
+ surface: "#45475a",
117
+ border: "#585b70",
118
+ text: "#cdd6f4",
119
+ textSecondary: "#bac2de",
120
+ textMuted: "#6c7086",
121
+ primary: "#89b4fa",
122
+ primaryText: "#1e1e2e",
123
+ secondary: "#cba6f7",
124
+ danger: "#f38ba8",
125
+ success: "#a6e3a1",
126
+ warning: "#f9e2af",
127
+ accent: "#94e2d5",
128
+ },
129
+ },
130
+ };
131
+
132
+ export const defaultTheme: ThemeName = "tokyo";
mobile/constants/types.ts ADDED
@@ -0,0 +1,404 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Types derived from real OpenCode serve OpenAPI spec (v1.18.3)
2
+ // Endpoint prefix: /api/
3
+
4
+ // ── Health ──
5
+ export interface HealthResponse {
6
+ healthy: boolean;
7
+ version: string;
8
+ }
9
+
10
+ // ── Config ──
11
+ export interface OpenCodeConfig {
12
+ $schema?: string;
13
+ theme?: string;
14
+ provider?: Record<string, ProviderConfig>;
15
+ model?: Record<string, string>;
16
+ command?: Record<string, string>;
17
+ plugin?: Record<string, unknown>;
18
+ mcp?: Record<string, unknown>;
19
+ share?: unknown;
20
+ experimental?: unknown;
21
+ }
22
+
23
+ export interface ProviderConfig {
24
+ api?: string;
25
+ apiKey?: string;
26
+ models?: Record<string, unknown>;
27
+ options?: Record<string, unknown>;
28
+ }
29
+
30
+ // ── Provider ──
31
+ export interface Provider {
32
+ id: string;
33
+ name: string;
34
+ api?: string;
35
+ auth?: ProviderAuth;
36
+ models?: Model[];
37
+ env?: Record<string, string>;
38
+ }
39
+
40
+ export interface ProviderAuth {
41
+ type: "api_key" | "oauth";
42
+ api_key?: { env?: string; header?: string };
43
+ oauth?: { authorize_url: string; token_url: string; scopes: string[] };
44
+ }
45
+
46
+ // ── Model ──
47
+ export interface Model {
48
+ id: string;
49
+ name: string;
50
+ providerID: string;
51
+ context?: number;
52
+ maxTokens?: number;
53
+ reasoning?: boolean;
54
+ config?: Record<string, unknown>;
55
+ }
56
+
57
+ // ── Session ──
58
+ export interface Session {
59
+ id: string;
60
+ parentID?: string;
61
+ title: string;
62
+ agent?: string;
63
+ model?: string;
64
+ status: "idle" | "busy" | "error";
65
+ directory: string;
66
+ createdAt: string;
67
+ updatedAt: string;
68
+ }
69
+
70
+ export interface SessionStatus {
71
+ busy: boolean;
72
+ }
73
+
74
+ /** Paginated list response from /api/session */
75
+ export interface SessionListResponse {
76
+ data: Session[];
77
+ cursor?: {
78
+ previous?: string;
79
+ next?: string;
80
+ };
81
+ }
82
+
83
+ /** Model list response from /api/model */
84
+ export interface ModelListResponse {
85
+ location?: {
86
+ directory?: string;
87
+ project?: { id: string; directory: string };
88
+ };
89
+ data: Model[];
90
+ }
91
+
92
+ // ── Message ──
93
+ export interface Message {
94
+ id: string;
95
+ sessionID: string;
96
+ role: "user" | "assistant" | "tool" | "system";
97
+ parts: MessagePart[];
98
+ createdAt: string;
99
+ }
100
+
101
+ export type MessagePart =
102
+ | TextPart
103
+ | ToolPart
104
+ | StepPart
105
+ | ReasoningPart
106
+ | SourcePart;
107
+
108
+ export interface TextPart {
109
+ type: "text";
110
+ text: string;
111
+ }
112
+
113
+ export interface ToolPart {
114
+ type: "tool";
115
+ tool: string;
116
+ state: "call" | "result" | "error";
117
+ callID: string;
118
+ input?: Record<string, unknown>;
119
+ output?: string;
120
+ title?: string;
121
+ metadata?: Record<string, unknown>;
122
+ }
123
+
124
+ export interface StepPart {
125
+ type: "step";
126
+ name: string;
127
+ state: "pending" | "running" | "completed" | "error";
128
+ }
129
+
130
+ export interface ReasoningPart {
131
+ type: "reasoning";
132
+ text: string;
133
+ }
134
+
135
+ export interface SourcePart {
136
+ type: "source";
137
+ source: string;
138
+ title?: string;
139
+ }
140
+
141
+ // ── Events (SSE) ──
142
+ export type ServerEvent =
143
+ | SessionCreatedEvent
144
+ | SessionUpdatedEvent
145
+ | SessionDeletedEvent
146
+ | MessageCreatedEvent
147
+ | MessageUpdatedEvent
148
+ | MessagePartUpdatedEvent
149
+ | MessagePartDeletedEvent
150
+ | PermissionRequestedEvent
151
+ | PermissionUpdatedEvent
152
+ | QuestionAskedEvent
153
+ | QuestionAnsweredEvent
154
+ | FileChangedEvent
155
+ | TodoUpdatedEvent
156
+ | SyncEvent
157
+ | ErrorEvent;
158
+
159
+ export interface SessionCreatedEvent {
160
+ type: "session.created";
161
+ properties: Session;
162
+ }
163
+
164
+ export interface SessionUpdatedEvent {
165
+ type: "session.updated";
166
+ properties: Partial<Session> & { id: string };
167
+ }
168
+
169
+ export interface SessionDeletedEvent {
170
+ type: "session.deleted";
171
+ properties: { id: string };
172
+ }
173
+
174
+ export interface MessageCreatedEvent {
175
+ type: "message.created";
176
+ properties: Message;
177
+ }
178
+
179
+ export interface MessageUpdatedEvent {
180
+ type: "message.updated";
181
+ properties: Partial<Message> & { id: string };
182
+ }
183
+
184
+ export interface MessagePartUpdatedEvent {
185
+ type: "message.part.updated";
186
+ properties: {
187
+ messageID: string;
188
+ part: MessagePart;
189
+ sessionID: string;
190
+ };
191
+ }
192
+
193
+ export interface MessagePartDeletedEvent {
194
+ type: "message.part.deleted";
195
+ properties: {
196
+ messageID: string;
197
+ partID: string;
198
+ sessionID: string;
199
+ };
200
+ }
201
+
202
+ export interface PermissionRequestedEvent {
203
+ type: "permission.requested";
204
+ properties: PermissionRequest;
205
+ }
206
+
207
+ export interface PermissionUpdatedEvent {
208
+ type: "permission.updated";
209
+ properties: PermissionRequest;
210
+ }
211
+
212
+ export interface QuestionAskedEvent {
213
+ type: "question.asked";
214
+ properties: QuestionRequest;
215
+ }
216
+
217
+ export interface QuestionAnsweredEvent {
218
+ type: "question.answered";
219
+ properties: QuestionRequest;
220
+ }
221
+
222
+ export interface FileChangedEvent {
223
+ type: "file.changed";
224
+ properties: { path: string; status: string };
225
+ }
226
+
227
+ export interface TodoUpdatedEvent {
228
+ type: "todo.updated";
229
+ properties: { sessionID: string; todos: Todo[] };
230
+ }
231
+
232
+ export interface SyncEvent {
233
+ type: "sync";
234
+ properties: Record<string, unknown>;
235
+ }
236
+
237
+ export interface ErrorEvent {
238
+ type: "error";
239
+ properties: { message: string; code?: string };
240
+ }
241
+
242
+ // ── Permission ──
243
+ export interface PermissionRequest {
244
+ id: string;
245
+ sessionID: string;
246
+ messageID?: string;
247
+ partID?: string;
248
+ permission: string;
249
+ description?: string;
250
+ state: "pending" | "approved" | "denied";
251
+ createdAt: string;
252
+ }
253
+
254
+ export interface SavedPermission {
255
+ id: string;
256
+ permission: string;
257
+ pattern?: string;
258
+ allow: boolean;
259
+ }
260
+
261
+ // ── Question ──
262
+ export interface QuestionRequest {
263
+ id: string;
264
+ sessionID: string;
265
+ question: string;
266
+ options?: string[];
267
+ state: "pending" | "answered" | "rejected";
268
+ answer?: string;
269
+ createdAt: string;
270
+ }
271
+
272
+ // ── Todo ──
273
+ export interface Todo {
274
+ id: string;
275
+ content: string;
276
+ status: "pending" | "in_progress" | "completed";
277
+ metadata?: Record<string, unknown>;
278
+ }
279
+
280
+ // ── File ──
281
+ export interface FileEntry {
282
+ name: string;
283
+ path: string;
284
+ type: "file" | "directory";
285
+ size?: number;
286
+ modified?: string;
287
+ }
288
+
289
+ export interface FileContent {
290
+ content: string;
291
+ encoding?: string;
292
+ }
293
+
294
+ export interface FileStatus {
295
+ path: string;
296
+ status: string;
297
+ }
298
+
299
+ // ── Find/Search ──
300
+ export interface FindResult {
301
+ file: string;
302
+ line: number;
303
+ column: number;
304
+ match: string;
305
+ context: string;
306
+ }
307
+
308
+ export interface SymbolResult {
309
+ name: string;
310
+ kind: string;
311
+ file: string;
312
+ line: number;
313
+ column: number;
314
+ }
315
+
316
+ // ── PTY ──
317
+ export interface PTY {
318
+ id: string;
319
+ shell: string;
320
+ cwd: string;
321
+ pid?: number;
322
+ createdAt: string;
323
+ }
324
+
325
+ // ── Command ──
326
+ export interface Command {
327
+ name: string;
328
+ description?: string;
329
+ category?: string;
330
+ }
331
+
332
+ // ── Skill ──
333
+ export interface Skill {
334
+ name: string;
335
+ description?: string;
336
+ location?: string;
337
+ }
338
+
339
+ // ── Agent ──
340
+ export interface Agent {
341
+ name: string;
342
+ description?: string;
343
+ tools?: string[];
344
+ }
345
+
346
+ // ── Diff ──
347
+ export interface DiffEntry {
348
+ path: string;
349
+ status: string;
350
+ additions: number;
351
+ deletions: number;
352
+ hunks?: DiffHunk[];
353
+ }
354
+
355
+ export interface DiffHunk {
356
+ oldStart: number;
357
+ newStart: number;
358
+ oldLines: number;
359
+ newLines: number;
360
+ content: string;
361
+ }
362
+
363
+ // ── VCS ──
364
+ export interface VCSStatus {
365
+ branch: string;
366
+ dirty: boolean;
367
+ ahead: number;
368
+ behind: number;
369
+ }
370
+
371
+ // ── Context helpers ──
372
+ export interface SessionContext {
373
+ files: string[];
374
+ capabilities: string[];
375
+ }
376
+
377
+ export interface SessionHistory {
378
+ messages: Message[];
379
+ }
380
+
381
+ // ── Theme ──
382
+ export type ThemeName = "tokyo" | "dracula" | "nord" | "gruvbox" | "onedark" | "catppuccin";
383
+
384
+ export interface Theme {
385
+ name: ThemeName;
386
+ label: string;
387
+ colors: {
388
+ bg: string;
389
+ bgSecondary: string;
390
+ bgTertiary: string;
391
+ surface: string;
392
+ border: string;
393
+ text: string;
394
+ textSecondary: string;
395
+ textMuted: string;
396
+ primary: string;
397
+ primaryText: string;
398
+ secondary: string;
399
+ danger: string;
400
+ success: string;
401
+ warning: string;
402
+ accent: string;
403
+ };
404
+ }
mobile/hooks/useApi.ts ADDED
@@ -0,0 +1,322 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
2
+ import * as api from "../lib/api";
3
+
4
+ // ── Health ──
5
+
6
+ export function useHealth() {
7
+ return useQuery({
8
+ queryKey: ["health"],
9
+ queryFn: api.getHealth,
10
+ refetchInterval: 10000,
11
+ retry: 3,
12
+ });
13
+ }
14
+
15
+ // ── Config ──
16
+
17
+ export function useConfig() {
18
+ return useQuery({
19
+ queryKey: ["config"],
20
+ queryFn: api.getConfig,
21
+ });
22
+ }
23
+
24
+ export function usePatchConfig() {
25
+ const qc = useQueryClient();
26
+ return useMutation({
27
+ mutationFn: api.patchConfig,
28
+ onSuccess: () => qc.invalidateQueries({ queryKey: ["config"] }),
29
+ });
30
+ }
31
+
32
+ // ── Sessions ──
33
+
34
+ export function useSessions() {
35
+ return useQuery({
36
+ queryKey: ["sessions"],
37
+ queryFn: api.getSessions,
38
+ });
39
+ }
40
+
41
+ export function useActiveSessions() {
42
+ return useQuery({
43
+ queryKey: ["sessions", "active"],
44
+ queryFn: api.getActiveSessions,
45
+ });
46
+ }
47
+
48
+ export function useSession(id: string) {
49
+ return useQuery({
50
+ queryKey: ["session", id],
51
+ queryFn: () => api.getSession(id),
52
+ enabled: !!id,
53
+ });
54
+ }
55
+
56
+ export function useCreateSession() {
57
+ const qc = useQueryClient();
58
+ return useMutation({
59
+ mutationFn: (options?: { title?: string; agent?: string; model?: string; directory?: string }) =>
60
+ api.createSession(options),
61
+ onSuccess: () => qc.invalidateQueries({ queryKey: ["sessions"] }),
62
+ });
63
+ }
64
+
65
+ export function useDeleteSession() {
66
+ const qc = useQueryClient();
67
+ return useMutation({
68
+ mutationFn: (id: string) => api.deleteSession(id),
69
+ onSuccess: () => qc.invalidateQueries({ queryKey: ["sessions"] }),
70
+ });
71
+ }
72
+
73
+ export function usePatchSession() {
74
+ const qc = useQueryClient();
75
+ return useMutation({
76
+ mutationFn: ({ id, data }: { id: string; data: { title?: string; agent?: string; model?: string } }) =>
77
+ api.patchSession(id, data),
78
+ onSuccess: () => qc.invalidateQueries({ queryKey: ["sessions"] }),
79
+ });
80
+ }
81
+
82
+ export function useSessionTodos(sessionId: string) {
83
+ return useQuery({
84
+ queryKey: ["todos", sessionId],
85
+ queryFn: () => api.getSessionTodos(sessionId),
86
+ enabled: !!sessionId,
87
+ });
88
+ }
89
+
90
+ // ── Messages ──
91
+
92
+ export function useMessages(sessionId: string) {
93
+ return useQuery({
94
+ queryKey: ["messages", sessionId],
95
+ queryFn: () => api.getMessages(sessionId),
96
+ enabled: !!sessionId,
97
+ });
98
+ }
99
+
100
+ export function useSendMessage() {
101
+ const qc = useQueryClient();
102
+ return useMutation({
103
+ mutationFn: ({ sessionId, content }: { sessionId: string; content: string }) =>
104
+ api.sendMessage(sessionId, content),
105
+ onSuccess: (_, vars) => {
106
+ qc.invalidateQueries({ queryKey: ["messages", vars.sessionId] });
107
+ qc.invalidateQueries({ queryKey: ["sessions"] });
108
+ },
109
+ });
110
+ }
111
+
112
+ export function useAbortSession() {
113
+ return useMutation({
114
+ mutationFn: (sessionId: string) => api.abortSession(sessionId),
115
+ });
116
+ }
117
+
118
+ export function useCompactSession() {
119
+ const qc = useQueryClient();
120
+ return useMutation({
121
+ mutationFn: (sessionId: string) => api.compactSession(sessionId),
122
+ onSuccess: (_, sessionId) => {
123
+ qc.invalidateQueries({ queryKey: ["messages", sessionId] });
124
+ },
125
+ });
126
+ }
127
+
128
+ export function useShareSession() {
129
+ const qc = useQueryClient();
130
+ return useMutation({
131
+ mutationFn: (sessionId: string) => api.shareSession(sessionId),
132
+ onSuccess: () => qc.invalidateQueries({ queryKey: ["sessions"] }),
133
+ });
134
+ }
135
+
136
+ export function useForkSession() {
137
+ const qc = useQueryClient();
138
+ return useMutation({
139
+ mutationFn: (sessionId: string) => api.forkSession(sessionId),
140
+ onSuccess: () => qc.invalidateQueries({ queryKey: ["sessions"] }),
141
+ });
142
+ }
143
+
144
+ // ── Session diff / revert ──
145
+
146
+ export function useSessionDiff(sessionId: string) {
147
+ return useQuery({
148
+ queryKey: ["diff", sessionId],
149
+ queryFn: () => api.getSessionDiff(sessionId),
150
+ enabled: !!sessionId,
151
+ });
152
+ }
153
+
154
+ // ── Permissions ──
155
+
156
+ export function usePermissionRequests() {
157
+ return useQuery({
158
+ queryKey: ["permissions", "requests"],
159
+ queryFn: api.getPermissionRequests,
160
+ refetchInterval: 5000,
161
+ });
162
+ }
163
+
164
+ export function useSavedPermissions() {
165
+ return useQuery({
166
+ queryKey: ["permissions", "saved"],
167
+ queryFn: api.getSavedPermissions,
168
+ });
169
+ }
170
+
171
+ export function useReplyPermission() {
172
+ const qc = useQueryClient();
173
+ return useMutation({
174
+ mutationFn: ({
175
+ sessionId,
176
+ requestId,
177
+ allow,
178
+ }: {
179
+ sessionId: string;
180
+ requestId: string;
181
+ allow: boolean;
182
+ }) => api.replyPermission(sessionId, requestId, allow),
183
+ onSuccess: () => {
184
+ qc.invalidateQueries({ queryKey: ["permissions"] });
185
+ },
186
+ });
187
+ }
188
+
189
+ // ── Questions ──
190
+
191
+ export function useQuestionRequests() {
192
+ return useQuery({
193
+ queryKey: ["questions", "requests"],
194
+ queryFn: api.getQuestionRequests,
195
+ refetchInterval: 5000,
196
+ });
197
+ }
198
+
199
+ export function useReplyQuestion() {
200
+ const qc = useQueryClient();
201
+ return useMutation({
202
+ mutationFn: ({
203
+ sessionId,
204
+ requestId,
205
+ answer,
206
+ }: {
207
+ sessionId: string;
208
+ requestId: string;
209
+ answer: string;
210
+ }) => api.replyQuestion(sessionId, requestId, answer),
211
+ onSuccess: () => {
212
+ qc.invalidateQueries({ queryKey: ["questions"] });
213
+ },
214
+ });
215
+ }
216
+
217
+ // ── Providers / Models ──
218
+
219
+ export function useProviders() {
220
+ return useQuery({
221
+ queryKey: ["providers"],
222
+ queryFn: api.getProviders,
223
+ });
224
+ }
225
+
226
+ export function useModels() {
227
+ return useQuery({
228
+ queryKey: ["models"],
229
+ queryFn: api.getModels,
230
+ });
231
+ }
232
+
233
+ // ── Agent / Command / Skill ──
234
+
235
+ export function useAgents() {
236
+ return useQuery({
237
+ queryKey: ["agents"],
238
+ queryFn: api.getAgents,
239
+ });
240
+ }
241
+
242
+ export function useCommands() {
243
+ return useQuery({
244
+ queryKey: ["commands"],
245
+ queryFn: api.getCommands,
246
+ });
247
+ }
248
+
249
+ export function useSkills() {
250
+ return useQuery({
251
+ queryKey: ["skills"],
252
+ queryFn: api.getSkills,
253
+ });
254
+ }
255
+
256
+ // ── Files ──
257
+
258
+ export function useListFiles(dir: string = ".") {
259
+ return useQuery({
260
+ queryKey: ["files", dir],
261
+ queryFn: () => api.listFiles(dir),
262
+ });
263
+ }
264
+
265
+ export function useFileContent(path: string) {
266
+ return useQuery({
267
+ queryKey: ["fileContent", path],
268
+ queryFn: () => api.readFile(path),
269
+ enabled: !!path,
270
+ });
271
+ }
272
+
273
+ export function useFindFiles(query: string, dir?: string) {
274
+ return useQuery({
275
+ queryKey: ["findFiles", query, dir],
276
+ queryFn: () => api.findFiles(query, dir),
277
+ enabled: query.length > 0,
278
+ });
279
+ }
280
+
281
+ export function useFindSymbols(query: string) {
282
+ return useQuery({
283
+ queryKey: ["findSymbols", query],
284
+ queryFn: () => api.findSymbols(query),
285
+ enabled: query.length > 0,
286
+ });
287
+ }
288
+
289
+ // ── VCS ──
290
+
291
+ export function useVCSStatus() {
292
+ return useQuery({
293
+ queryKey: ["vcs"],
294
+ queryFn: api.getVCSStatus,
295
+ refetchInterval: 15000,
296
+ });
297
+ }
298
+
299
+ // ── PTY ──
300
+
301
+ export function usePTYs() {
302
+ return useQuery({
303
+ queryKey: ["pty"],
304
+ queryFn: api.getPTYs,
305
+ });
306
+ }
307
+
308
+ export function useCreatePTY() {
309
+ const qc = useQueryClient();
310
+ return useMutation({
311
+ mutationFn: (shell?: string) => api.createPTY(shell),
312
+ onSuccess: () => qc.invalidateQueries({ queryKey: ["pty"] }),
313
+ });
314
+ }
315
+
316
+ export function useDeletePTY() {
317
+ const qc = useQueryClient();
318
+ return useMutation({
319
+ mutationFn: (id: string) => api.deletePTY(id),
320
+ onSuccess: () => qc.invalidateQueries({ queryKey: ["pty"] }),
321
+ });
322
+ }
mobile/hooks/useConnection.ts ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect, useCallback, useRef } from "react";
2
+ import {
3
+ getConnectionManager,
4
+ type ConnectionState,
5
+ } from "../lib/connection-manager";
6
+ import type { ServerEvent } from "../constants/types";
7
+
8
+ export interface UseConnectionReturn {
9
+ state: ConnectionState;
10
+ connect: (sessionId?: string) => void;
11
+ disconnect: () => void;
12
+ switchSession: (sessionId: string | null) => void;
13
+ isConnected: boolean;
14
+ }
15
+
16
+ export function useConnection(sessionId?: string): UseConnectionReturn {
17
+ const [state, setState] = useState<ConnectionState>("disconnected");
18
+ const manager = useRef(getConnectionManager());
19
+
20
+ useEffect(() => {
21
+ const unsub = manager.current.onStateChange(setState);
22
+ return unsub;
23
+ }, []);
24
+
25
+ // Auto-connect on mount
26
+ useEffect(() => {
27
+ manager.current.connect(sessionId);
28
+ return () => {
29
+ // Don't disconnect on unmount — keep connection alive
30
+ };
31
+ }, [sessionId]);
32
+
33
+ const connect = useCallback(
34
+ (sid?: string) => manager.current.connect(sid ?? sessionId),
35
+ [sessionId]
36
+ );
37
+
38
+ const disconnect = useCallback(() => manager.current.disconnect(), []);
39
+
40
+ const switchSession = useCallback(
41
+ (sid: string | null) => manager.current.switchSession(sid),
42
+ []
43
+ );
44
+
45
+ return {
46
+ state,
47
+ connect,
48
+ disconnect,
49
+ switchSession,
50
+ isConnected: state === "connected",
51
+ };
52
+ }
53
+
54
+ export function useConnectionEvents(
55
+ onEvent: (event: ServerEvent) => void
56
+ ) {
57
+ const callbackRef = useRef(onEvent);
58
+ callbackRef.current = onEvent;
59
+
60
+ useEffect(() => {
61
+ const manager = getConnectionManager();
62
+ const unsub = manager.onEvent((event) => callbackRef.current(event));
63
+ return unsub;
64
+ }, []);
65
+ }
mobile/hooks/useSSE.ts ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useCallback, useRef, useEffect } from "react";
2
+ import { subscribeEvents, parseSSELines } from "../lib/api";
3
+ import type { ServerEvent, Message, MessagePart } from "../constants/types";
4
+
5
+ export interface SSEState {
6
+ connected: boolean;
7
+ streaming: boolean;
8
+ error: string | null;
9
+ events: ServerEvent[];
10
+ }
11
+
12
+ export function useSSE(sessionId?: string) {
13
+ const [state, setState] = useState<SSEState>({
14
+ connected: false,
15
+ streaming: false,
16
+ error: null,
17
+ events: [],
18
+ });
19
+
20
+ const abortRef = useRef<AbortController | null>(null);
21
+ const bufferRef = useRef("");
22
+ const reconnectTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
23
+
24
+ const connect = useCallback(async () => {
25
+ setState((s) => ({ ...s, error: null }));
26
+
27
+ try {
28
+ const response = await subscribeEvents(sessionId);
29
+ abortRef.current = new AbortController();
30
+ setState((s) => ({ ...s, connected: true, streaming: true }));
31
+
32
+ const reader = response.body?.getReader();
33
+ if (!reader) throw new Error("No readable stream");
34
+
35
+ const decoder = new TextDecoder();
36
+
37
+ while (true) {
38
+ const { done, value } = await reader.read();
39
+ if (done) break;
40
+
41
+ bufferRef.current += decoder.decode(value, { stream: true });
42
+ const { events, remaining } = parseSSELines(bufferRef.current);
43
+ bufferRef.current = remaining;
44
+
45
+ if (events.length > 0) {
46
+ setState((s) => ({
47
+ ...s,
48
+ events: [...s.events, ...events],
49
+ }));
50
+ }
51
+ }
52
+
53
+ setState((s) => ({ ...s, connected: false, streaming: false }));
54
+ } catch (err: unknown) {
55
+ if (err instanceof Error && err.name !== "AbortError") {
56
+ setState((s) => ({
57
+ ...s,
58
+ connected: false,
59
+ streaming: false,
60
+ error: err.message,
61
+ }));
62
+ }
63
+ }
64
+ }, [sessionId]);
65
+
66
+ const disconnect = useCallback(() => {
67
+ abortRef.current?.abort();
68
+ if (reconnectTimer.current) {
69
+ clearTimeout(reconnectTimer.current);
70
+ }
71
+ setState({ connected: false, streaming: false, error: null, events: [] });
72
+ }, []);
73
+
74
+ const clearEvents = useCallback(() => {
75
+ setState((s) => ({ ...s, events: [] }));
76
+ }, []);
77
+
78
+ // Auto-reconnect on disconnect
79
+ useEffect(() => {
80
+ if (!state.connected && !state.streaming && sessionId) {
81
+ reconnectTimer.current = setTimeout(() => {
82
+ connect();
83
+ }, 3000);
84
+ }
85
+ return () => {
86
+ if (reconnectTimer.current) clearTimeout(reconnectTimer.current);
87
+ };
88
+ }, [state.connected, state.streaming, sessionId, connect]);
89
+
90
+ // Cleanup on unmount
91
+ useEffect(() => {
92
+ return () => {
93
+ abortRef.current?.abort();
94
+ };
95
+ }, []);
96
+
97
+ return {
98
+ ...state,
99
+ connect,
100
+ disconnect,
101
+ clearEvents,
102
+ };
103
+ }
104
+
105
+ // ── Derived helpers ──
106
+
107
+ /** Extract the latest assistant message from events */
108
+ export function getLatestAssistantMessage(events: ServerEvent[]): Message | null {
109
+ for (let i = events.length - 1; i >= 0; i--) {
110
+ const ev = events[i];
111
+ if (ev.type === "message.created" || ev.type === "message.updated") {
112
+ const msg = ev.properties as Message;
113
+ if (msg.role === "assistant") return msg;
114
+ }
115
+ }
116
+ return null;
117
+ }
118
+
119
+ /** Extract all messages from events in order */
120
+ export function getMessagesFromEvents(events: ServerEvent[]): Message[] {
121
+ const map = new Map<string, Message>();
122
+
123
+ for (const ev of events) {
124
+ if (ev.type === "message.created") {
125
+ const msg = ev.properties as Message;
126
+ map.set(msg.id, msg);
127
+ } else if (ev.type === "message.updated") {
128
+ const partial = ev.properties as Partial<Message> & { id: string };
129
+ const existing = map.get(partial.id);
130
+ if (existing) {
131
+ map.set(partial.id, { ...existing, ...partial });
132
+ }
133
+ } else if (ev.type === "message.part.updated") {
134
+ const { messageID, part } = ev.properties as {
135
+ messageID: string;
136
+ part: MessagePart;
137
+ sessionID: string;
138
+ };
139
+ const existing = map.get(messageID);
140
+ if (existing) {
141
+ const partIdx = existing.parts.findIndex(
142
+ (p) => JSON.stringify(p) === JSON.stringify(part)
143
+ );
144
+ const newParts = [...existing.parts];
145
+ if (partIdx >= 0) {
146
+ newParts[partIdx] = part;
147
+ } else {
148
+ newParts.push(part);
149
+ }
150
+ map.set(messageID, { ...existing, parts: newParts });
151
+ }
152
+ }
153
+ }
154
+
155
+ return Array.from(map.values());
156
+ }
mobile/lib/api.ts ADDED
@@ -0,0 +1,434 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { getServerUrl, getAuthHeader, setServerUrl as persistServerUrl, setCredentials, clearAuth, setAuthType, setAuthValue } from "./storage";
2
+ import type { AuthType } from "./storage";
3
+ import type {
4
+ HealthResponse,
5
+ Session,
6
+ SessionStatus,
7
+ SessionListResponse,
8
+ Message,
9
+ MessagePart,
10
+ OpenCodeConfig,
11
+ Provider,
12
+ Model,
13
+ ModelListResponse,
14
+ FileEntry,
15
+ FileContent,
16
+ FindResult,
17
+ SymbolResult,
18
+ PTY,
19
+ Command,
20
+ Skill,
21
+ Agent,
22
+ DiffEntry,
23
+ VCSStatus,
24
+ PermissionRequest,
25
+ SavedPermission,
26
+ QuestionRequest,
27
+ Todo,
28
+ ServerEvent,
29
+ } from "../constants/types";
30
+
31
+ // ── Base request helper ──
32
+
33
+ async function request<T>(
34
+ path: string,
35
+ options?: RequestInit
36
+ ): Promise<T> {
37
+ const base = getServerUrl();
38
+ const authHeaders = getAuthHeader();
39
+ const headers: Record<string, string> = {
40
+ "Content-Type": "application/json",
41
+ ...authHeaders,
42
+ ...((options?.headers as Record<string, string>) || {}),
43
+ };
44
+
45
+ const res = await fetch(`${base}${path}`, {
46
+ ...options,
47
+ headers,
48
+ });
49
+
50
+ if (!res.ok) {
51
+ const text = await res.text();
52
+ throw new Error(text || `HTTP ${res.status} ${res.statusText}`);
53
+ }
54
+
55
+ const contentType = res.headers.get("content-type") || "";
56
+ if (contentType.includes("application/json")) {
57
+ return res.json();
58
+ }
59
+ return res.text() as unknown as T;
60
+ }
61
+
62
+ /** Extract data array from { location, data } or { data } response */
63
+ function extractData<T>(response: { data?: T[] } | T[]): T[] {
64
+ if (Array.isArray(response)) return response;
65
+ return response.data || [];
66
+ }
67
+
68
+ // ── Server URL / Credentials ──
69
+
70
+ export function setServerUrl(url: string) {
71
+ persistServerUrl(url);
72
+ }
73
+
74
+ export function setServerCredentials(username: string, password: string) {
75
+ setCredentials(username, password);
76
+ }
77
+
78
+ // ── Health ──
79
+
80
+ export function getHealth(): Promise<HealthResponse> {
81
+ return request("/api/health");
82
+ }
83
+
84
+ // ── Config ── (root endpoint, not /api/)
85
+
86
+ export function getConfig(): Promise<OpenCodeConfig> {
87
+ return request("/config");
88
+ }
89
+
90
+ export function patchConfig(config: Partial<OpenCodeConfig>): Promise<void> {
91
+ return request("/config", { method: "PATCH", body: JSON.stringify(config) });
92
+ }
93
+
94
+ // ── Sessions ──
95
+
96
+ export function getSessions(): Promise<Session[]> {
97
+ return request("/session");
98
+ }
99
+
100
+ export function getActiveSessions(): Promise<Session[]> {
101
+ return request("/session/active");
102
+ }
103
+
104
+ export function getSession(id: string): Promise<Session> {
105
+ return request(`/session/${id}`);
106
+ }
107
+
108
+ /** Get session via /api/ — returns {data: Session} */
109
+ export function getSessionApi(id: string): Promise<Session> {
110
+ return request<{ data: Session }>(`/api/session/${id}`).then((r) => r.data);
111
+ }
112
+
113
+ export function createSession(options?: {
114
+ title?: string;
115
+ agent?: string;
116
+ model?: string;
117
+ directory?: string;
118
+ }): Promise<Session> {
119
+ return request("/session", {
120
+ method: "POST",
121
+ body: JSON.stringify(options || {}),
122
+ });
123
+ }
124
+
125
+ export function deleteSession(id: string): Promise<void> {
126
+ return request(`/session/${id}`, { method: "DELETE" });
127
+ }
128
+
129
+ export function patchSession(
130
+ id: string,
131
+ data: { title?: string; agent?: string; model?: string }
132
+ ): Promise<Session> {
133
+ return request(`/session/${id}`, {
134
+ method: "PATCH",
135
+ body: JSON.stringify(data),
136
+ });
137
+ }
138
+
139
+ export function getSessionStatus(id: string): Promise<SessionStatus> {
140
+ return request(`/session/${id}`);
141
+ }
142
+
143
+ export function getSessionChildren(id: string): Promise<Session[]> {
144
+ return request<Session[]>(`/session/${id}/children`).catch(() => []);
145
+ }
146
+
147
+ export function getSessionTodos(id: string): Promise<Todo[]> {
148
+ return request(`/session/${id}/todo`);
149
+ }
150
+
151
+ // ── Messages ──
152
+
153
+ export function getMessages(sessionId: string): Promise<Message[]> {
154
+ return request(`/session/${sessionId}/message`);
155
+ }
156
+
157
+ export function getMessage(sessionId: string, messageId: string): Promise<Message> {
158
+ return request(`/session/${sessionId}/message/${messageId}`);
159
+ }
160
+
161
+ export function deleteMessage(sessionId: string, messageId: string): Promise<void> {
162
+ return request(`/session/${sessionId}/message/${messageId}`, {
163
+ method: "DELETE",
164
+ });
165
+ }
166
+
167
+ // ── Send message (triggers SSE response) ──
168
+
169
+ export interface MessageResponse {
170
+ info: {
171
+ id: string;
172
+ parentID?: string;
173
+ role: string;
174
+ sessionID: string;
175
+ modelID?: string;
176
+ providerID?: string;
177
+ time?: { created: number; completed?: number };
178
+ finish?: string;
179
+ };
180
+ parts: MessagePart[];
181
+ }
182
+
183
+ export function sendMessage(
184
+ sessionId: string,
185
+ content: string
186
+ ): Promise<MessageResponse> {
187
+ return request(`/session/${sessionId}/message`, {
188
+ method: "POST",
189
+ body: JSON.stringify({
190
+ parts: [{ type: "text", text: content }],
191
+ }),
192
+ });
193
+ }
194
+
195
+ // ── Session actions ──
196
+
197
+ export function abortSession(sessionId: string): Promise<void> {
198
+ return request(`/session/${sessionId}/abort`, { method: "POST" });
199
+ }
200
+
201
+ export function initSession(sessionId: string): Promise<void> {
202
+ return request(`/session/${sessionId}/init`, { method: "POST" });
203
+ }
204
+
205
+ export function compactSession(sessionId: string): Promise<void> {
206
+ return request(`/session/${sessionId}/compact`, { method: "POST" });
207
+ }
208
+
209
+ export function summarizeSession(sessionId: string): Promise<void> {
210
+ return request(`/session/${sessionId}/summarize`, { method: "POST" });
211
+ }
212
+
213
+ export function shareSession(sessionId: string): Promise<{ url?: string }> {
214
+ return request(`/session/${sessionId}/share`, { method: "POST" });
215
+ }
216
+
217
+ export function forkSession(sessionId: string): Promise<Session> {
218
+ return request(`/session/${sessionId}/fork`, { method: "POST" });
219
+ }
220
+
221
+ // ── Session diff / revert ──
222
+
223
+ export function getSessionDiff(sessionId: string): Promise<DiffEntry[]> {
224
+ return request(`/session/${sessionId}/diff`);
225
+ }
226
+
227
+ export function revertSessionStage(sessionId: string): Promise<void> {
228
+ return request(`/session/${sessionId}/revert`, { method: "POST" });
229
+ }
230
+
231
+ export function revertSessionClear(sessionId: string): Promise<void> {
232
+ return request(`/session/${sessionId}/unrevert`, { method: "POST" });
233
+ }
234
+
235
+ // ── Session history / context ──
236
+
237
+ export function getSessionHistory(sessionId: string): Promise<{ messages: Message[] }> {
238
+ return request(`/session/${sessionId}/history`);
239
+ }
240
+
241
+ export function getSessionContext(sessionId: string): Promise<{ files: string[]; capabilities: string[] }> {
242
+ return request(`/session/${sessionId}/context`);
243
+ }
244
+
245
+ // ── Permissions ──
246
+
247
+ export function getPermissionRequests(): Promise<PermissionRequest[]> {
248
+ return request("/permission");
249
+ }
250
+
251
+ export function getSavedPermissions(): Promise<SavedPermission[]> {
252
+ return request("/permission/saved");
253
+ }
254
+
255
+ export function deleteSavedPermission(id: string): Promise<void> {
256
+ return request(`/permission/saved/${id}`, { method: "DELETE" });
257
+ }
258
+
259
+ export function replyPermission(
260
+ sessionId: string,
261
+ requestId: string,
262
+ allow: boolean
263
+ ): Promise<void> {
264
+ return request(`/session/${sessionId}/permissions/${requestId}`, {
265
+ method: "POST",
266
+ body: JSON.stringify({ allow }),
267
+ });
268
+ }
269
+
270
+ // ── Questions ──
271
+
272
+ export function getQuestionRequests(): Promise<QuestionRequest[]> {
273
+ return request("/question");
274
+ }
275
+
276
+ export function getSessionQuestions(sessionId: string): Promise<QuestionRequest[]> {
277
+ return request(`/session/${sessionId}/question`);
278
+ }
279
+
280
+ export function replyQuestion(
281
+ sessionId: string,
282
+ requestId: string,
283
+ answer: string
284
+ ): Promise<void> {
285
+ return request(`/question/${requestId}/reply`, {
286
+ method: "POST",
287
+ body: JSON.stringify({ answer }),
288
+ });
289
+ }
290
+
291
+ export function rejectQuestion(
292
+ sessionId: string,
293
+ requestId: string
294
+ ): Promise<void> {
295
+ return request(`/question/${requestId}/reject`, {
296
+ method: "POST",
297
+ });
298
+ }
299
+
300
+ // ── Providers ──
301
+
302
+ export function getProviders(): Promise<Provider[]> {
303
+ return request<{ data: Provider[] }>("/api/provider").then((r) => r.data);
304
+ }
305
+
306
+ export function getProvider(id: string): Promise<Provider> {
307
+ return request(`/api/provider/${id}`);
308
+ }
309
+
310
+ // ── Models ──
311
+
312
+ export function getModels(): Promise<Model[]> {
313
+ return request<{ data: Model[] }>("/api/model").then((r) => r.data);
314
+ }
315
+
316
+ // ── Agent / Command / Skill ──
317
+
318
+ export function getAgents(): Promise<Agent[]> {
319
+ return request<{ data: Agent[] }>("/api/agent").then((r) => r.data);
320
+ }
321
+
322
+ export function getCommands(): Promise<Command[]> {
323
+ return request<{ data: Command[] }>("/api/command").then((r) => r.data);
324
+ }
325
+
326
+ export function getSkills(): Promise<Skill[]> {
327
+ return request<{ data: Skill[] }>("/api/skill").then((r) => r.data);
328
+ }
329
+
330
+ // ── Files ──
331
+
332
+ export function listFiles(dir: string = "."): Promise<FileEntry[]> {
333
+ return request<{ data: FileEntry[] }>(`/api/fs/list?path=${encodeURIComponent(dir)}`).then((r) => r.data);
334
+ }
335
+
336
+ export function readFile(filePath: string): Promise<FileContent> {
337
+ return request(`/file/content?path=${encodeURIComponent(filePath)}`);
338
+ }
339
+
340
+ export function findFiles(query: string, dir?: string): Promise<FindResult[]> {
341
+ const params = new URLSearchParams({ query });
342
+ if (dir) params.set("path", dir);
343
+ return request(`/find/file?${params.toString()}`);
344
+ }
345
+
346
+ export function findSymbols(query: string): Promise<SymbolResult[]> {
347
+ return request(`/find/symbol?query=${encodeURIComponent(query)}`);
348
+ }
349
+
350
+ // ── VCS ──
351
+
352
+ export function getVCSStatus(): Promise<VCSStatus> {
353
+ return request("/api/vcs/status");
354
+ }
355
+
356
+ // ── PTY ──
357
+
358
+ export function getPTYs(): Promise<PTY[]> {
359
+ return request<{ data: PTY[] }>("/api/pty").then((r) => r.data);
360
+ }
361
+
362
+ export function createPTY(shell?: string): Promise<PTY> {
363
+ return request("/api/pty", {
364
+ method: "POST",
365
+ body: JSON.stringify({ shell }),
366
+ });
367
+ }
368
+
369
+ export function getPTY(id: string): Promise<PTY> {
370
+ return request(`/api/pty/${id}`);
371
+ }
372
+
373
+ export function deletePTY(id: string): Promise<void> {
374
+ return request(`/api/pty/${id}`, { method: "DELETE" });
375
+ }
376
+
377
+ export function getPTYConnectToken(id: string): Promise<{ token: string }> {
378
+ return request(`/api/pty/${id}/connect-token`, { method: "POST" });
379
+ }
380
+
381
+ // ── SSE Event Stream (returns raw Response for manual parsing) ──
382
+
383
+ export async function subscribeEvents(
384
+ sessionId?: string
385
+ ): Promise<Response> {
386
+ const base = getServerUrl();
387
+ const authHeaders = getAuthHeader();
388
+ const url = sessionId
389
+ ? `${base}/session/${sessionId}/event`
390
+ : `${base}/event`;
391
+
392
+ const res = await fetch(url, {
393
+ headers: { Accept: "text/event-stream", ...authHeaders },
394
+ });
395
+
396
+ if (!res.ok) {
397
+ throw new Error(`SSE connection failed: ${res.status}`);
398
+ }
399
+
400
+ return res;
401
+ }
402
+
403
+ // ── SSE Parser helper ──
404
+
405
+ export function parseSSELines(
406
+ buffer: string
407
+ ): { events: ServerEvent[]; remaining: string } {
408
+ const events: ServerEvent[] = [];
409
+ const lines = buffer.split("\n");
410
+ const remaining = lines.pop() || "";
411
+
412
+ let eventType = "";
413
+ let eventData = "";
414
+
415
+ for (const line of lines) {
416
+ if (line.startsWith("event: ")) {
417
+ eventType = line.slice(7).trim();
418
+ } else if (line.startsWith("data: ")) {
419
+ eventData = line.slice(6);
420
+ } else if (line === "") {
421
+ if (eventType && eventData) {
422
+ try {
423
+ events.push(JSON.parse(eventData) as ServerEvent);
424
+ } catch {
425
+ // skip malformed
426
+ }
427
+ }
428
+ eventType = "";
429
+ eventData = "";
430
+ }
431
+ }
432
+
433
+ return { events, remaining };
434
+ }
mobile/lib/connection-manager.ts ADDED
@@ -0,0 +1,285 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { getHealth, subscribeEvents, parseSSELines } from "./api";
2
+ import { getServerUrl, getCredentials } from "./storage";
3
+ import type { ServerEvent } from "../constants/types";
4
+
5
+ export type ConnectionState =
6
+ | "disconnected"
7
+ | "connecting"
8
+ | "connected"
9
+ | "reconnecting"
10
+ | "error";
11
+
12
+ export type ConnectionListener = (state: ConnectionState) => void;
13
+ export type EventListener = (event: ServerEvent) => void;
14
+
15
+ interface ConnectionManagerOptions {
16
+ /** Max reconnect attempts before giving up (default: Infinity) */
17
+ maxRetries?: number;
18
+ /** Base delay in ms for exponential backoff (default: 1000) */
19
+ baseDelay?: number;
20
+ /** Max delay in ms (default: 30000) */
21
+ maxDelay?: number;
22
+ /** Health check interval in ms (default: 15000) */
23
+ heartbeatInterval?: number;
24
+ /** Timeout for health check in ms (default: 5000) */
25
+ healthTimeout?: number;
26
+ }
27
+
28
+ export class ConnectionManager {
29
+ private state: ConnectionState = "disconnected";
30
+ private retryCount = 0;
31
+ private maxRetries: number;
32
+ private baseDelay: number;
33
+ private maxDelay: number;
34
+ private heartbeatMs: number;
35
+ private healthTimeout: number;
36
+
37
+ private stateListeners: Set<ConnectionListener> = new Set();
38
+ private eventListeners: Set<EventListener> = new Set();
39
+
40
+ private heartbeatTimer: ReturnType<typeof setTimeout> | null = null;
41
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
42
+ private abortController: AbortController | null = null;
43
+ private sseBuffer = "";
44
+
45
+ /** Currently active SSE session subscription (null = global events) */
46
+ private activeSessionId: string | null = null;
47
+
48
+ constructor(options?: ConnectionManagerOptions) {
49
+ this.maxRetries = options?.maxRetries ?? Infinity;
50
+ this.baseDelay = options?.baseDelay ?? 1000;
51
+ this.maxDelay = options?.maxDelay ?? 30000;
52
+ this.heartbeatMs = options?.heartbeatInterval ?? 15000;
53
+ this.healthTimeout = options?.healthTimeout ?? 5000;
54
+ }
55
+
56
+ // ── State ──
57
+
58
+ getState(): ConnectionState {
59
+ return this.state;
60
+ }
61
+
62
+ isConnected(): boolean {
63
+ return this.state === "connected";
64
+ }
65
+
66
+ // ── Listeners ──
67
+
68
+ onStateChange(listener: ConnectionListener): () => void {
69
+ this.stateListeners.add(listener);
70
+ return () => this.stateListeners.delete(listener);
71
+ }
72
+
73
+ onEvent(listener: EventListener): () => void {
74
+ this.eventListeners.add(listener);
75
+ return () => this.eventListeners.delete(listener);
76
+ }
77
+
78
+ private setState(newState: ConnectionState) {
79
+ if (this.state === newState) return;
80
+ this.state = newState;
81
+ this.stateListeners.forEach((fn) => fn(newState));
82
+ }
83
+
84
+ private emitEvent(event: ServerEvent) {
85
+ this.eventListeners.forEach((fn) => fn(event));
86
+ }
87
+
88
+ // ── Connect ──
89
+
90
+ async connect(sessionId?: string) {
91
+ if (this.state === "connected" || this.state === "connecting") return;
92
+
93
+ this.activeSessionId = sessionId ?? null;
94
+ this.setState("connecting");
95
+
96
+ try {
97
+ await this.checkHealth();
98
+ this.retryCount = 0;
99
+ this.setState("connected");
100
+ this.startHeartbeat();
101
+ this.connectSSE();
102
+ } catch {
103
+ this.setState("error");
104
+ this.scheduleReconnect();
105
+ }
106
+ }
107
+
108
+ disconnect() {
109
+ this.stopHeartbeat();
110
+ this.stopReconnect();
111
+ this.abortController?.abort();
112
+ this.abortController = null;
113
+ this.sseBuffer = "";
114
+ this.retryCount = 0;
115
+ this.setState("disconnected");
116
+ }
117
+
118
+ // ── Health check ──
119
+
120
+ private async checkHealth(): Promise<void> {
121
+ const controller = new AbortController();
122
+ const timeout = setTimeout(() => controller.abort(), this.healthTimeout);
123
+
124
+ try {
125
+ const result = await Promise.race([
126
+ getHealth(),
127
+ new Promise<never>((_, reject) => {
128
+ controller.signal.addEventListener("abort", () =>
129
+ reject(new Error("Health check timeout"))
130
+ );
131
+ }),
132
+ ]);
133
+
134
+ clearTimeout(timeout);
135
+
136
+ if (!result.healthy) {
137
+ throw new Error("Server reports unhealthy");
138
+ }
139
+ } catch (err) {
140
+ clearTimeout(timeout);
141
+ throw err;
142
+ }
143
+ }
144
+
145
+ // ── Heartbeat ──
146
+
147
+ private startHeartbeat() {
148
+ this.stopHeartbeat();
149
+ this.heartbeatTimer = setInterval(async () => {
150
+ try {
151
+ await this.checkHealth();
152
+ // If we were in error state, recover
153
+ if (this.state === "error") {
154
+ this.retryCount = 0;
155
+ this.setState("connected");
156
+ this.connectSSE();
157
+ }
158
+ } catch {
159
+ if (this.state === "connected") {
160
+ this.setState("error");
161
+ this.scheduleReconnect();
162
+ }
163
+ }
164
+ }, this.heartbeatMs);
165
+ }
166
+
167
+ private stopHeartbeat() {
168
+ if (this.heartbeatTimer) {
169
+ clearInterval(this.heartbeatTimer);
170
+ this.heartbeatTimer = null;
171
+ }
172
+ }
173
+
174
+ // ── SSE ──
175
+
176
+ private connectSSE() {
177
+ this.abortController?.abort();
178
+ this.abortController = new AbortController();
179
+ this.sseBuffer = "";
180
+
181
+ const doConnect = async () => {
182
+ try {
183
+ const response = await subscribeEvents(this.activeSessionId ?? undefined);
184
+ const reader = response.body?.getReader();
185
+ if (!reader) throw new Error("No readable stream");
186
+
187
+ const decoder = new TextDecoder();
188
+
189
+ while (true) {
190
+ if (this.abortController?.signal.aborted) break;
191
+
192
+ const { done, value } = await reader.read();
193
+ if (done) break;
194
+
195
+ this.sseBuffer += decoder.decode(value, { stream: true });
196
+ const { events, remaining } = parseSSELines(this.sseBuffer);
197
+ this.sseBuffer = remaining;
198
+
199
+ for (const event of events) {
200
+ this.emitEvent(event);
201
+ }
202
+ }
203
+
204
+ // SSE ended normally — try reconnect
205
+ if (this.state === "connected") {
206
+ this.setState("error");
207
+ this.scheduleReconnect();
208
+ }
209
+ } catch (err) {
210
+ if (
211
+ this.abortController?.signal.aborted ||
212
+ this.state === "disconnected"
213
+ ) {
214
+ return;
215
+ }
216
+
217
+ console.warn("[ConnectionManager] SSE error:", err);
218
+ if (this.state === "connected") {
219
+ this.setState("error");
220
+ this.scheduleReconnect();
221
+ }
222
+ }
223
+ };
224
+
225
+ doConnect();
226
+ }
227
+
228
+ // ── Reconnect with exponential backoff ──
229
+
230
+ private scheduleReconnect() {
231
+ if (this.retryCount >= this.maxRetries) {
232
+ this.setState("disconnected");
233
+ return;
234
+ }
235
+
236
+ this.setState("reconnecting");
237
+
238
+ const delay = Math.min(
239
+ this.baseDelay * Math.pow(2, this.retryCount),
240
+ this.maxDelay
241
+ );
242
+ this.retryCount++;
243
+
244
+ this.reconnectTimer = setTimeout(() => {
245
+ this.connectSSE();
246
+ // Also check health
247
+ this.checkHealth()
248
+ .then(() => {
249
+ this.retryCount = 0;
250
+ this.setState("connected");
251
+ })
252
+ .catch(() => {
253
+ // Will retry again
254
+ });
255
+ }, delay);
256
+ }
257
+
258
+ private stopReconnect() {
259
+ if (this.reconnectTimer) {
260
+ clearTimeout(this.reconnectTimer);
261
+ this.reconnectTimer = null;
262
+ }
263
+ }
264
+
265
+ // ── Switch session subscription ──
266
+
267
+ switchSession(sessionId: string | null) {
268
+ if (this.activeSessionId === sessionId) return;
269
+ this.activeSessionId = sessionId;
270
+ if (this.state === "connected") {
271
+ this.connectSSE();
272
+ }
273
+ }
274
+ }
275
+
276
+ // ── Singleton ──
277
+
278
+ let instance: ConnectionManager | null = null;
279
+
280
+ export function getConnectionManager(options?: ConnectionManagerOptions): ConnectionManager {
281
+ if (!instance) {
282
+ instance = new ConnectionManager(options);
283
+ }
284
+ return instance;
285
+ }
mobile/lib/query-provider.tsx ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from "react";
2
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
3
+
4
+ const queryClient = new QueryClient({
5
+ defaultOptions: {
6
+ queries: {
7
+ staleTime: 5000,
8
+ refetchOnWindowFocus: false,
9
+ },
10
+ },
11
+ });
12
+
13
+ export function QueryProvider({ children }: { children: React.ReactNode }) {
14
+ return (
15
+ <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
16
+ );
17
+ }
mobile/lib/storage.ts ADDED
@@ -0,0 +1,134 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import AsyncStorage from "@react-native-async-storage/async-storage";
2
+ import { ThemeName } from "../constants/types";
3
+
4
+ export const storageKeys = {
5
+ serverUrl: "opencode_server_url",
6
+ authType: "opencode_auth_type",
7
+ authValue: "opencode_auth_value",
8
+ theme: "opencode_theme",
9
+ lastSessionId: "opencode_last_session_id",
10
+ } as const;
11
+
12
+ export type AuthType = "none" | "basic" | "bearer" | "apikey";
13
+
14
+ const cache: Record<string, string | null> = {};
15
+
16
+ async function get(key: string): Promise<string | null> {
17
+ if (cache[key] !== undefined) return cache[key];
18
+ const val = await AsyncStorage.getItem(key);
19
+ cache[key] = val;
20
+ return val;
21
+ }
22
+
23
+ async function set(key: string, value: string): Promise<void> {
24
+ cache[key] = value;
25
+ await AsyncStorage.setItem(key, value);
26
+ }
27
+
28
+ async function del(key: string): Promise<void> {
29
+ cache[key] = null;
30
+ await AsyncStorage.removeItem(key);
31
+ }
32
+
33
+ function getSync(key: string): string | null {
34
+ if (cache[key] !== undefined) return cache[key];
35
+ return null;
36
+ }
37
+
38
+ // Preload all keys on module load
39
+ const _loaded = (async () => {
40
+ const entries = await AsyncStorage.multiGet(Object.values(storageKeys));
41
+ for (const [k, v] of entries) {
42
+ cache[k] = v;
43
+ }
44
+ })();
45
+
46
+ // ── Server URL ──
47
+
48
+ export function getServerUrl(): string {
49
+ return getSync(storageKeys.serverUrl) || "http://127.0.0.1:4096";
50
+ }
51
+
52
+ export async function setServerUrl(url: string): Promise<void> {
53
+ await set(storageKeys.serverUrl, url);
54
+ }
55
+
56
+ // ── Auth ──
57
+
58
+ export function getAuthType(): AuthType {
59
+ return (getSync(storageKeys.authType) as AuthType) || "none";
60
+ }
61
+
62
+ export async function setAuthType(type: AuthType): Promise<void> {
63
+ await set(storageKeys.authType, type);
64
+ }
65
+
66
+ export function getAuthValue(): string {
67
+ return getSync(storageKeys.authValue) || "";
68
+ }
69
+
70
+ export async function setAuthValue(value: string): Promise<void> {
71
+ await set(storageKeys.authValue, value);
72
+ }
73
+
74
+ export function getCredentials(): { username: string; password: string } {
75
+ const val = getAuthValue();
76
+ if (getAuthType() === "basic" && val.includes(":")) {
77
+ const [username, ...rest] = val.split(":");
78
+ return { username, password: rest.join(":") };
79
+ }
80
+ return { username: "", password: "" };
81
+ }
82
+
83
+ export async function setCredentials(username: string, password: string): Promise<void> {
84
+ await setAuthType("basic");
85
+ await setAuthValue(`${username}:${password}`);
86
+ }
87
+
88
+ export async function clearAuth(): Promise<void> {
89
+ await del(storageKeys.authType);
90
+ await del(storageKeys.authValue);
91
+ }
92
+
93
+ export function getAuthHeader(): Record<string, string> {
94
+ const type = getAuthType();
95
+ const value = getAuthValue();
96
+
97
+ if (!value) return {};
98
+
99
+ switch (type) {
100
+ case "basic": {
101
+ const creds = getCredentials();
102
+ if (creds.username) {
103
+ return { Authorization: `Basic ${btoa(`${creds.username}:${creds.password}`)}` };
104
+ }
105
+ return {};
106
+ }
107
+ case "bearer":
108
+ return { Authorization: `Bearer ${value}` };
109
+ case "apikey":
110
+ return { "X-API-Key": value };
111
+ default:
112
+ return {};
113
+ }
114
+ }
115
+
116
+ // ── Theme ──
117
+
118
+ export function getTheme(): ThemeName {
119
+ return (getSync(storageKeys.theme) as ThemeName) || "tokyo";
120
+ }
121
+
122
+ export async function setTheme(theme: ThemeName): Promise<void> {
123
+ await set(storageKeys.theme, theme);
124
+ }
125
+
126
+ // ── Last session ──
127
+
128
+ export function getLastSessionId(): string | undefined {
129
+ return getSync(storageKeys.lastSessionId) || undefined;
130
+ }
131
+
132
+ export async function setLastSessionId(id: string): Promise<void> {
133
+ await set(storageKeys.lastSessionId, id);
134
+ }
mobile/package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
mobile/package.json ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "opencode-mobile",
3
+ "version": "1.0.0",
4
+ "main": "expo-router/entry",
5
+ "scripts": {
6
+ "start": "expo start",
7
+ "android": "expo start --android",
8
+ "ios": "expo start --ios",
9
+ "web": "expo start --web"
10
+ },
11
+ "dependencies": {
12
+ "@react-native-async-storage/async-storage": "2.1.2",
13
+ "@tanstack/react-query": "^5.60.6",
14
+ "expo": "~53.0.27",
15
+ "expo-asset": "~11.1.7",
16
+ "expo-constants": "~17.1.8",
17
+ "expo-font": "~13.3.2",
18
+ "expo-linking": "~7.1.7",
19
+ "expo-router": "~5.1.11",
20
+ "expo-secure-store": "~14.2.4",
21
+ "expo-splash-screen": "~0.30.10",
22
+ "expo-status-bar": "~2.2.3",
23
+ "expo-system-ui": "~5.0.11",
24
+ "lucide-react-native": "^0.460.0",
25
+ "react": "19.0.0",
26
+ "react-native": "0.79.6",
27
+ "react-native-gesture-handler": "~2.24.0",
28
+ "react-native-markdown-display": "^7.0.2",
29
+ "react-native-reanimated": "~3.17.4",
30
+ "react-native-safe-area-context": "5.4.0",
31
+ "react-native-screens": "~4.11.1",
32
+ "react-native-svg": "15.11.2"
33
+ },
34
+ "devDependencies": {
35
+ "@types/react": "~19.0.10",
36
+ "typescript": "~5.8.3"
37
+ }
38
+ }
mobile/tests/integration.test.ts ADDED
@@ -0,0 +1,251 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * End-to-end integration test for OpenCode Mobile client
3
+ * Run against a live opencode serve instance
4
+ *
5
+ * Usage: npx tsx mobile/tests/integration.test.ts
6
+ */
7
+
8
+ const BASE = "http://127.0.0.1:4096";
9
+
10
+ interface TestResult {
11
+ name: string;
12
+ passed: boolean;
13
+ error?: string;
14
+ duration: number;
15
+ }
16
+
17
+ const results: TestResult[] = [];
18
+
19
+ async function test(name: string, fn: () => Promise<void>) {
20
+ const start = Date.now();
21
+ try {
22
+ await fn();
23
+ results.push({ name, passed: true, duration: Date.now() - start });
24
+ console.log(` ✓ ${name} (${Date.now() - start}ms)`);
25
+ } catch (err: any) {
26
+ results.push({
27
+ name,
28
+ passed: false,
29
+ error: err.message,
30
+ duration: Date.now() - start,
31
+ });
32
+ console.log(` ✗ ${name}: ${err.message}`);
33
+ }
34
+ }
35
+
36
+ function assert(condition: boolean, msg: string) {
37
+ if (!condition) throw new Error(msg);
38
+ }
39
+
40
+ async function api<T = any>(path: string, opts?: RequestInit): Promise<T> {
41
+ const res = await fetch(`${BASE}${path}`, {
42
+ headers: { "Content-Type": "application/json", ...opts?.headers },
43
+ ...opts,
44
+ });
45
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
46
+ return res.json();
47
+ }
48
+
49
+ // ── Tests ──
50
+
51
+ async function run() {
52
+ console.log("\n=== OpenCode Integration Tests ===\n");
53
+
54
+ let sessionId: string;
55
+
56
+ // 1. Health
57
+ await test("GET /api/health", async () => {
58
+ const data = await api("/api/health");
59
+ assert(data.healthy === true, "not healthy");
60
+ });
61
+
62
+ // 2. Config (root endpoint — /api/config returns HTML)
63
+ await test("GET /config", async () => {
64
+ const data = await api("/config");
65
+ assert(typeof data === "object", "config is not object");
66
+ });
67
+
68
+ // 3. List sessions (root returns array)
69
+ await test("GET /session (list)", async () => {
70
+ const data = await api("/session");
71
+ assert(Array.isArray(data), "not array");
72
+ });
73
+
74
+ // 4. List sessions via /api/ (returns {data, cursor})
75
+ await test("GET /api/session (list)", async () => {
76
+ const data = await api("/api/session");
77
+ assert(Array.isArray(data.data), "data is not array");
78
+ });
79
+
80
+ // 5. Create session
81
+ await test("POST /session (create)", async () => {
82
+ const data = await api("/session", {
83
+ method: "POST",
84
+ body: JSON.stringify({ title: "Integration Test" }),
85
+ });
86
+ assert(data.id, "no id returned");
87
+ sessionId = data.id;
88
+ });
89
+
90
+ // 6. Get session (root)
91
+ await test("GET /session/{id}", async () => {
92
+ const data = await api(`/session/${sessionId}`);
93
+ assert(data.id === sessionId, "id mismatch");
94
+ });
95
+
96
+ // 7. Get session via /api/ (returns {data: Session})
97
+ await test("GET /api/session/{id}", async () => {
98
+ const data = await api(`/api/session/${sessionId}`);
99
+ assert(data.data?.id === sessionId, "id mismatch in data");
100
+ });
101
+
102
+ // 8. List messages (empty)
103
+ await test("GET /session/{id}/message (empty)", async () => {
104
+ const data = await api(`/session/${sessionId}/message`);
105
+ assert(Array.isArray(data), "not array");
106
+ });
107
+
108
+ // 9. Send message (requires {parts: [{type:'text', text:'...'}]})
109
+ await test("POST /session/{id}/message (send)", async () => {
110
+ const data = await api(`/session/${sessionId}/message`, {
111
+ method: "POST",
112
+ body: JSON.stringify({
113
+ parts: [{ type: "text", text: "Say exactly: integration test ok" }],
114
+ }),
115
+ });
116
+ assert(data.info || data.parts, "unexpected response shape");
117
+ });
118
+
119
+ // 10. SSE event stream (global endpoint)
120
+ await test("GET /event (SSE)", async () => {
121
+ const controller = new AbortController();
122
+ const timeout = setTimeout(() => controller.abort(), 4000);
123
+
124
+ const res = await fetch(`${BASE}/event`, {
125
+ headers: { Accept: "text/event-stream" },
126
+ signal: controller.signal,
127
+ });
128
+
129
+ clearTimeout(timeout);
130
+ assert(res.ok, `HTTP ${res.status}`);
131
+
132
+ const reader = res.body!.getReader();
133
+ const decoder = new TextDecoder();
134
+ let buffer = "";
135
+ let eventCount = 0;
136
+
137
+ const readTimeout = setTimeout(() => reader.cancel(), 3000);
138
+
139
+ while (eventCount < 1) {
140
+ const { done, value } = await reader.read();
141
+ if (done) break;
142
+ buffer += decoder.decode(value, { stream: true });
143
+ const lines = buffer.split("\n");
144
+ buffer = lines.pop() || "";
145
+ for (const line of lines) {
146
+ if (line.startsWith("data: ") || line.startsWith("event: ")) eventCount++;
147
+ }
148
+ }
149
+
150
+ clearTimeout(readTimeout);
151
+ reader.cancel();
152
+ assert(eventCount > 0, `no events (got ${eventCount})`);
153
+ });
154
+
155
+ // 11. Providers
156
+ await test("GET /api/provider", async () => {
157
+ const data = await api("/api/provider");
158
+ assert(Array.isArray(data.data), "data is not array");
159
+ });
160
+
161
+ // 12. Models
162
+ await test("GET /api/model", async () => {
163
+ const data = await api("/api/model");
164
+ assert(Array.isArray(data.data), "data is not array");
165
+ });
166
+
167
+ // 13. Agents
168
+ await test("GET /api/agent", async () => {
169
+ const data = await api("/api/agent");
170
+ assert(Array.isArray(data.data), "data is not array");
171
+ });
172
+
173
+ // 14. Commands
174
+ await test("GET /api/command", async () => {
175
+ const data = await api("/api/command");
176
+ assert(Array.isArray(data.data), "data is not array");
177
+ });
178
+
179
+ // 15. Skills
180
+ await test("GET /api/skill", async () => {
181
+ const data = await api("/api/skill");
182
+ assert(Array.isArray(data.data), "data is not array");
183
+ });
184
+
185
+ // 16. File list
186
+ await test("GET /api/fs/list", async () => {
187
+ const data = await api("/api/fs/list?path=.");
188
+ assert(Array.isArray(data.data), "data is not array");
189
+ assert(data.data.length > 0, "empty file list");
190
+ });
191
+
192
+ // 17. File content (root endpoint)
193
+ await test("GET /file/content", async () => {
194
+ const data = await api("/file/content?path=package.json");
195
+ assert(data.content || typeof data === "string", "no content");
196
+ });
197
+
198
+ // 18. Find files (root endpoint)
199
+ await test("GET /find/file", async () => {
200
+ const data = await api("/find/file?query=package");
201
+ assert(Array.isArray(data), "not array");
202
+ });
203
+
204
+ // 19. PTY list
205
+ await test("GET /api/pty", async () => {
206
+ const data = await api("/api/pty");
207
+ assert(Array.isArray(data.data), "data is not array");
208
+ });
209
+
210
+ // 20. VCS status
211
+ await test("GET /vcs/status", async () => {
212
+ const data = await api("/vcs/status");
213
+ assert(typeof data === "object", "not object");
214
+ });
215
+
216
+ // 21. Delete session
217
+ await test("DELETE /session/{id}", async () => {
218
+ await api(`/session/${sessionId}`, { method: "DELETE" });
219
+ });
220
+
221
+ // 22. Verify deleted
222
+ await test("GET /session/{id} after delete", async () => {
223
+ try {
224
+ await api(`/session/${sessionId}`);
225
+ assert(false, "should have thrown");
226
+ } catch {
227
+ // Expected
228
+ }
229
+ });
230
+
231
+ // Summary
232
+ console.log("\n=== Results ===\n");
233
+ const passed = results.filter((r) => r.passed).length;
234
+ const failed = results.filter((r) => !r.passed).length;
235
+ console.log(` ${passed} passed, ${failed} failed out of ${results.length} tests`);
236
+
237
+ if (failed > 0) {
238
+ console.log("\n Failed tests:");
239
+ results
240
+ .filter((r) => !r.passed)
241
+ .forEach((r) => console.log(` - ${r.name}: ${r.error}`));
242
+ }
243
+
244
+ console.log("");
245
+ process.exit(failed > 0 ? 1 : 0);
246
+ }
247
+
248
+ run().catch((err) => {
249
+ console.error("Fatal:", err);
250
+ process.exit(1);
251
+ });
mobile/tsconfig.json ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "extends": "expo/tsconfig.base",
3
+ "compilerOptions": {
4
+ "strict": true,
5
+ "paths": {
6
+ "@/*": [
7
+ "./*"
8
+ ]
9
+ }
10
+ },
11
+ "include": [
12
+ "**/*.ts",
13
+ "**/*.tsx"
14
+ ]
15
+ }