Initial commit: OpenCode mobile app (extracted from opencode monorepo)

This commit is contained in:
Ubuntu
2026-05-17 19:52:54 +00:00
commit 1843a7c37e
84 changed files with 9303 additions and 0 deletions

59
.github/workflows/build.yml vendored Normal file
View File

@@ -0,0 +1,59 @@
name: Build Android APK
on:
push:
branches: [main]
tags: ["v*"]
pull_request:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
- name: Setup Android SDK
uses: android-actions/setup-android@v3
- name: Install dependencies
run: npm install
- name: Expo prebuild
run: npx expo prebuild --platform android --no-install
- name: Build APK
working-directory: android
run: ./gradlew assembleRelease
- name: Upload APK artifact
uses: actions/upload-artifact@v4
with:
name: app-release
path: android/app/build/outputs/apk/release/app-release.apk
release:
needs: build
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/download-artifact@v4
with:
name: app-release
- name: Create Release
uses: softprops/action-gh-release@v2
with:
files: app-release.apk
generate_release_notes: true

12
.gitignore vendored Normal file
View File

@@ -0,0 +1,12 @@
node_modules/
.expo/
android/.gradle/
android/app/build/
android/build/
ios/Pods/
*.apk
*.aab
*.jks
*.keystore
.env
.env.*

59
AGENTS.md Normal file
View File

@@ -0,0 +1,59 @@
# Mobile App - AGENTS.md
## Overview
React Native / Expo mobile client for opencode. Connects to an opencode server instance via HTTP + SSE for real-time updates.
## Architecture
```
packages/mobile/
├── app/ # Expo Router file-based routing
│ ├── (tabs)/ # Tab navigation (sessions, connections, settings)
│ ├── session/[id].tsx # Chat screen
│ └── connection/ # Add/edit connection screens
├── src/
│ ├── components/ # Reusable UI components
│ │ ├── markdown/ # Markdown renderer (wraps react-native-marked)
│ │ └── AuthGate.tsx # Biometric auth gate
│ ├── lib/
│ │ ├── sdk.ts # HTTP + SSE client for opencode server API
│ │ └── types.ts # Re-exported types
│ └── stores/ # Zustand state stores
│ ├── sessions.ts # Session list, messages, parts
│ ├── connections.ts # Server connections, client lifecycle
│ ├── events.ts # SSE event stream, status tracking, permissions, questions
│ └── auth.ts # Biometric auth
```
## Key Patterns
- **SSE for real-time**: The `events.ts` store connects to `/global/event` and dispatches to other stores
- **Fire-and-forget sends**: `sendMessage` posts to the API but doesn't await response; SSE events drive all UI updates
- **Session status**: Derived from `session.status` events (`idle`/`busy`/`retry`) + last part type for status text
- **Markdown**: `react-native-marked` wrapped in our own `Markdown` component with custom `CodeBlock` (copy button). Designed to be swappable/publishable later.
## Style Guide
Follow the root repo AGENTS.md style guide:
- Prefer `const` over `let`
- Avoid `else` statements, use early returns
- Prefer single-word variable names
- Avoid `try/catch` where possible
- Avoid `any` type
- Use Bun APIs where applicable (for scripts, not in RN runtime)
## Running
```bash
cd packages/mobile
bun install
bun start # Expo dev server
bun run ios # iOS simulator
bun run android # Android emulator
```
## Connecting
Run `opencode serve --hostname 0.0.0.0 --port 4096` on your machine, then add a connection in the app with your machine's local IP and port 4096.

88
README.md Normal file
View File

@@ -0,0 +1,88 @@
# OpenCode Mobile
A React Native / Expo app for connecting to OpenCode servers from your phone.
## Features
- **Multiple Connection Types**: Connect via local network, tunnels (Cloudflare/ngrok), or cloud-hosted instances
- **Secure Authentication**: Biometric authentication (Face ID/Touch ID) support
- **Session Management**: View, create, and manage coding sessions
- **Real-time Chat**: Stream responses from your AI assistant
- **File Diff Viewer**: See what changes were made to your code
## Getting Started
### Prerequisites
- Node.js 18+
- Bun (recommended) or npm
- Expo Go app on your phone (for development)
### Installation
```bash
# From the monorepo root
cd packages/mobile
bun install
# Start the development server
bun start
```
### Connecting to OpenCode
1. Start OpenCode in server mode on your machine:
```bash
OPENCODE_SERVER_PASSWORD=yourpassword opencode serve --hostname 0.0.0.0 --port 4096
```
2. Open the app and add a connection:
- **Local Network**: Use your machine's local IP (e.g., `http://192.168.1.100:4096`)
- **Tunnel**: Set up a Cloudflare Tunnel or ngrok and use the tunnel URL
- **Cloud**: Connect to a hosted OpenCode instance
## Building for Production
### iOS
```bash
bun run ios
# or
eas build --platform ios
```
### Android
```bash
bun run android
# or
eas build --platform android
```
## Security
- Credentials are stored securely using `expo-secure-store` (iOS Keychain / Android Keystore)
- Optional biometric authentication for app access
- Optional biometric confirmation for sending messages
- All traffic should use HTTPS for non-local connections
## Architecture
```
packages/mobile/
├── app/ # Expo Router screens
│ ├── (tabs)/ # Tab navigation
│ ├── session/ # Session screens
│ └── connection/ # Connection management
├── src/
│ ├── components/ # Reusable components
│ ├── hooks/ # Custom hooks
│ ├── lib/ # SDK client & types
│ └── stores/ # Zustand state stores
└── assets/ # App icons & images
```
## Contributing
This is part of the OpenCode monorepo. See the root README for contribution guidelines.

16
android/.gitignore vendored Normal file
View File

@@ -0,0 +1,16 @@
# OSX
#
.DS_Store
# Android/IntelliJ
#
build/
.idea
.gradle
local.properties
*.iml
*.hprof
.cxx/
# Bundle artifacts
*.jsbundle

182
android/app/build.gradle Normal file
View File

@@ -0,0 +1,182 @@
apply plugin: "com.android.application"
apply plugin: "org.jetbrains.kotlin.android"
apply plugin: "com.facebook.react"
def projectRoot = rootDir.getAbsoluteFile().getParentFile().getAbsolutePath()
/**
* This is the configuration block to customize your React Native Android app.
* By default you don't need to apply any configuration, just uncomment the lines you need.
*/
react {
entryFile = file(["node", "-e", "require('expo/scripts/resolveAppEntry')", projectRoot, "android", "absolute"].execute(null, rootDir).text.trim())
reactNativeDir = new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile()
hermesCommand = new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).getParentFile().getAbsolutePath() + "/sdks/hermesc/%OS-BIN%/hermesc"
codegenDir = new File(["node", "--print", "require.resolve('@react-native/codegen/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile()
enableBundleCompression = (findProperty('android.enableBundleCompression') ?: false).toBoolean()
// Use Expo CLI to bundle the app, this ensures the Metro config
// works correctly with Expo projects.
cliFile = new File(["node", "--print", "require.resolve('@expo/cli', { paths: [require.resolve('expo/package.json')] })"].execute(null, rootDir).text.trim())
bundleCommand = "export:embed"
/* Folders */
// The root of your project, i.e. where "package.json" lives. Default is '../..'
// root = file("../../")
// The folder where the react-native NPM package is. Default is ../../node_modules/react-native
// reactNativeDir = file("../../node_modules/react-native")
// The folder where the react-native Codegen package is. Default is ../../node_modules/@react-native/codegen
// codegenDir = file("../../node_modules/@react-native/codegen")
/* Variants */
// The list of variants to that are debuggable. For those we're going to
// skip the bundling of the JS bundle and the assets. By default is just 'debug'.
// If you add flavors like lite, prod, etc. you'll have to list your debuggableVariants.
// debuggableVariants = ["liteDebug", "prodDebug"]
/* Bundling */
// A list containing the node command and its flags. Default is just 'node'.
// nodeExecutableAndArgs = ["node"]
//
// The path to the CLI configuration file. Default is empty.
// bundleConfig = file(../rn-cli.config.js)
//
// The name of the generated asset file containing your JS bundle
// bundleAssetName = "MyApplication.android.bundle"
//
// The entry file for bundle generation. Default is 'index.android.js' or 'index.js'
// entryFile = file("../js/MyApplication.android.js")
//
// A list of extra flags to pass to the 'bundle' commands.
// See https://github.com/react-native-community/cli/blob/main/docs/commands.md#bundle
// extraPackagerArgs = []
/* Hermes Commands */
// The hermes compiler command to run. By default it is 'hermesc'
// hermesCommand = "$rootDir/my-custom-hermesc/bin/hermesc"
//
// The list of flags to pass to the Hermes compiler. By default is "-O", "-output-source-map"
// hermesFlags = ["-O", "-output-source-map"]
/* Autolinking */
autolinkLibrariesWithApp()
}
/**
* Set this to true in release builds to optimize the app using [R8](https://developer.android.com/topic/performance/app-optimization/enable-app-optimization).
*/
def enableMinifyInReleaseBuilds = (findProperty('android.enableMinifyInReleaseBuilds') ?: false).toBoolean()
/**
* The preferred build flavor of JavaScriptCore (JSC)
*
* For example, to use the international variant, you can use:
* `def jscFlavor = 'org.webkit:android-jsc-intl:+'`
*
* The international variant includes ICU i18n library and necessary data
* allowing to use e.g. `Date.toLocaleString` and `String.localeCompare` that
* give correct results when using with locales other than en-US. Note that
* this variant is about 6MiB larger per architecture than default.
*/
def jscFlavor = 'io.github.react-native-community:jsc-android:2026004.+'
android {
ndkVersion rootProject.ext.ndkVersion
buildToolsVersion rootProject.ext.buildToolsVersion
compileSdk rootProject.ext.compileSdkVersion
namespace 'ai.opencode.mobile'
defaultConfig {
applicationId 'ai.opencode.mobile'
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "1.0.0"
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
}
signingConfigs {
debug {
storeFile file('debug.keystore')
storePassword 'android'
keyAlias 'androiddebugkey'
keyPassword 'android'
}
}
buildTypes {
debug {
signingConfig signingConfigs.debug
}
release {
// Caution! In production, you need to generate your own keystore file.
// see https://reactnative.dev/docs/signed-apk-android.
signingConfig signingConfigs.debug
def enableShrinkResources = findProperty('android.enableShrinkResourcesInReleaseBuilds') ?: 'false'
shrinkResources enableShrinkResources.toBoolean()
minifyEnabled enableMinifyInReleaseBuilds
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
def enablePngCrunchInRelease = findProperty('android.enablePngCrunchInReleaseBuilds') ?: 'true'
crunchPngs enablePngCrunchInRelease.toBoolean()
}
}
packagingOptions {
jniLibs {
def enableLegacyPackaging = findProperty('expo.useLegacyPackaging') ?: 'false'
useLegacyPackaging enableLegacyPackaging.toBoolean()
}
}
androidResources {
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:!CVS:!thumbs.db:!picasa.ini:!*~'
}
}
// Apply static values from `gradle.properties` to the `android.packagingOptions`
// Accepts values in comma delimited lists, example:
// android.packagingOptions.pickFirsts=/LICENSE,**/picasa.ini
["pickFirsts", "excludes", "merges", "doNotStrip"].each { prop ->
// Split option: 'foo,bar' -> ['foo', 'bar']
def options = (findProperty("android.packagingOptions.$prop") ?: "").split(",");
// Trim all elements in place.
for (i in 0..<options.size()) options[i] = options[i].trim();
// `[] - ""` is essentially `[""].filter(Boolean)` removing all empty strings.
options -= ""
if (options.length > 0) {
println "android.packagingOptions.$prop += $options ($options.length)"
// Ex: android.packagingOptions.pickFirsts += '**/SCCS/**'
options.each {
android.packagingOptions[prop] += it
}
}
}
dependencies {
// The version of react-native is set by the React Native Gradle Plugin
implementation("com.facebook.react:react-android")
def isGifEnabled = (findProperty('expo.gif.enabled') ?: "") == "true";
def isWebpEnabled = (findProperty('expo.webp.enabled') ?: "") == "true";
def isWebpAnimatedEnabled = (findProperty('expo.webp.animated') ?: "") == "true";
if (isGifEnabled) {
// For animated gif support
implementation("com.facebook.fresco:animated-gif:${expoLibs.versions.fresco.get()}")
}
if (isWebpEnabled) {
// For webp support
implementation("com.facebook.fresco:webpsupport:${expoLibs.versions.fresco.get()}")
if (isWebpAnimatedEnabled) {
// Animated webp support
implementation("com.facebook.fresco:animated-webp:${expoLibs.versions.fresco.get()}")
}
}
if (hermesEnabled.toBoolean()) {
implementation("com.facebook.react:hermes-android")
} else {
implementation jscFlavor
}
}

14
android/app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,14 @@
# Add project specific ProGuard rules here.
# By default, the flags in this file are appended to flags specified
# in /usr/local/Cellar/android-sdk/24.3.3/tools/proguard/proguard-android.txt
# You can edit the include path and order by changing the proguardFiles
# directive in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# react-native-reanimated
-keep class com.swmansion.reanimated.** { *; }
-keep class com.facebook.react.turbomodule.** { *; }
# Add any project specific keep options here:

View File

@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
<application android:usesCleartextTraffic="true" tools:targetApi="28" tools:ignore="GoogleAppIndexingWarning" tools:replace="android:usesCleartextTraffic" />
</manifest>

View File

@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
<application android:usesCleartextTraffic="true" tools:targetApi="28" tools:ignore="GoogleAppIndexingWarning" tools:replace="android:usesCleartextTraffic" />
</manifest>

View File

@@ -0,0 +1,34 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
<uses-permission android:name="android.permission.USE_BIOMETRIC"/>
<uses-permission android:name="android.permission.USE_FINGERPRINT"/>
<uses-permission android:name="android.permission.VIBRATE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<queries>
<intent>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="https"/>
</intent>
</queries>
<application android:name=".MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="true" android:theme="@style/AppTheme" android:supportsRtl="true" android:enableOnBackInvokedCallback="false" android:fullBackupContent="@xml/secure_store_backup_rules" android:dataExtractionRules="@xml/secure_store_data_extraction_rules">
<meta-data android:name="expo.modules.updates.ENABLED" android:value="false"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_CHECK_ON_LAUNCH" android:value="ALWAYS"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS" android:value="0"/>
<activity android:name=".MainActivity" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|uiMode" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.App.SplashScreen" android:exported="true" android:screenOrientation="portrait">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="opencode"/>
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -0,0 +1,61 @@
package ai.opencode.mobile
import android.os.Build
import android.os.Bundle
import com.facebook.react.ReactActivity
import com.facebook.react.ReactActivityDelegate
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled
import com.facebook.react.defaults.DefaultReactActivityDelegate
import expo.modules.ReactActivityDelegateWrapper
class MainActivity : ReactActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
// Set the theme to AppTheme BEFORE onCreate to support
// coloring the background, status bar, and navigation bar.
// This is required for expo-splash-screen.
setTheme(R.style.AppTheme);
super.onCreate(null)
}
/**
* Returns the name of the main component registered from JavaScript. This is used to schedule
* rendering of the component.
*/
override fun getMainComponentName(): String = "main"
/**
* Returns the instance of the [ReactActivityDelegate]. We use [DefaultReactActivityDelegate]
* which allows you to enable New Architecture with a single boolean flags [fabricEnabled]
*/
override fun createReactActivityDelegate(): ReactActivityDelegate {
return ReactActivityDelegateWrapper(
this,
BuildConfig.IS_NEW_ARCHITECTURE_ENABLED,
object : DefaultReactActivityDelegate(
this,
mainComponentName,
fabricEnabled
){})
}
/**
* Align the back button behavior with Android S
* where moving root activities to background instead of finishing activities.
* @see <a href="https://developer.android.com/reference/android/app/Activity#onBackPressed()">onBackPressed</a>
*/
override fun invokeDefaultOnBackPressed() {
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.R) {
if (!moveTaskToBack(false)) {
// For non-root activities, use the default implementation to finish them.
super.invokeDefaultOnBackPressed()
}
return
}
// Use the default back button implementation on Android S
// because it's doing more than [Activity.moveTaskToBack] in fact.
super.invokeDefaultOnBackPressed()
}
}

View File

@@ -0,0 +1,56 @@
package ai.opencode.mobile
import android.app.Application
import android.content.res.Configuration
import com.facebook.react.PackageList
import com.facebook.react.ReactApplication
import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative
import com.facebook.react.ReactNativeHost
import com.facebook.react.ReactPackage
import com.facebook.react.ReactHost
import com.facebook.react.common.ReleaseLevel
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint
import com.facebook.react.defaults.DefaultReactNativeHost
import expo.modules.ApplicationLifecycleDispatcher
import expo.modules.ReactNativeHostWrapper
class MainApplication : Application(), ReactApplication {
override val reactNativeHost: ReactNativeHost = ReactNativeHostWrapper(
this,
object : DefaultReactNativeHost(this) {
override fun getPackages(): List<ReactPackage> =
PackageList(this).packages.apply {
// Packages that cannot be autolinked yet can be added manually here, for example:
// add(MyReactNativePackage())
}
override fun getJSMainModuleName(): String = ".expo/.virtual-metro-entry"
override fun getUseDeveloperSupport(): Boolean = BuildConfig.DEBUG
override val isNewArchEnabled: Boolean = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED
}
)
override val reactHost: ReactHost
get() = ReactNativeHostWrapper.createReactHost(applicationContext, reactNativeHost)
override fun onCreate() {
super.onCreate()
DefaultNewArchitectureEntryPoint.releaseLevel = try {
ReleaseLevel.valueOf(BuildConfig.REACT_NATIVE_RELEASE_LEVEL.uppercase())
} catch (e: IllegalArgumentException) {
ReleaseLevel.STABLE
}
loadReactNative(this)
ApplicationLifecycleDispatcher.onApplicationCreate(this)
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
ApplicationLifecycleDispatcher.onConfigurationChanged(this, newConfig)
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

View File

@@ -0,0 +1,6 @@
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@color/splashscreen_background"/>
<item>
<bitmap android:gravity="center" android:src="@drawable/splashscreen_logo"/>
</item>
</layer-list>

View File

@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright (C) 2014 The Android Open Source Project
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<inset xmlns:android="http://schemas.android.com/apk/res/android"
android:insetLeft="@dimen/abc_edit_text_inset_horizontal_material"
android:insetRight="@dimen/abc_edit_text_inset_horizontal_material"
android:insetTop="@dimen/abc_edit_text_inset_top_material"
android:insetBottom="@dimen/abc_edit_text_inset_bottom_material"
>
<selector>
<!--
This file is a copy of abc_edit_text_material (https://bit.ly/3k8fX7I).
The item below with state_pressed="false" and state_focused="false" causes a NullPointerException.
NullPointerException:tempt to invoke virtual method 'android.graphics.drawable.Drawable android.graphics.drawable.Drawable$ConstantState.newDrawable(android.content.res.Resources)'
<item android:state_pressed="false" android:state_focused="false" android:drawable="@drawable/abc_textfield_default_mtrl_alpha"/>
For more info, see https://bit.ly/3CdLStv (react-native/pull/29452) and https://bit.ly/3nxOMoR.
-->
<item android:state_enabled="false" android:drawable="@drawable/abc_textfield_default_mtrl_alpha"/>
<item android:drawable="@drawable/abc_textfield_activated_mtrl_alpha"/>
</selector>
</inset>

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1 @@
<resources/>

View File

@@ -0,0 +1,5 @@
<resources>
<color name="splashscreen_background">#FFFFFF</color>
<color name="colorPrimary">#023c69</color>
<color name="colorPrimaryDark">#ffffff</color>
</resources>

View File

@@ -0,0 +1,3 @@
<resources>
<string name="app_name">OpenCode</string>
</resources>

View File

@@ -0,0 +1,11 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<style name="AppTheme" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="android:enforceNavigationBarContrast" tools:targetApi="29">true</item>
<item name="android:editTextBackground">@drawable/rn_edit_text_material</item>
<item name="colorPrimary">@color/colorPrimary</item>
<item name="android:statusBarColor">#ffffff</item>
</style>
<style name="Theme.App.SplashScreen" parent="AppTheme">
<item name="android:windowBackground">@drawable/ic_launcher_background</item>
</style>
</resources>

24
android/build.gradle Normal file
View File

@@ -0,0 +1,24 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
repositories {
google()
mavenCentral()
}
dependencies {
classpath('com.android.tools.build:gradle')
classpath('com.facebook.react:react-native-gradle-plugin')
classpath('org.jetbrains.kotlin:kotlin-gradle-plugin')
}
}
allprojects {
repositories {
google()
mavenCentral()
maven { url 'https://www.jitpack.io' }
}
}
apply plugin: "expo-root-project"
apply plugin: "com.facebook.react.rootproject"

65
android/gradle.properties Normal file
View File

@@ -0,0 +1,65 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
# Default value: -Xmx512m -XX:MaxMetaspaceSize=256m
org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
# Enable AAPT2 PNG crunching
android.enablePngCrunchInReleaseBuilds=true
# Use this property to specify which architecture you want to build.
# You can also override it from the CLI using
# ./gradlew <task> -PreactNativeArchitectures=x86_64
reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64
# Use this property to enable support to the new architecture.
# This will allow you to use TurboModules and the Fabric render in
# your application. You should enable this flag either if you want
# to write custom TurboModules/Fabric components OR use libraries that
# are providing them.
newArchEnabled=true
# Use this property to enable or disable the Hermes JS engine.
# If set to false, you will be using JSC instead.
hermesEnabled=true
# Use this property to enable edge-to-edge display support.
# This allows your app to draw behind system bars for an immersive UI.
# Note: Only works with ReactActivity and should not be used with custom Activity.
edgeToEdgeEnabled=true
# Enable GIF support in React Native images (~200 B increase)
expo.gif.enabled=true
# Enable webp support in React Native images (~85 KB increase)
expo.webp.enabled=true
# Enable animated webp support (~3.4 MB increase)
# Disabled by default because iOS doesn't support animated webp
expo.webp.animated=false
# Enable network inspector
EX_DEV_CLIENT_NETWORK_INSPECTOR=true
# Use legacy packaging to compress native libraries in the resulting APK.
expo.useLegacyPackaging=false
# Specifies whether the app is configured to use edge-to-edge via the app config or plugin
# WARNING: This property has been deprecated and will be removed in Expo SDK 55. Use `edgeToEdgeEnabled` or `react.edgeToEdgeEnabled` to determine whether the project is using edge-to-edge.
expo.edgeToEdgeEnabled=true

Binary file not shown.

View File

@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

251
android/gradlew vendored Executable file
View File

@@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

94
android/gradlew.bat vendored Normal file
View File

@@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

39
android/settings.gradle Normal file
View File

@@ -0,0 +1,39 @@
pluginManagement {
def reactNativeGradlePlugin = new File(
providers.exec {
workingDir(rootDir)
commandLine("node", "--print", "require.resolve('@react-native/gradle-plugin/package.json', { paths: [require.resolve('react-native/package.json')] })")
}.standardOutput.asText.get().trim()
).getParentFile().absolutePath
includeBuild(reactNativeGradlePlugin)
def expoPluginsPath = new File(
providers.exec {
workingDir(rootDir)
commandLine("node", "--print", "require.resolve('expo-modules-autolinking/package.json', { paths: [require.resolve('expo/package.json')] })")
}.standardOutput.asText.get().trim(),
"../android/expo-gradle-plugin"
).absolutePath
includeBuild(expoPluginsPath)
}
plugins {
id("com.facebook.react.settings")
id("expo-autolinking-settings")
}
extensions.configure(com.facebook.react.ReactSettingsExtension) { ex ->
if (System.getenv('EXPO_USE_COMMUNITY_AUTOLINKING') == '1') {
ex.autolinkLibrariesFromCommand()
} else {
ex.autolinkLibrariesFromCommand(expoAutolinking.rnConfigCommand)
}
}
expoAutolinking.useExpoModules()
rootProject.name = 'OpenCode'
expoAutolinking.useExpoVersionCatalog()
include ':app'
includeBuild(expoAutolinking.reactNativeGradlePlugin)

29
app.json Normal file
View File

@@ -0,0 +1,29 @@
{
"expo": {
"name": "OpenCode",
"slug": "opencode-mobile",
"version": "1.0.0",
"orientation": "portrait",
"scheme": "opencode",
"userInterfaceStyle": "automatic",
"ios": {
"supportsTablet": true,
"bundleIdentifier": "ai.opencode.mobile",
"infoPlist": {
"NSFaceIDUsageDescription": "Use Face ID to unlock OpenCode and access your sessions securely."
}
},
"android": {
"package": "ai.opencode.mobile"
},
"web": {
"bundler": "metro",
"output": "static"
},
"plugins": ["expo-router", "expo-secure-store", "expo-local-authentication"],
"experiments": {
"typedRoutes": true,
"reactCompiler": true
}
}
}

47
app/(tabs)/_layout.tsx Normal file
View File

@@ -0,0 +1,47 @@
import { Tabs } from "expo-router"
import { useColorScheme } from "react-native"
import { Ionicons } from "@expo/vector-icons"
export default function TabLayout() {
const colorScheme = useColorScheme()
const isDark = colorScheme === "dark"
return (
<Tabs
screenOptions={{
tabBarActiveTintColor: isDark ? "#ffffff" : "#0a0a0a",
tabBarInactiveTintColor: isDark ? "#666666" : "#999999",
tabBarStyle: {
backgroundColor: isDark ? "#0a0a0a" : "#ffffff",
borderTopColor: isDark ? "#1a1a1a" : "#e5e5e5",
},
headerStyle: {
backgroundColor: isDark ? "#0a0a0a" : "#ffffff",
},
headerTintColor: isDark ? "#ffffff" : "#0a0a0a",
}}
>
<Tabs.Screen
name="index"
options={{
title: "Sessions",
tabBarIcon: ({ color, size }) => <Ionicons name="chatbubbles-outline" size={size} color={color} />,
}}
/>
<Tabs.Screen
name="connections"
options={{
title: "Connections",
tabBarIcon: ({ color, size }) => <Ionicons name="server-outline" size={size} color={color} />,
}}
/>
<Tabs.Screen
name="settings"
options={{
title: "Settings",
tabBarIcon: ({ color, size }) => <Ionicons name="settings-outline" size={size} color={color} />,
}}
/>
</Tabs>
)
}

387
app/(tabs)/connections.tsx Normal file
View File

@@ -0,0 +1,387 @@
import { View, Text, FlatList, TouchableOpacity, StyleSheet, useColorScheme, Alert } from "react-native"
import { router } from "expo-router"
import { Ionicons } from "@expo/vector-icons"
import { useConnections } from "../../src/stores/connections"
import { useSettings } from "../../src/stores/settings"
import type { ServerConnection } from "../../src/lib/types"
const PAGE_SIZE_OPTIONS = [10, 25, 50, 100, 200] as const
function ConnectionItem({
connection,
isDark,
isActive,
onSelect,
onEdit,
onDelete,
}: {
connection: ServerConnection
isDark: boolean
isActive: boolean
onSelect: () => void
onEdit: () => void
onDelete: () => void
}) {
const typeIcon = connection.type === "local" ? "wifi" : connection.type === "tunnel" ? "globe" : "cloud"
const handleLongPress = () => {
Alert.alert(connection.name, "What would you like to do?", [
{ text: "Cancel", style: "cancel" },
{ text: "Edit", onPress: onEdit },
{ text: "Delete", style: "destructive", onPress: onDelete },
])
}
return (
<TouchableOpacity
style={[
styles.connectionItem,
isDark && styles.connectionItemDark,
isActive && styles.connectionItemActive,
isActive && isDark && styles.connectionItemActiveDark,
]}
onPress={onSelect}
onLongPress={handleLongPress}
>
<View style={styles.connectionIcon}>
<Ionicons name={typeIcon} size={24} color={isActive ? "#22c55e" : isDark ? "#888888" : "#666666"} />
</View>
<View style={styles.connectionContent}>
<View style={styles.connectionHeader}>
<Text
style={[
styles.connectionName,
isDark && styles.textDark,
isActive && styles.connectionNameActive,
isActive && isDark && styles.connectionNameActiveDark,
]}
>
{connection.name}
</Text>
{isActive && (
<View style={[styles.activeBadge, isDark && styles.activeBadgeDark]}>
<Text style={[styles.activeBadgeText, isDark && styles.activeBadgeTextDark]}>Active</Text>
</View>
)}
</View>
<Text
style={[styles.connectionUrl, isDark && styles.metaDark, isActive && styles.connectionUrlActive]}
numberOfLines={1}
>
{connection.url}
</Text>
{connection.lastConnected && (
<Text style={[styles.connectionMeta, isDark && styles.metaDark]}>
Last connected: {new Date(connection.lastConnected).toLocaleDateString()}
</Text>
)}
</View>
<TouchableOpacity onPress={onEdit} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}>
<Ionicons name="ellipsis-vertical" size={20} color={isDark ? "#666666" : "#999999"} />
</TouchableOpacity>
</TouchableOpacity>
)
}
export default function ConnectionsScreen() {
const colorScheme = useColorScheme()
const isDark = colorScheme === "dark"
const { connections, activeConnection, setActiveConnection, removeConnection } = useConnections()
const { pageSize, setPageSize } = useSettings()
const handleDelete = (connection: ServerConnection) => {
Alert.alert("Delete Connection", `Are you sure you want to delete "${connection.name}"?`, [
{ text: "Cancel", style: "cancel" },
{
text: "Delete",
style: "destructive",
onPress: () => removeConnection(connection.id),
},
])
}
return (
<View style={[styles.container, isDark && styles.containerDark]}>
<FlatList
data={connections}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<ConnectionItem
connection={item}
isDark={isDark}
isActive={activeConnection?.id === item.id}
onSelect={() => setActiveConnection(item.id)}
onEdit={() => router.push(`/connection/${item.id}`)}
onDelete={() => handleDelete(item)}
/>
)}
ListEmptyComponent={
<View style={styles.emptyContainer}>
<Ionicons name="server-outline" size={64} color={isDark ? "#444444" : "#cccccc"} />
<Text style={[styles.emptyTitle, isDark && styles.textDark]}>No Connections</Text>
<Text style={[styles.emptySubtitle, isDark && styles.metaDark]}>
Add a connection to your OpenCode server
</Text>
</View>
}
ListHeaderComponent={
<View style={[styles.header, isDark && styles.headerDark]}>
<Text style={[styles.headerText, isDark && styles.metaDark]}>Tap to switch, long press for options</Text>
</View>
}
ListFooterComponent={
<View style={[styles.settingsSection, isDark && styles.settingsSectionDark]}>
<Text style={[styles.settingsTitle, isDark && styles.textDark]}>Preferences</Text>
<View style={styles.settingRow}>
<View style={styles.settingLabel}>
<Ionicons name="layers-outline" size={18} color={isDark ? "#888888" : "#666666"} />
<Text style={[styles.settingText, isDark && styles.textDark]}>Messages per page</Text>
</View>
<View style={styles.pagePicker}>
{PAGE_SIZE_OPTIONS.map((size) => (
<TouchableOpacity
key={size}
style={[
styles.pageOption,
isDark && styles.pageOptionDark,
pageSize === size && styles.pageOptionActive,
pageSize === size && isDark && styles.pageOptionActiveDark,
]}
onPress={() => setPageSize(size)}
>
<Text
style={[
styles.pageOptionText,
isDark && styles.metaDark,
pageSize === size && styles.pageOptionTextActive,
]}
>
{size}
</Text>
</TouchableOpacity>
))}
</View>
</View>
<Text style={[styles.settingHint, isDark && styles.metaDark]}>
How many messages to load when opening a session. Lower = faster.
</Text>
</View>
}
contentContainerStyle={connections.length === 0 ? styles.emptyContent : undefined}
/>
{/* FAB to add connection */}
<TouchableOpacity style={[styles.fab, isDark && styles.fabDark]} onPress={() => router.push("/connection/add")}>
<Ionicons name="add" size={28} color={isDark ? "#0a0a0a" : "#ffffff"} />
</TouchableOpacity>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#ffffff",
},
containerDark: {
backgroundColor: "#0a0a0a",
},
header: {
padding: 16,
borderBottomWidth: 1,
borderBottomColor: "#e5e5e5",
},
headerDark: {
borderBottomColor: "#1a1a1a",
},
headerText: {
fontSize: 13,
color: "#666666",
},
connectionItem: {
flexDirection: "row",
alignItems: "center",
padding: 16,
borderBottomWidth: 1,
borderBottomColor: "#e5e5e5",
},
connectionItemDark: {
borderBottomColor: "#1a1a1a",
},
connectionItemActive: {
backgroundColor: "#f0fdf4",
},
connectionItemActiveDark: {
backgroundColor: "#1a2e1a",
},
connectionIcon: {
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: "#f5f5f5",
justifyContent: "center",
alignItems: "center",
marginRight: 12,
},
connectionContent: {
flex: 1,
},
connectionHeader: {
flexDirection: "row",
alignItems: "center",
gap: 8,
},
connectionName: {
fontSize: 16,
fontWeight: "600",
color: "#0a0a0a",
},
connectionNameActive: {
color: "#0a0a0a",
},
connectionNameActiveDark: {
color: "#ffffff",
},
textDark: {
color: "#ffffff",
},
activeBadge: {
backgroundColor: "#22c55e",
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 4,
},
activeBadgeDark: {
backgroundColor: "#16a34a",
},
activeBadgeText: {
color: "#ffffff",
fontSize: 11,
fontWeight: "600",
},
activeBadgeTextDark: {
color: "#ffffff",
},
connectionUrl: {
fontSize: 13,
color: "#666666",
marginTop: 2,
},
connectionUrlActive: {
color: "#888888",
},
connectionMeta: {
fontSize: 12,
color: "#999999",
marginTop: 4,
},
metaDark: {
color: "#888888",
},
emptyContainer: {
flex: 1,
justifyContent: "center",
alignItems: "center",
padding: 32,
},
emptyTitle: {
fontSize: 20,
fontWeight: "600",
marginTop: 16,
color: "#0a0a0a",
},
emptySubtitle: {
fontSize: 14,
color: "#666666",
marginTop: 8,
textAlign: "center",
},
emptyContent: {
flex: 1,
},
fab: {
position: "absolute",
right: 16,
bottom: 16,
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: "#0a0a0a",
justifyContent: "center",
alignItems: "center",
shadowColor: "#000",
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.25,
shadowRadius: 4,
elevation: 5,
},
fabDark: {
backgroundColor: "#ffffff",
},
settingsSection: {
padding: 16,
borderTopWidth: 1,
borderTopColor: "#e5e5e5",
marginTop: 16,
gap: 10,
},
settingsSectionDark: {
borderTopColor: "#1a1a1a",
},
settingsTitle: {
fontSize: 15,
fontWeight: "700",
color: "#0a0a0a",
},
settingRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
},
settingLabel: {
flexDirection: "row",
alignItems: "center",
gap: 8,
},
settingText: {
fontSize: 14,
color: "#0a0a0a",
},
pagePicker: {
flexDirection: "row",
gap: 6,
},
pageOption: {
paddingHorizontal: 10,
paddingVertical: 5,
borderRadius: 8,
borderWidth: 1,
borderColor: "#e5e5e5",
backgroundColor: "#f5f5f5",
},
pageOptionDark: {
borderColor: "#2a2a2a",
backgroundColor: "#1a1a1a",
},
pageOptionActive: {
backgroundColor: "#0a0a0a",
borderColor: "#0a0a0a",
},
pageOptionActiveDark: {
backgroundColor: "#3b82f6",
borderColor: "#3b82f6",
},
pageOptionText: {
fontSize: 13,
fontWeight: "500",
color: "#666666",
},
pageOptionTextActive: {
color: "#ffffff",
},
settingHint: {
fontSize: 12,
color: "#999999",
},
})

836
app/(tabs)/index.tsx Normal file
View File

@@ -0,0 +1,836 @@
import { useEffect, useCallback, useState, useRef } from "react"
import {
View,
Text,
FlatList,
TouchableOpacity,
StyleSheet,
useColorScheme,
RefreshControl,
ActivityIndicator,
Modal,
TextInput,
Alert,
KeyboardAvoidingView,
Platform,
} from "react-native"
import { router } from "expo-router"
import { Ionicons } from "@expo/vector-icons"
import { useSessions } from "../../src/stores/sessions"
import { useConnections } from "../../src/stores/connections"
import type BottomSheet from "@gorhom/bottom-sheet"
import type { Session } from "../../src/lib/sdk"
import { DirectorySwitcher } from "../../src/components/chat"
function formatTime(timestamp: number): string {
const date = new Date(timestamp)
const now = new Date()
const diff = now.getTime() - date.getTime()
if (diff < 60000) return "Just now"
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`
if (diff < 604800000) return `${Math.floor(diff / 86400000)}d ago`
return date.toLocaleDateString()
}
function SessionItem({
session,
isDark,
onRename,
onDelete,
}: {
session: Session
isDark: boolean
onRename: () => void
onDelete: () => void
}) {
const onPress = () => {
router.push(`/session/${session.id}`)
}
const onLongPress = () => {
Alert.alert(session.title || "Untitled Session", undefined, [
{ text: "Cancel", style: "cancel" },
{ text: "Rename", onPress: onRename },
{ text: "Delete", style: "destructive", onPress: onDelete },
])
}
// Extract short directory name from session
const shortDir = session.directory ? session.directory.split("/").filter(Boolean).pop() : null
return (
<TouchableOpacity
style={[styles.sessionItem, isDark && styles.sessionItemDark]}
onPress={onPress}
onLongPress={onLongPress}
>
<View style={styles.sessionContent}>
<View style={styles.sessionHeader}>
<Text style={[styles.sessionTitle, isDark && styles.textDark]} numberOfLines={1}>
{session.title || "Untitled Session"}
</Text>
</View>
<View style={styles.sessionMetaRow}>
<Text style={[styles.sessionMeta, isDark && styles.metaDark]}>
{formatTime(session.time.updated)}
{session.summary && ` · ${session.summary.files} files`}
</Text>
{shortDir && (
<View style={styles.sessionDirBadge}>
<Ionicons name="folder-outline" size={12} color={isDark ? "#888888" : "#666666"} />
<Text style={[styles.sessionDirText, isDark && styles.metaDark]}>{shortDir}</Text>
</View>
)}
</View>
</View>
<Ionicons name="chevron-forward" size={20} color={isDark ? "#666666" : "#999999"} />
</TouchableOpacity>
)
}
// Get short directory name (last folder or project name)
function getShortPath(
project: { path?: { cwd?: string; root?: string; absolute?: string }; name?: string } | null | undefined,
): string {
if (!project) return ""
if (project.name) return project.name
if (!project.path?.absolute) return ""
const parts = project.path.absolute.split("/").filter(Boolean)
return parts[parts.length - 1] || project.path.absolute
}
export default function SessionsScreen() {
const colorScheme = useColorScheme()
const isDark = colorScheme === "dark"
const [showNewSession, setShowNewSession] = useState(false)
const [customDir, setCustomDir] = useState("")
const [isCreating, setIsCreating] = useState(false)
const [renaming, setRenaming] = useState<Session | null>(null)
const [renameText, setRenameText] = useState("")
const { sessions, isLoading, error, loadSessions, createSession, deleteSession } = useSessions()
const {
activeConnection,
client,
currentProject,
serverHome,
refreshProject,
clientForDirectory,
switchDirectory,
addRecentDirectory,
recentDirectories,
} = useConnections()
const dirSheetRef = useRef<BottomSheet>(null)
const [refreshing, setRefreshing] = useState(false)
const handleSwitchDirectory = useCallback(
async (dir?: string) => {
await switchDirectory(dir)
loadSessions()
refreshProject()
},
[switchDirectory, loadSessions, refreshProject],
)
useEffect(() => {
if (client) {
loadSessions()
refreshProject()
}
}, [client])
const onRefresh = useCallback(async () => {
setRefreshing(true)
await Promise.all([loadSessions(), refreshProject()])
setRefreshing(false)
}, [])
const handleRename = useCallback((session: Session) => {
setRenameText(session.title || "")
setRenaming(session)
}, [])
const submitRename = useCallback(async () => {
const title = renameText.trim()
if (!title || !renaming || !client) return
await client.session.update(renaming.id, { title })
setRenaming(null)
setRenameText("")
loadSessions()
}, [renaming, renameText, client, loadSessions])
const handleDelete = useCallback(
(session: Session) => {
Alert.alert("Delete Session", `Delete "${session.title || "Untitled Session"}"?`, [
{ text: "Cancel", style: "cancel" },
{
text: "Delete",
style: "destructive",
onPress: async () => {
await deleteSession(session.id)
},
},
])
},
[deleteSession],
)
const onCreateSession = async () => {
const session = await createSession()
if (session) {
router.push(`/session/${session.id}`)
}
}
const onCreateInDirectory = async (dir?: string) => {
if (!activeConnection) return
setIsCreating(true)
// If a custom directory is specified, use a one-off client for that directory
// so we don't mutate the connection's default project
if (dir && dir.trim()) {
const dirClient = clientForDirectory(dir.trim())
if (!dirClient) {
setIsCreating(false)
return
}
try {
const session = await dirClient.session.create({})
addRecentDirectory(dir.trim())
setIsCreating(false)
setShowNewSession(false)
setCustomDir("")
if (session) {
router.push({ pathname: `/session/[id]`, params: { id: session.id, directory: dir.trim() } })
}
} catch (error) {
console.error("Failed to create session in directory:", error)
Alert.alert("Error", "Failed to create session in that directory.")
setIsCreating(false)
}
return
}
const session = await createSession()
setIsCreating(false)
setShowNewSession(false)
setCustomDir("")
if (session) {
router.push(`/session/${session.id}`)
}
}
const onFabPress = () => {
// Quick create in current project
onCreateSession()
}
const onFabLongPress = () => {
// Show modal with more options
setCustomDir("")
setShowNewSession(true)
}
if (!activeConnection) {
return (
<View style={[styles.emptyContainer, isDark && styles.containerDark]}>
<Ionicons name="server-outline" size={64} color={isDark ? "#444444" : "#cccccc"} />
<Text style={[styles.emptyTitle, isDark && styles.textDark]}>No Connection</Text>
<Text style={[styles.emptySubtitle, isDark && styles.metaDark]}>Add a server connection to get started</Text>
<TouchableOpacity
style={[styles.addButton, isDark && styles.addButtonDark]}
onPress={() => router.push("/connection/add")}
>
<Text style={[styles.addButtonText, isDark && styles.addButtonTextDark]}>Add Connection</Text>
</TouchableOpacity>
</View>
)
}
const shortPath = getShortPath(currentProject)
return (
<View style={[styles.container, isDark && styles.containerDark]}>
{/* Connection indicator — tap to switch project */}
<TouchableOpacity
style={[styles.connectionBar, isDark && styles.connectionBarDark]}
onPress={() => dirSheetRef.current?.expand()}
onLongPress={() => router.push("/(tabs)/connections")}
activeOpacity={0.7}
>
<View style={styles.connectionInfo}>
<View style={[styles.connectionDot, { backgroundColor: "#22c55e" }]} />
<Text style={[styles.connectionName, isDark && styles.textDark]} numberOfLines={1}>
{activeConnection.name}
</Text>
{shortPath && (
<>
<Ionicons name="folder" size={14} color={isDark ? "#888888" : "#666666"} />
<Text style={[styles.projectPath, isDark && styles.metaDark]} numberOfLines={1}>
{shortPath}
</Text>
</>
)}
</View>
<Ionicons name="swap-horizontal-outline" size={16} color={isDark ? "#666666" : "#999999"} />
</TouchableOpacity>
{error && (
<View style={styles.errorBar}>
<Text style={styles.errorText}>{error}</Text>
</View>
)}
<FlatList
data={sessions}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<SessionItem
session={item}
isDark={isDark}
onRename={() => handleRename(item)}
onDelete={() => handleDelete(item)}
/>
)}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={isDark ? "#ffffff" : "#0a0a0a"} />
}
ListEmptyComponent={
isLoading ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={isDark ? "#ffffff" : "#0a0a0a"} />
</View>
) : (
<View style={styles.emptyList}>
<Text style={[styles.emptyListText, isDark && styles.metaDark]}>No sessions yet</Text>
</View>
)
}
contentContainerStyle={sessions.length === 0 ? styles.emptyContent : undefined}
/>
{/* FAB to create new session */}
<TouchableOpacity
style={[styles.fab, isDark && styles.fabDark]}
onPress={onFabPress}
onLongPress={onFabLongPress}
delayLongPress={500}
>
<Ionicons name="add" size={28} color={isDark ? "#0a0a0a" : "#ffffff"} />
</TouchableOpacity>
{/* New Session Info Modal */}
<Modal visible={showNewSession} animationType="slide" transparent>
<KeyboardAvoidingView style={styles.modalOverlay} behavior={Platform.OS === "ios" ? "padding" : "height"}>
<TouchableOpacity style={styles.modalDismiss} activeOpacity={1} onPress={() => setShowNewSession(false)} />
<View style={[styles.modalContent, isDark && styles.modalContentDark]}>
<View style={styles.modalHeader}>
<Text style={[styles.modalTitle, isDark && styles.textDark]}>New Session</Text>
<TouchableOpacity onPress={() => setShowNewSession(false)}>
<Ionicons name="close" size={24} color={isDark ? "#ffffff" : "#0a0a0a"} />
</TouchableOpacity>
</View>
<View style={styles.modalBody}>
{/* Current directory */}
<Text style={[styles.modalLabel, isDark && styles.metaDark]}>Current Directory</Text>
<View style={[styles.modalDirBox, isDark && styles.modalDirBoxDark]}>
<Ionicons name="folder" size={20} color={isDark ? "#888888" : "#666666"} />
<Text style={[styles.modalDirText, isDark && styles.textDark]} numberOfLines={2}>
{currentProject?.path?.absolute || activeConnection?.directory || "Server default"}
</Text>
</View>
{/* Custom directory input */}
<Text style={[styles.modalLabel, isDark && styles.metaDark, { marginTop: 16 }]}>
Or use a different folder
</Text>
<TextInput
style={[styles.modalInput, isDark && styles.modalInputDark]}
placeholder={serverHome ? `${serverHome}/...` : "/path/to/project"}
placeholderTextColor={isDark ? "#666666" : "#999999"}
value={customDir}
onChangeText={(text) => {
// Expand ~ to server home directory
if (serverHome && text.startsWith("~/")) {
setCustomDir(serverHome + text.slice(1))
} else if (serverHome && text === "~") {
setCustomDir(serverHome)
} else {
setCustomDir(text)
}
}}
autoCapitalize="none"
autoCorrect={false}
/>
{/* Quick path shortcuts */}
{serverHome && (
<View style={styles.pathChips}>
<TouchableOpacity
style={[styles.pathChip, isDark && styles.pathChipDark]}
onPress={() => setCustomDir(serverHome)}
>
<Text style={[styles.pathChipText, isDark && styles.pathChipTextDark]}>~</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.pathChip, isDark && styles.pathChipDark]}
onPress={() => setCustomDir(serverHome + "/")}
>
<Text style={[styles.pathChipText, isDark && styles.pathChipTextDark]}>~/</Text>
</TouchableOpacity>
</View>
)}
</View>
<View style={styles.modalActions}>
{customDir.trim() ? (
<TouchableOpacity
style={[
styles.modalButton,
styles.modalButtonPrimary,
isDark && styles.modalButtonPrimaryDark,
styles.modalButtonFull,
]}
onPress={() => onCreateInDirectory(customDir)}
disabled={isCreating}
>
{isCreating ? (
<ActivityIndicator size="small" color={isDark ? "#0a0a0a" : "#ffffff"} />
) : (
<Text style={[styles.modalButtonTextPrimary, isDark && styles.modalButtonTextPrimaryDark]}>
Create in {customDir.split("/").filter(Boolean).pop() || customDir}
</Text>
)}
</TouchableOpacity>
) : (
<TouchableOpacity
style={[
styles.modalButton,
styles.modalButtonPrimary,
isDark && styles.modalButtonPrimaryDark,
styles.modalButtonFull,
]}
onPress={() => onCreateInDirectory()}
disabled={isCreating}
>
{isCreating ? (
<ActivityIndicator size="small" color={isDark ? "#0a0a0a" : "#ffffff"} />
) : (
<Text style={[styles.modalButtonTextPrimary, isDark && styles.modalButtonTextPrimaryDark]}>
Create Session
</Text>
)}
</TouchableOpacity>
)}
</View>
</View>
</KeyboardAvoidingView>
</Modal>
{/* Rename modal */}
<Modal visible={!!renaming} animationType="fade" transparent>
<KeyboardAvoidingView
style={[styles.modalOverlay, { justifyContent: "center" }]}
behavior={Platform.OS === "ios" ? "padding" : "height"}
>
<TouchableOpacity style={styles.modalDismiss} activeOpacity={1} onPress={() => setRenaming(null)} />
<View style={[styles.renameCard, isDark && styles.renameCardDark]}>
<Text style={[styles.renameTitle, isDark && styles.textDark]}>Rename Session</Text>
<TextInput
style={[styles.modalInput, isDark && styles.modalInputDark]}
value={renameText}
onChangeText={setRenameText}
onSubmitEditing={submitRename}
returnKeyType="done"
autoFocus
selectTextOnFocus
autoCapitalize="sentences"
autoCorrect={false}
/>
<View style={styles.renameActions}>
<TouchableOpacity style={[styles.renameBtn, styles.renameBtnCancel]} onPress={() => setRenaming(null)}>
<Text style={styles.renameBtnCancelText}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.renameBtn, styles.modalButtonPrimary, isDark && styles.modalButtonPrimaryDark]}
onPress={submitRename}
disabled={!renameText.trim()}
>
<Text style={[styles.modalButtonTextPrimary, isDark && styles.modalButtonTextPrimaryDark]}>Save</Text>
</TouchableOpacity>
</View>
</View>
<TouchableOpacity style={styles.modalDismiss} activeOpacity={1} onPress={() => setRenaming(null)} />
</KeyboardAvoidingView>
</Modal>
{/* Directory switcher bottom sheet */}
<DirectorySwitcher
sheetRef={dirSheetRef}
current={activeConnection?.directory}
recents={recentDirectories}
serverHome={serverHome}
isDark={isDark}
onSwitch={handleSwitchDirectory}
/>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#ffffff",
},
containerDark: {
backgroundColor: "#0a0a0a",
},
connectionBar: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingHorizontal: 16,
paddingVertical: 12,
borderBottomWidth: 1,
borderBottomColor: "#e5e5e5",
},
connectionBarDark: {
borderBottomColor: "#1a1a1a",
},
connectionInfo: {
flexDirection: "row",
alignItems: "center",
gap: 8,
},
connectionDot: {
width: 8,
height: 8,
borderRadius: 4,
},
connectionName: {
fontSize: 14,
fontWeight: "600",
color: "#0a0a0a",
},
connectionUrl: {
fontSize: 12,
color: "#666666",
},
projectPath: {
fontSize: 13,
color: "#666666",
flex: 1,
},
errorBar: {
backgroundColor: "#fef2f2",
padding: 12,
borderBottomWidth: 1,
borderBottomColor: "#fecaca",
},
errorText: {
color: "#dc2626",
fontSize: 14,
},
sessionItem: {
flexDirection: "row",
alignItems: "center",
padding: 16,
borderBottomWidth: 1,
borderBottomColor: "#e5e5e5",
},
sessionItemDark: {
borderBottomColor: "#1a1a1a",
},
sessionContent: {
flex: 1,
},
sessionHeader: {
flexDirection: "row",
alignItems: "center",
gap: 8,
marginBottom: 2,
},
sessionTitle: {
fontSize: 16,
fontWeight: "500",
color: "#0a0a0a",
marginBottom: 4,
},
textDark: {
color: "#ffffff",
},
sessionMeta: {
fontSize: 13,
color: "#666666",
},
sessionMetaRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
},
sessionDirBadge: {
flexDirection: "row",
alignItems: "center",
gap: 4,
backgroundColor: "#f5f5f5",
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 4,
},
sessionDirText: {
fontSize: 11,
color: "#666666",
},
metaDark: {
color: "#888888",
},
emptyContainer: {
flex: 1,
justifyContent: "center",
alignItems: "center",
padding: 32,
backgroundColor: "#ffffff",
},
emptyTitle: {
fontSize: 20,
fontWeight: "600",
marginTop: 16,
color: "#0a0a0a",
},
emptySubtitle: {
fontSize: 14,
color: "#666666",
marginTop: 8,
textAlign: "center",
},
addButton: {
backgroundColor: "#0a0a0a",
paddingHorizontal: 24,
paddingVertical: 12,
borderRadius: 8,
marginTop: 24,
},
addButtonDark: {
backgroundColor: "#ffffff",
},
addButtonText: {
color: "#ffffff",
fontWeight: "600",
},
addButtonTextDark: {
color: "#0a0a0a",
},
loadingContainer: {
flex: 1,
justifyContent: "center",
alignItems: "center",
paddingVertical: 64,
},
emptyList: {
flex: 1,
justifyContent: "center",
alignItems: "center",
paddingVertical: 64,
},
emptyListText: {
fontSize: 16,
color: "#666666",
},
emptyContent: {
flex: 1,
},
fab: {
position: "absolute",
right: 16,
bottom: 16,
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: "#0a0a0a",
justifyContent: "center",
alignItems: "center",
shadowColor: "#000",
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.25,
shadowRadius: 4,
elevation: 5,
},
fabDark: {
backgroundColor: "#ffffff",
},
// Modal styles
modalOverlay: {
flex: 1,
backgroundColor: "rgba(0, 0, 0, 0.5)",
justifyContent: "flex-end",
},
modalDismiss: {
flex: 1,
},
modalContent: {
backgroundColor: "#ffffff",
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
padding: 20,
paddingBottom: 40,
},
modalContentDark: {
backgroundColor: "#1a1a1a",
},
modalHeader: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 20,
},
modalTitle: {
fontSize: 20,
fontWeight: "600",
color: "#0a0a0a",
},
modalBody: {
marginBottom: 24,
},
modalLabel: {
fontSize: 13,
fontWeight: "600",
color: "#666666",
marginBottom: 8,
textTransform: "uppercase",
},
modalDirBox: {
flexDirection: "row",
alignItems: "center",
gap: 12,
backgroundColor: "#f5f5f5",
padding: 16,
borderRadius: 12,
},
modalDirBoxDark: {
backgroundColor: "#2a2a2a",
},
modalDirText: {
fontSize: 15,
color: "#0a0a0a",
flex: 1,
},
modalInput: {
backgroundColor: "#f5f5f5",
borderRadius: 12,
paddingHorizontal: 16,
paddingVertical: 14,
fontSize: 15,
color: "#0a0a0a",
},
modalInputDark: {
backgroundColor: "#2a2a2a",
color: "#ffffff",
},
pathChips: {
flexDirection: "row",
gap: 8,
marginTop: 8,
},
pathChip: {
paddingHorizontal: 12,
paddingVertical: 6,
backgroundColor: "#e8e5f0",
borderRadius: 16,
},
pathChipDark: {
backgroundColor: "#2a2040",
},
pathChipText: {
fontSize: 13,
fontWeight: "600",
color: "#6d28d9",
},
pathChipTextDark: {
color: "#c4b5fd",
},
modalHint: {
fontSize: 13,
color: "#666666",
marginTop: 12,
},
modalActions: {
flexDirection: "row",
gap: 12,
},
modalButton: {
flex: 1,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: 8,
padding: 16,
borderRadius: 12,
},
modalButtonSecondary: {
backgroundColor: "#f5f5f5",
},
modalButtonSecondaryDark: {
backgroundColor: "#2a2a2a",
},
modalButtonPrimary: {
backgroundColor: "#0a0a0a",
},
modalButtonPrimaryDark: {
backgroundColor: "#ffffff",
},
modalButtonText: {
fontSize: 15,
fontWeight: "600",
color: "#0a0a0a",
},
modalButtonTextPrimary: {
fontSize: 15,
fontWeight: "600",
color: "#ffffff",
},
modalButtonTextPrimaryDark: {
color: "#0a0a0a",
},
modalButtonFull: {
flex: 0,
width: "100%",
},
// Rename modal
renameCard: {
backgroundColor: "#ffffff",
borderRadius: 16,
padding: 20,
marginHorizontal: 32,
gap: 16,
},
renameCardDark: {
backgroundColor: "#1a1a1a",
},
renameTitle: {
fontSize: 17,
fontWeight: "600",
color: "#0a0a0a",
},
renameActions: {
flexDirection: "row",
justifyContent: "flex-end",
gap: 12,
},
renameBtn: {
paddingHorizontal: 20,
paddingVertical: 10,
borderRadius: 10,
},
renameBtnCancel: {
backgroundColor: "transparent",
},
renameBtnCancelText: {
fontSize: 15,
fontWeight: "600",
color: "#888888",
},
})

289
app/(tabs)/settings.tsx Normal file
View File

@@ -0,0 +1,289 @@
import { useCallback, useState } from "react"
import {
View,
Text,
ScrollView,
TouchableOpacity,
Switch,
StyleSheet,
useColorScheme,
Linking,
Alert,
} from "react-native"
import { Ionicons } from "@expo/vector-icons"
import { useAuth } from "../../src/stores/auth"
import { useSettings } from "../../src/stores/settings"
import {
categories,
categoryMeta,
setup as setupNotifications,
granted as notificationsGranted,
} from "../../src/lib/notifications"
import type { Category } from "../../src/lib/notifications"
function SettingRow({
icon,
label,
description,
isDark,
right,
onPress,
}: {
icon: keyof typeof Ionicons.glyphMap
label: string
description?: string
isDark: boolean
right?: React.ReactNode
onPress?: () => void
}) {
const content = (
<View style={[styles.settingRow, isDark && styles.settingRowDark]}>
<View style={[styles.settingIcon, isDark && styles.settingIconDark]}>
<Ionicons name={icon} size={22} color={isDark ? "#ffffff" : "#0a0a0a"} />
</View>
<View style={styles.settingContent}>
<Text style={[styles.settingLabel, isDark && styles.textDark]}>{label}</Text>
{description && <Text style={[styles.settingDescription, isDark && styles.metaDark]}>{description}</Text>}
</View>
{right}
</View>
)
if (onPress) {
return <TouchableOpacity onPress={onPress}>{content}</TouchableOpacity>
}
return content
}
function SettingSection({ title, children, isDark }: { title: string; children: React.ReactNode; isDark: boolean }) {
return (
<View style={styles.section}>
<Text style={[styles.sectionTitle, isDark && styles.sectionTitleDark]}>{title}</Text>
<View style={[styles.sectionContent, isDark && styles.sectionContentDark]}>{children}</View>
</View>
)
}
export default function SettingsScreen() {
const colorScheme = useColorScheme()
const isDark = colorScheme === "dark"
const { settings, hasBiometrics, updateSettings, lock } = useAuth()
const { notifications, setNotification } = useSettings()
const [osGranted, setOsGranted] = useState<boolean | null>(null)
// Check OS permission state on first toggle attempt
const handleToggle = useCallback(
async (category: Category, enabled: boolean) => {
if (enabled) {
const ok = await setupNotifications()
setOsGranted(ok)
if (!ok) {
Alert.alert(
"Notifications Disabled",
"Enable notifications for OpenCode in your device settings to receive alerts.",
)
return
}
}
setNotification(category, enabled)
},
[setNotification],
)
// Lazy-check OS permission for status display
if (osGranted === null) {
notificationsGranted().then(setOsGranted)
}
return (
<ScrollView style={[styles.container, isDark && styles.containerDark]} contentContainerStyle={styles.content}>
<SettingSection title="Security" isDark={isDark}>
<SettingRow
icon="finger-print"
label="Require Biometric to Open"
description={
hasBiometrics ? "Use Face ID or Touch ID to unlock the app" : "Biometric authentication not available"
}
isDark={isDark}
right={
<Switch
value={settings.requireBiometric}
onValueChange={(value) => updateSettings({ requireBiometric: value })}
disabled={!hasBiometrics}
trackColor={{ false: "#767577", true: "#22c55e" }}
/>
}
/>
<SettingRow
icon="lock-closed"
label="Require Biometric to Send"
description="Authenticate before sending messages"
isDark={isDark}
right={
<Switch
value={settings.requireBiometricForMessages}
onValueChange={(value) => updateSettings({ requireBiometricForMessages: value })}
disabled={!hasBiometrics || !settings.requireBiometric}
trackColor={{ false: "#767577", true: "#22c55e" }}
/>
}
/>
{settings.requireBiometric && (
<SettingRow
icon="exit"
label="Lock App Now"
description="Require authentication to reopen"
isDark={isDark}
onPress={lock}
right={<Ionicons name="chevron-forward" size={20} color={isDark ? "#666666" : "#999999"} />}
/>
)}
</SettingSection>
<SettingSection title="Notifications" isDark={isDark}>
{categories.map((category) => {
const meta = categoryMeta[category]
return (
<SettingRow
key={category}
icon={meta.icon as keyof typeof Ionicons.glyphMap}
label={meta.label}
description={meta.description}
isDark={isDark}
right={
<Switch
value={notifications[category]}
onValueChange={(value) => handleToggle(category, value)}
trackColor={{ false: "#767577", true: "#22c55e" }}
/>
}
/>
)
})}
{osGranted === false && (
<View style={[styles.settingRow, isDark && styles.settingRowDark]}>
<Text style={[styles.settingDescription, { color: "#ef4444", paddingLeft: 48 }]}>
Notifications are disabled at the system level. Enable them in Settings to receive alerts.
</Text>
</View>
)}
</SettingSection>
<SettingSection title="About" isDark={isDark}>
<SettingRow icon="information-circle" label="Version" description="1.0.0" isDark={isDark} />
<SettingRow
icon="logo-github"
label="GitHub"
description="View source code"
isDark={isDark}
onPress={() => Linking.openURL("https://github.com/anomalyco/opencode")}
right={<Ionicons name="open-outline" size={20} color={isDark ? "#666666" : "#999999"} />}
/>
<SettingRow
icon="document-text"
label="Documentation"
description="Learn how to use OpenCode"
isDark={isDark}
onPress={() => Linking.openURL("https://opencode.ai/docs")}
right={<Ionicons name="open-outline" size={20} color={isDark ? "#666666" : "#999999"} />}
/>
</SettingSection>
<View style={styles.footer}>
<Text style={[styles.footerText, isDark && styles.metaDark]}>OpenCode Mobile</Text>
<Text style={[styles.footerText, isDark && styles.metaDark]}>
Connect to your AI coding assistant from anywhere
</Text>
</View>
</ScrollView>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#f5f5f5",
},
containerDark: {
backgroundColor: "#0a0a0a",
},
content: {
paddingBottom: 32,
},
section: {
marginTop: 24,
},
sectionTitle: {
fontSize: 13,
fontWeight: "600",
color: "#666666",
marginLeft: 16,
marginBottom: 8,
textTransform: "uppercase",
},
sectionTitleDark: {
color: "#888888",
},
sectionContent: {
backgroundColor: "#ffffff",
borderTopWidth: 1,
borderBottomWidth: 1,
borderColor: "#e5e5e5",
},
sectionContentDark: {
backgroundColor: "#1a1a1a",
borderColor: "#2a2a2a",
},
settingRow: {
flexDirection: "row",
alignItems: "center",
paddingVertical: 12,
paddingHorizontal: 16,
borderBottomWidth: 1,
borderBottomColor: "#e5e5e5",
},
settingRowDark: {
borderBottomColor: "#2a2a2a",
},
settingIcon: {
width: 36,
height: 36,
borderRadius: 8,
backgroundColor: "#f5f5f5",
justifyContent: "center",
alignItems: "center",
marginRight: 12,
},
settingIconDark: {
backgroundColor: "#2a2a2a",
},
settingContent: {
flex: 1,
},
settingLabel: {
fontSize: 16,
color: "#0a0a0a",
},
textDark: {
color: "#ffffff",
},
settingDescription: {
fontSize: 13,
color: "#666666",
marginTop: 2,
},
metaDark: {
color: "#888888",
},
footer: {
alignItems: "center",
padding: 32,
},
footerText: {
fontSize: 13,
color: "#999999",
textAlign: "center",
},
})

120
app/_layout.tsx Normal file
View File

@@ -0,0 +1,120 @@
import { useEffect, useRef } from "react"
import { Stack, router } from "expo-router"
import { StatusBar } from "expo-status-bar"
import { useColorScheme, View, ActivityIndicator } from "react-native"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { GestureHandlerRootView } from "react-native-gesture-handler"
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet"
import { useAuth } from "../src/stores/auth"
import { useConnections } from "../src/stores/connections"
import { useEvents } from "../src/stores/events"
import { useCatalog } from "../src/stores/catalog"
import { useSettings } from "../src/stores/settings"
import { AuthGate } from "../src/components/AuthGate"
import * as notifications from "../src/lib/notifications"
const queryClient = new QueryClient()
export default function RootLayout() {
const colorScheme = useColorScheme()
const isDark = colorScheme === "dark"
const { initialize: initAuth, isLoading: authLoading } = useAuth()
const { loadConnections, isLoading: connectionsLoading, client } = useConnections()
const sseStarted = useRef(false)
useEffect(() => {
initAuth()
loadConnections()
useSettings.getState().load()
// Connect notification preferences to the notification module
notifications.configure(() => useSettings.getState().notifications)
// Navigate to session when user taps a notification
return notifications.onTap((data) => {
router.push(`/session/${data.sessionId}`)
})
}, [])
// Connect/disconnect SSE and load catalog when client changes
useEffect(() => {
if (client && !sseStarted.current) {
sseStarted.current = true
useEvents.getState().connect()
useCatalog.getState().load()
} else if (!client && sseStarted.current) {
sseStarted.current = false
useEvents.getState().disconnect()
}
return () => {
if (sseStarted.current) {
sseStarted.current = false
useEvents.getState().disconnect()
}
}
}, [client])
const isLoading = authLoading || connectionsLoading
if (isLoading) {
return (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
backgroundColor: isDark ? "#0a0a0a" : "#ffffff",
}}
>
<ActivityIndicator size="large" color={isDark ? "#ffffff" : "#0a0a0a"} />
</View>
)
}
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<BottomSheetModalProvider>
<QueryClientProvider client={queryClient}>
<AuthGate>
<Stack
screenOptions={{
headerStyle: {
backgroundColor: isDark ? "#0a0a0a" : "#ffffff",
},
headerTintColor: isDark ? "#ffffff" : "#0a0a0a",
contentStyle: {
backgroundColor: isDark ? "#0a0a0a" : "#ffffff",
},
}}
>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen
name="session/[id]"
options={{
title: "Session",
presentation: "card",
}}
/>
<Stack.Screen
name="connection/add"
options={{
title: "Add Connection",
presentation: "modal",
}}
/>
<Stack.Screen
name="connection/[id]"
options={{
title: "Edit Connection",
presentation: "modal",
}}
/>
</Stack>
<StatusBar style={isDark ? "light" : "dark"} />
</AuthGate>
</QueryClientProvider>
</BottomSheetModalProvider>
</GestureHandlerRootView>
)
}

413
app/connection/[id].tsx Normal file
View File

@@ -0,0 +1,413 @@
import { useEffect, useState } from "react"
import {
View,
Text,
TextInput,
TouchableOpacity,
ScrollView,
StyleSheet,
useColorScheme,
ActivityIndicator,
Alert,
} from "react-native"
import { router, useLocalSearchParams } from "expo-router"
import { Ionicons } from "@expo/vector-icons"
import { useConnections } from "../../src/stores/connections"
import type { ConnectionType } from "../../src/lib/types"
const CONNECTION_TYPES: Array<{
type: ConnectionType
label: string
icon: keyof typeof Ionicons.glyphMap
}> = [
{ type: "local", label: "Local", icon: "wifi" },
{ type: "tunnel", label: "Tunnel", icon: "globe" },
{ type: "cloud", label: "Cloud", icon: "cloud" },
]
export default function EditConnectionScreen() {
const { id } = useLocalSearchParams<{ id: string }>()
const colorScheme = useColorScheme()
const isDark = colorScheme === "dark"
const { connections, updateConnection, removeConnection, testConnection } = useConnections()
const connection = connections.find((c) => c.id === id)
const [type, setType] = useState<ConnectionType>(connection?.type || "local")
const [name, setName] = useState(connection?.name || "")
const [url, setUrl] = useState(connection?.url || "")
const [directory, setDirectory] = useState(connection?.directory || "")
const [username, setUsername] = useState(connection?.username || "")
const [password, setPassword] = useState("")
const [isTesting, setIsTesting] = useState(false)
const [isSaving, setIsSaving] = useState(false)
useEffect(() => {
if (connection) {
setType(connection.type)
setName(connection.name)
setUrl(connection.url)
setDirectory(connection.directory || "")
setUsername(connection.username || "")
}
}, [connection])
if (!connection) {
return (
<View style={[styles.container, isDark && styles.containerDark, styles.center]}>
<Text style={[styles.errorText, isDark && styles.textDark]}>Connection not found</Text>
</View>
)
}
const handleTest = async () => {
if (!url.trim()) {
Alert.alert("Error", "Please enter a server URL")
return
}
setIsTesting(true)
const success = await testConnection(
{
id: connection.id,
name: name || "Test",
type,
url: url.trim(),
directory: directory.trim() || undefined,
username: username.trim() || undefined,
},
password || undefined,
)
setIsTesting(false)
Alert.alert(
success ? "Success" : "Failed",
success ? "Connection successful!" : "Could not connect to the server. Check the URL and credentials.",
)
}
const handleSave = async () => {
if (!name.trim()) {
Alert.alert("Error", "Please enter a connection name")
return
}
if (!url.trim()) {
Alert.alert("Error", "Please enter a server URL")
return
}
setIsSaving(true)
await updateConnection(connection.id, {
name: name.trim(),
type,
url: url.trim(),
directory: directory.trim() || undefined,
username: username.trim() || undefined,
})
setIsSaving(false)
router.back()
}
const handleDelete = () => {
Alert.alert("Delete Connection", `Are you sure you want to delete "${connection.name}"?`, [
{ text: "Cancel", style: "cancel" },
{
text: "Delete",
style: "destructive",
onPress: async () => {
await removeConnection(connection.id)
router.back()
},
},
])
}
return (
<ScrollView
style={[styles.container, isDark && styles.containerDark]}
contentContainerStyle={styles.content}
keyboardShouldPersistTaps="handled"
>
{/* Connection Type */}
<Text style={[styles.label, isDark && styles.labelDark]}>Connection Type</Text>
<View style={styles.typeContainer}>
{CONNECTION_TYPES.map((t) => (
<TouchableOpacity
key={t.type}
style={[
styles.typeOption,
isDark && styles.typeOptionDark,
type === t.type && styles.typeOptionSelected,
type === t.type && isDark && styles.typeOptionSelectedDark,
]}
onPress={() => setType(t.type)}
>
<Ionicons
name={t.icon}
size={20}
color={type === t.type ? (isDark ? "#0a0a0a" : "#ffffff") : isDark ? "#888888" : "#666666"}
/>
<Text
style={[
styles.typeLabel,
isDark && styles.textDark,
type === t.type && styles.typeLabelSelected,
type === t.type && isDark && styles.typeLabelSelectedDark,
]}
>
{t.label}
</Text>
</TouchableOpacity>
))}
</View>
{/* Name */}
<Text style={[styles.label, isDark && styles.labelDark]}>Name</Text>
<TextInput
style={[styles.input, isDark && styles.inputDark]}
placeholder="My Server"
placeholderTextColor={isDark ? "#666666" : "#999999"}
value={name}
onChangeText={setName}
/>
{/* URL */}
<Text style={[styles.label, isDark && styles.labelDark]}>Server URL</Text>
<TextInput
style={[styles.input, isDark && styles.inputDark]}
placeholder="http://192.168.1.100:4096"
placeholderTextColor={isDark ? "#666666" : "#999999"}
value={url}
onChangeText={setUrl}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
/>
{/* Directory */}
<Text style={[styles.label, isDark && styles.labelDark]}>Project Directory (Optional)</Text>
<TextInput
style={[styles.input, isDark && styles.inputDark]}
placeholder="/path/to/project"
placeholderTextColor={isDark ? "#666666" : "#999999"}
value={directory}
onChangeText={setDirectory}
autoCapitalize="none"
autoCorrect={false}
/>
<Text style={[styles.hint, isDark && styles.hintDark]}>
Leave empty to use the server's current directory. Sessions will be created in this folder.
</Text>
{/* Auth */}
<Text style={[styles.sectionTitle, isDark && styles.textDark]}>Authentication</Text>
<Text style={[styles.label, isDark && styles.labelDark]}>Username</Text>
<TextInput
style={[styles.input, isDark && styles.inputDark]}
placeholder="admin"
placeholderTextColor={isDark ? "#666666" : "#999999"}
value={username}
onChangeText={setUsername}
autoCapitalize="none"
autoCorrect={false}
/>
<Text style={[styles.label, isDark && styles.labelDark]}>Password (leave empty to keep existing)</Text>
<TextInput
style={[styles.input, isDark && styles.inputDark]}
placeholder="••••••••"
placeholderTextColor={isDark ? "#666666" : "#999999"}
value={password}
onChangeText={setPassword}
secureTextEntry
/>
{/* Actions */}
<View style={styles.actions}>
<TouchableOpacity
style={[styles.testButton, isDark && styles.testButtonDark]}
onPress={handleTest}
disabled={isTesting}
>
{isTesting ? (
<ActivityIndicator size="small" color={isDark ? "#ffffff" : "#0a0a0a"} />
) : (
<>
<Ionicons name="pulse" size={20} color={isDark ? "#ffffff" : "#0a0a0a"} />
<Text style={[styles.testButtonText, isDark && styles.textDark]}>Test Connection</Text>
</>
)}
</TouchableOpacity>
<TouchableOpacity
style={[styles.saveButton, isDark && styles.saveButtonDark]}
onPress={handleSave}
disabled={isSaving}
>
{isSaving ? (
<ActivityIndicator size="small" color={isDark ? "#0a0a0a" : "#ffffff"} />
) : (
<Text style={[styles.saveButtonText, isDark && styles.saveButtonTextDark]}>Save Changes</Text>
)}
</TouchableOpacity>
<TouchableOpacity style={styles.deleteButton} onPress={handleDelete}>
<Ionicons name="trash-outline" size={20} color="#ef4444" />
<Text style={styles.deleteButtonText}>Delete Connection</Text>
</TouchableOpacity>
</View>
</ScrollView>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#ffffff",
},
containerDark: {
backgroundColor: "#0a0a0a",
},
center: {
justifyContent: "center",
alignItems: "center",
},
content: {
padding: 16,
paddingBottom: 32,
},
errorText: {
fontSize: 16,
color: "#666666",
},
label: {
fontSize: 14,
fontWeight: "600",
color: "#0a0a0a",
marginTop: 16,
marginBottom: 8,
},
labelDark: {
color: "#ffffff",
},
typeContainer: {
flexDirection: "row",
gap: 8,
},
typeOption: {
flex: 1,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
padding: 12,
borderRadius: 8,
backgroundColor: "#f5f5f5",
gap: 6,
},
typeOptionDark: {
backgroundColor: "#1a1a1a",
},
typeOptionSelected: {
backgroundColor: "#0a0a0a",
},
typeOptionSelectedDark: {
backgroundColor: "#ffffff",
},
typeLabel: {
fontSize: 13,
fontWeight: "500",
color: "#666666",
},
textDark: {
color: "#ffffff",
},
typeLabelSelected: {
color: "#ffffff",
},
typeLabelSelectedDark: {
color: "#0a0a0a",
},
input: {
backgroundColor: "#f5f5f5",
borderRadius: 8,
paddingHorizontal: 16,
paddingVertical: 12,
fontSize: 16,
color: "#0a0a0a",
},
inputDark: {
backgroundColor: "#1a1a1a",
color: "#ffffff",
},
hint: {
fontSize: 13,
color: "#666666",
marginTop: 6,
},
hintDark: {
color: "#888888",
},
sectionTitle: {
fontSize: 18,
fontWeight: "600",
color: "#0a0a0a",
marginTop: 32,
marginBottom: 8,
},
actions: {
marginTop: 32,
gap: 12,
},
testButton: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: 8,
padding: 16,
borderRadius: 12,
borderWidth: 1,
borderColor: "#e5e5e5",
},
testButtonDark: {
borderColor: "#333333",
},
testButtonText: {
fontSize: 16,
fontWeight: "600",
color: "#0a0a0a",
},
saveButton: {
alignItems: "center",
justifyContent: "center",
padding: 16,
borderRadius: 12,
backgroundColor: "#0a0a0a",
},
saveButtonDark: {
backgroundColor: "#ffffff",
},
saveButtonText: {
fontSize: 16,
fontWeight: "600",
color: "#ffffff",
},
saveButtonTextDark: {
color: "#0a0a0a",
},
deleteButton: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: 8,
padding: 16,
borderRadius: 12,
backgroundColor: "#fef2f2",
},
deleteButtonText: {
fontSize: 16,
fontWeight: "600",
color: "#ef4444",
},
})

548
app/connection/add.tsx Normal file
View File

@@ -0,0 +1,548 @@
import { useState } from "react"
import {
View,
Text,
TextInput,
TouchableOpacity,
ScrollView,
StyleSheet,
useColorScheme,
ActivityIndicator,
Alert,
} from "react-native"
import { router } from "expo-router"
import { Ionicons } from "@expo/vector-icons"
import { useConnections } from "../../src/stores/connections"
import type { ConnectionType } from "../../src/lib/types"
export default function AddConnectionScreen() {
const colorScheme = useColorScheme()
const isDark = colorScheme === "dark"
const { addConnection, testConnection } = useConnections()
const [mode, setMode] = useState<"quick" | "advanced">("quick")
const [type, setType] = useState<ConnectionType>("local")
const [name, setName] = useState("")
const [ip, setIp] = useState("")
const [port, setPort] = useState("4096")
const [url, setUrl] = useState("")
const [directory, setDirectory] = useState("")
const [username, setUsername] = useState("")
const [password, setPassword] = useState("")
const [isConnecting, setIsConnecting] = useState(false)
const buildUrl = () => {
if (mode === "advanced") return url.trim()
if (!ip.trim()) return ""
return `http://${ip.trim()}:${port || "4096"}`
}
const handleQuickConnect = async () => {
const serverUrl = buildUrl()
if (!serverUrl) {
Alert.alert("Error", "Please enter your computer's IP address")
return
}
setIsConnecting(true)
// Test connection first
const success = await testConnection(
{
id: "",
name: name || "My Server",
type: "local",
url: serverUrl,
username: username.trim() || undefined,
},
password || undefined,
)
if (success) {
// Save and go back
await addConnection(
{
name: name.trim() || "My Server",
type: "local",
url: serverUrl,
username: username.trim() || undefined,
},
password || undefined,
)
setIsConnecting(false)
router.back()
} else {
setIsConnecting(false)
Alert.alert(
"Connection Failed",
"Could not connect to the server.\n\nMake sure:\n1. OpenCode is running: opencode serve --hostname 0.0.0.0\n2. You're on the same WiFi network\n3. The IP address is correct",
[{ text: "OK" }],
)
}
}
const handleAdvancedSave = async () => {
if (!name.trim()) {
Alert.alert("Error", "Please enter a connection name")
return
}
if (!url.trim()) {
Alert.alert("Error", "Please enter a server URL")
return
}
setIsConnecting(true)
await addConnection(
{
name: name.trim(),
type,
url: url.trim(),
directory: directory.trim() || undefined,
username: username.trim() || undefined,
},
password || undefined,
)
setIsConnecting(false)
router.back()
}
// Quick connect mode - simplified
if (mode === "quick") {
return (
<ScrollView
style={[styles.container, isDark && styles.containerDark]}
contentContainerStyle={styles.content}
keyboardShouldPersistTaps="handled"
>
<View style={styles.quickHeader}>
<Ionicons name="wifi" size={48} color={isDark ? "#ffffff" : "#0a0a0a"} />
<Text style={[styles.quickTitle, isDark && styles.textDark]}>Connect to OpenCode</Text>
<Text style={[styles.quickSubtitle, isDark && styles.hintDark]}>
Enter your computer's IP address to connect
</Text>
</View>
{/* IP Address */}
<Text style={[styles.label, isDark && styles.labelDark]}>IP Address</Text>
<View style={styles.ipRow}>
<TextInput
style={[styles.input, styles.ipInput, isDark && styles.inputDark]}
placeholder="192.168.1.100"
placeholderTextColor={isDark ? "#666666" : "#999999"}
value={ip}
onChangeText={setIp}
autoCapitalize="none"
autoCorrect={false}
keyboardType="decimal-pad"
/>
<Text style={[styles.ipColon, isDark && styles.textDark]}>:</Text>
<TextInput
style={[styles.input, styles.portInput, isDark && styles.inputDark]}
placeholder="4096"
placeholderTextColor={isDark ? "#666666" : "#999999"}
value={port}
onChangeText={setPort}
keyboardType="number-pad"
/>
</View>
{/* Optional name */}
<Text style={[styles.label, isDark && styles.labelDark]}>Name (optional)</Text>
<TextInput
style={[styles.input, isDark && styles.inputDark]}
placeholder="My Mac"
placeholderTextColor={isDark ? "#666666" : "#999999"}
value={name}
onChangeText={setName}
/>
{/* Password if needed */}
<Text style={[styles.label, isDark && styles.labelDark]}>Password (if set on server)</Text>
<TextInput
style={[styles.input, isDark && styles.inputDark]}
placeholder="Leave empty if none"
placeholderTextColor={isDark ? "#666666" : "#999999"}
value={password}
onChangeText={setPassword}
secureTextEntry
/>
{/* Connect button */}
<TouchableOpacity
style={[styles.connectButton, isDark && styles.connectButtonDark]}
onPress={handleQuickConnect}
disabled={isConnecting}
>
{isConnecting ? (
<ActivityIndicator size="small" color={isDark ? "#0a0a0a" : "#ffffff"} />
) : (
<>
<Ionicons name="flash" size={20} color={isDark ? "#0a0a0a" : "#ffffff"} />
<Text style={[styles.connectButtonText, isDark && styles.connectButtonTextDark]}>Connect</Text>
</>
)}
</TouchableOpacity>
{/* Help text */}
<View style={[styles.helpBox, isDark && styles.helpBoxDark]}>
<Text style={[styles.helpTitle, isDark && styles.textDark]}>How to find your IP:</Text>
<Text style={[styles.helpText, isDark && styles.hintDark]}>
On your Mac, run:{"\n"}
<Text style={styles.code}>ipconfig getifaddr en0</Text>
</Text>
<Text style={[styles.helpText, isDark && styles.hintDark, { marginTop: 8 }]}>
Tailscale examples:{"\n"}
<Text style={styles.code}>http://100.64.12.34:4096</Text>
{"\n"}
<Text style={styles.code}>http://my-mac.tailnet.ts.net:4096</Text>
</Text>
<Text style={[styles.helpText, isDark && styles.hintDark, { marginTop: 8 }]}>
Use <Text style={styles.code}>http://</Text> for local and Tailscale addresses unless TLS is configured,
then use <Text style={styles.code}>https://</Text>.
</Text>
<Text style={[styles.helpText, isDark && styles.hintDark, { marginTop: 8 }]}>
Make sure OpenCode is running:{"\n"}
<Text style={styles.code}>opencode serve --hostname 0.0.0.0</Text>
</Text>
</View>
{/* Advanced mode link */}
<TouchableOpacity style={styles.advancedLink} onPress={() => setMode("advanced")}>
<Text style={[styles.advancedLinkText, isDark && styles.hintDark]}>
Advanced options (tunnels, cloud, auth)
</Text>
<Ionicons name="chevron-forward" size={16} color={isDark ? "#888888" : "#666666"} />
</TouchableOpacity>
</ScrollView>
)
}
// Advanced mode - full options
return (
<ScrollView
style={[styles.container, isDark && styles.containerDark]}
contentContainerStyle={styles.content}
keyboardShouldPersistTaps="handled"
>
<TouchableOpacity style={styles.backToQuick} onPress={() => setMode("quick")}>
<Ionicons name="chevron-back" size={16} color={isDark ? "#888888" : "#666666"} />
<Text style={[styles.backToQuickText, isDark && styles.hintDark]}>Simple mode</Text>
</TouchableOpacity>
{/* Connection Type */}
<Text style={[styles.label, isDark && styles.labelDark]}>Connection Type</Text>
<View style={styles.typeContainer}>
{[
{ type: "local" as const, label: "Local", icon: "wifi" as const },
{ type: "tunnel" as const, label: "Tunnel", icon: "globe" as const },
{ type: "cloud" as const, label: "Cloud", icon: "cloud" as const },
].map((t) => (
<TouchableOpacity
key={t.type}
style={[
styles.typeOption,
isDark && styles.typeOptionDark,
type === t.type && styles.typeOptionSelected,
type === t.type && isDark && styles.typeOptionSelectedDark,
]}
onPress={() => setType(t.type)}
>
<Ionicons
name={t.icon}
size={20}
color={type === t.type ? (isDark ? "#0a0a0a" : "#ffffff") : isDark ? "#888888" : "#666666"}
/>
<Text
style={[
styles.typeLabel,
isDark && styles.textDark,
type === t.type && styles.typeLabelSelected,
type === t.type && isDark && styles.typeLabelSelectedDark,
]}
>
{t.label}
</Text>
</TouchableOpacity>
))}
</View>
{/* Name */}
<Text style={[styles.label, isDark && styles.labelDark]}>Name</Text>
<TextInput
style={[styles.input, isDark && styles.inputDark]}
placeholder="My Server"
placeholderTextColor={isDark ? "#666666" : "#999999"}
value={name}
onChangeText={setName}
/>
{/* URL */}
<Text style={[styles.label, isDark && styles.labelDark]}>Server URL</Text>
<TextInput
style={[styles.input, isDark && styles.inputDark]}
placeholder={
type === "local"
? "http://192.168.1.100:4096"
: type === "tunnel"
? "https://your-tunnel.trycloudflare.com"
: "https://api.opencode.ai"
}
placeholderTextColor={isDark ? "#666666" : "#999999"}
value={url}
onChangeText={setUrl}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
/>
<Text style={[styles.hint, isDark && styles.hintDark]}>
Local/Tailscale examples: <Text style={styles.code}>http://100.64.12.34:4096</Text> or{" "}
<Text style={styles.code}>http://my-mac.tailnet.ts.net:4096</Text>. Use <Text style={styles.code}>https://</Text>
only when TLS is configured.
</Text>
{/* Directory */}
<Text style={[styles.label, isDark && styles.labelDark]}>Project Directory (Optional)</Text>
<TextInput
style={[styles.input, isDark && styles.inputDark]}
placeholder="/path/to/project"
placeholderTextColor={isDark ? "#666666" : "#999999"}
value={directory}
onChangeText={setDirectory}
autoCapitalize="none"
autoCorrect={false}
/>
<Text style={[styles.hint, isDark && styles.hintDark]}>
Leave empty to use the server's current directory, or specify a path to work in a different folder.
</Text>
{/* Auth */}
<Text style={[styles.sectionTitle, isDark && styles.textDark]}>Authentication</Text>
<Text style={[styles.label, isDark && styles.labelDark]}>Username</Text>
<TextInput
style={[styles.input, isDark && styles.inputDark]}
placeholder="admin"
placeholderTextColor={isDark ? "#666666" : "#999999"}
value={username}
onChangeText={setUsername}
autoCapitalize="none"
autoCorrect={false}
/>
<Text style={[styles.label, isDark && styles.labelDark]}>Password</Text>
<TextInput
style={[styles.input, isDark && styles.inputDark]}
placeholder="password"
placeholderTextColor={isDark ? "#666666" : "#999999"}
value={password}
onChangeText={setPassword}
secureTextEntry
/>
{/* Save */}
<TouchableOpacity
style={[styles.connectButton, isDark && styles.connectButtonDark, { marginTop: 32 }]}
onPress={handleAdvancedSave}
disabled={isConnecting}
>
{isConnecting ? (
<ActivityIndicator size="small" color={isDark ? "#0a0a0a" : "#ffffff"} />
) : (
<Text style={[styles.connectButtonText, isDark && styles.connectButtonTextDark]}>Save Connection</Text>
)}
</TouchableOpacity>
</ScrollView>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#ffffff",
},
containerDark: {
backgroundColor: "#0a0a0a",
},
content: {
padding: 16,
paddingBottom: 32,
},
// Quick connect styles
quickHeader: {
alignItems: "center",
paddingVertical: 24,
},
quickTitle: {
fontSize: 24,
fontWeight: "700",
color: "#0a0a0a",
marginTop: 16,
},
quickSubtitle: {
fontSize: 15,
color: "#666666",
marginTop: 8,
textAlign: "center",
},
ipRow: {
flexDirection: "row",
alignItems: "center",
gap: 8,
},
ipInput: {
flex: 1,
},
ipColon: {
fontSize: 20,
fontWeight: "600",
color: "#0a0a0a",
},
portInput: {
width: 80,
},
connectButton: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: 8,
padding: 16,
borderRadius: 12,
backgroundColor: "#0a0a0a",
marginTop: 24,
},
connectButtonDark: {
backgroundColor: "#ffffff",
},
connectButtonText: {
fontSize: 18,
fontWeight: "600",
color: "#ffffff",
},
connectButtonTextDark: {
color: "#0a0a0a",
},
helpBox: {
backgroundColor: "#f5f5f5",
borderRadius: 12,
padding: 16,
marginTop: 24,
},
helpBoxDark: {
backgroundColor: "#1a1a1a",
},
helpTitle: {
fontSize: 14,
fontWeight: "600",
color: "#0a0a0a",
marginBottom: 8,
},
helpText: {
fontSize: 13,
color: "#666666",
lineHeight: 20,
},
code: {
fontFamily: "monospace",
backgroundColor: "#e5e5e5",
paddingHorizontal: 4,
borderRadius: 4,
},
advancedLink: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: 4,
paddingVertical: 16,
marginTop: 16,
},
advancedLinkText: {
fontSize: 14,
color: "#666666",
},
backToQuick: {
flexDirection: "row",
alignItems: "center",
gap: 4,
paddingVertical: 8,
},
backToQuickText: {
fontSize: 14,
color: "#666666",
},
// Original styles
label: {
fontSize: 14,
fontWeight: "600",
color: "#0a0a0a",
marginTop: 16,
marginBottom: 8,
},
labelDark: {
color: "#ffffff",
},
typeContainer: {
flexDirection: "row",
gap: 8,
},
typeOption: {
flex: 1,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
padding: 12,
borderRadius: 8,
backgroundColor: "#f5f5f5",
gap: 6,
},
typeOptionDark: {
backgroundColor: "#1a1a1a",
},
typeOptionSelected: {
backgroundColor: "#0a0a0a",
},
typeOptionSelectedDark: {
backgroundColor: "#ffffff",
},
typeLabel: {
fontSize: 13,
fontWeight: "500",
color: "#666666",
},
textDark: {
color: "#ffffff",
},
typeLabelSelected: {
color: "#ffffff",
},
typeLabelSelectedDark: {
color: "#0a0a0a",
},
hint: {
fontSize: 13,
color: "#666666",
marginTop: 8,
},
hintDark: {
color: "#888888",
},
input: {
backgroundColor: "#f5f5f5",
borderRadius: 8,
paddingHorizontal: 16,
paddingVertical: 12,
fontSize: 16,
color: "#0a0a0a",
},
inputDark: {
backgroundColor: "#1a1a1a",
color: "#ffffff",
},
sectionTitle: {
fontSize: 18,
fontWeight: "600",
color: "#0a0a0a",
marginTop: 32,
marginBottom: 8,
},
})

817
app/session/[id].tsx Normal file
View File

@@ -0,0 +1,817 @@
import { useEffect, useRef, useState, useCallback, useMemo } from "react"
import {
View,
Text,
FlatList,
TextInput,
TouchableOpacity,
StyleSheet,
useColorScheme,
KeyboardAvoidingView,
Platform,
ActivityIndicator,
Alert,
} from "react-native"
import { useLocalSearchParams, Stack, useRouter } from "expo-router"
import { Ionicons } from "@expo/vector-icons"
import { useSafeAreaInsets } from "react-native-safe-area-context"
import * as ImagePicker from "expo-image-picker"
import * as ImageManipulator from "expo-image-manipulator"
import * as Clipboard from "expo-clipboard"
import type BottomSheet from "@gorhom/bottom-sheet"
import {
MessageBubble,
PermissionPrompt,
QuestionPrompt,
StatusIndicator,
SlashPopover,
ModelPicker,
ImageAttachments,
SessionInfo,
type SlashCommand,
type Attachment,
} from "../../src/components/chat"
import { useSessions } from "../../src/stores/sessions"
import { useEvents, refreshPending } from "../../src/stores/events"
import { useConnections } from "../../src/stores/connections"
import { useAuth } from "../../src/stores/auth"
import { useCatalog } from "../../src/stores/catalog"
import { useSpeech } from "../../src/lib/speech"
// --- Builtin slash commands ---
const BUILTIN_COMMANDS: SlashCommand[] = [
{
trigger: "new",
title: "New Session",
description: "Start a new session",
icon: "add-circle-outline",
type: "builtin",
},
{
trigger: "model",
title: "Switch Model",
description: "Choose a different model",
icon: "hardware-chip-outline",
type: "builtin",
},
{
trigger: "agent",
title: "Switch Agent",
description: "Cycle to next agent",
icon: "person-outline",
type: "builtin",
},
{
trigger: "compact",
title: "Compact",
description: "Summarize conversation",
icon: "contract-outline",
type: "builtin",
},
{ trigger: "clear", title: "Clear", description: "Clear the session", icon: "trash-outline", type: "builtin" },
]
function getShortDir(dir?: string): string | null {
if (!dir) return null
const parts = dir.split("/").filter(Boolean)
return parts[parts.length - 1] || null
}
export default function SessionScreen() {
const { id, directory } = useLocalSearchParams<{ id: string; directory?: string }>()
const router = useRouter()
const colorScheme = useColorScheme()
const isDark = colorScheme === "dark"
const insets = useSafeAreaInsets()
const flatListRef = useRef<FlatList>(null)
const modelSheetRef = useRef<BottomSheet>(null)
const [input, setInput] = useState("")
const [attachments, setAttachments] = useState<Attachment[]>([])
const [showInfo, setShowInfo] = useState(false)
const {
currentSession,
messages,
parts,
isLoading,
loadingMore,
hasMore,
selectSession,
sendMessage,
abortSession,
loadOlderMessages,
} = useSessions()
// Derive sending state for this specific session
const isSending = useSessions((s) => !!(currentSession && s.sending[currentSession.id]))
const { authenticateForMessage } = useAuth()
const { client } = useConnections()
// Catalog
const catalog = useCatalog()
const agents = Array.isArray(catalog.agents) ? catalog.agents : []
const serverCommands = Array.isArray(catalog.commands) ? catalog.commands : []
const providers = Array.isArray(catalog.providers) ? catalog.providers : []
const agent = catalog.agent || ""
const model = catalog.model
const setModel = catalog.setModel
const cycleAgent = catalog.cycleAgent
// Permission & question state
const sessionID = currentSession?.id
const permissions = useEvents((s) => (sessionID ? s.permissions[sessionID] : undefined)) || []
const questions = useEvents((s) => (sessionID ? s.questions[sessionID] : undefined)) || []
const shortDir = getShortDir(currentSession?.directory)
const [showScrollButton, setShowScrollButton] = useState(false)
// Voice input — transcript appends to the text input on completion
const speech = useSpeech(
useCallback((text: string) => {
setInput((prev) => (prev ? prev + " " + text : text))
}, []),
)
// Slash command state
const slashActive = input.startsWith("/") && !input.includes(" ")
const slashQuery = slashActive ? input.slice(1) : ""
const allCommands = useMemo<SlashCommand[]>(() => {
const custom: SlashCommand[] = serverCommands.map((cmd) => ({
trigger: cmd.name,
title: cmd.name,
description: cmd.description,
icon: "code-slash-outline",
type: "custom",
}))
return [...custom, ...BUILTIN_COMMANDS]
}, [serverCommands])
// Inverted FlatList: data is reversed (newest first) so newest renders at bottom
const messageData = useMemo(
() =>
(messages || [])
.map((msg) => ({
message: msg,
parts: (parts && parts[msg.id]) || [],
}))
.reverse(),
[messages, parts],
)
const scrollToBottom = useCallback((animated = true) => {
flatListRef.current?.scrollToOffset({ offset: 0, animated })
}, [])
useEffect(() => {
if (!id) return
selectSession(id, directory).then(() => {
// Re-fetch pending permissions/questions from the server to recover from
// missed SSE events or failed optimistic removals
if (client) refreshPending(client, id)
})
}, [id])
// Sync model chip from latest assistant message
useEffect(() => {
if (!messages || messages.length === 0) return
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i]
if (msg.role === "assistant" && msg.providerID && msg.modelID) {
setModel({ providerID: msg.providerID, modelID: msg.modelID })
return
}
if (msg.role === "user" && msg.model) {
setModel(msg.model)
return
}
}
}, [currentSession?.id, messages?.length])
// Slash command handler
const handleSlashSelect = useCallback(
(cmd: SlashCommand) => {
if (cmd.type === "builtin") {
switch (cmd.trigger) {
case "new":
router.back()
return
case "model":
setInput("")
modelSheetRef.current?.expand()
return
case "agent":
setInput("")
cycleAgent()
return
case "compact":
setInput("")
return
case "clear":
setInput("")
return
}
}
setInput(`/${cmd.trigger} `)
},
[router, cycleAgent],
)
// --- Image picking ---
// Convert any image (including HEIC/HEIF from iOS) to guaranteed JPEG bytes
const MAX_DIMENSION = 1568 // Anthropic recommended max
async function toJpeg(uri: string, width: number, height: number): Promise<Attachment> {
const actions: ImageManipulator.Action[] = []
if (width > MAX_DIMENSION || height > MAX_DIMENSION) {
const scale = MAX_DIMENSION / Math.max(width, height)
actions.push({ resize: { width: Math.round(width * scale), height: Math.round(height * scale) } })
}
const result = await ImageManipulator.manipulateAsync(uri, actions, {
format: ImageManipulator.SaveFormat.JPEG,
compress: 0.8,
base64: true,
})
return {
uri: result.uri,
mime: "image/jpeg",
filename: "image.jpg",
width: result.width,
height: result.height,
base64: result.base64 || undefined,
}
}
const pickFromLibrary = useCallback(async () => {
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ["images"],
allowsMultipleSelection: true,
quality: 1, // full quality - we compress in manipulator
})
if (result.canceled) return
const items = await Promise.all(result.assets.map((a) => toJpeg(a.uri, a.width, a.height)))
setAttachments((prev) => [...prev, ...items])
}, [])
const pickFromCamera = useCallback(async () => {
const perm = await ImagePicker.requestCameraPermissionsAsync()
if (!perm.granted) {
Alert.alert("Permission needed", "Camera access is required to take photos.")
return
}
const result = await ImagePicker.launchCameraAsync({ quality: 1 })
if (result.canceled) return
const a = result.assets[0]
const item = await toJpeg(a.uri, a.width, a.height)
setAttachments((prev) => [...prev, item])
}, [])
const pasteFromClipboard = useCallback(async () => {
// Try image first
const hasImage = await Clipboard.hasImageAsync()
if (hasImage) {
const img = await Clipboard.getImageAsync({ format: "png" })
if (img?.data) {
const uri = img.data.startsWith("data:") ? img.data : `data:image/png;base64,${img.data}`
setAttachments((prev) => [
...prev,
{
uri,
mime: "image/png",
filename: "clipboard.png",
width: img.size.width,
height: img.size.height,
},
])
return
}
}
// Fall back to text
const hasText = await Clipboard.hasStringAsync()
if (hasText) {
const text = await Clipboard.getStringAsync()
if (text) {
setInput((prev) => prev + text)
return
}
}
Alert.alert("Empty clipboard", "Clipboard does not contain text or an image.")
}, [])
const removeAttachment = useCallback((index: number) => {
setAttachments((prev) => prev.filter((_, i) => i !== index))
}, [])
// --- Send ---
const handleSend = async () => {
if (!input.trim() && attachments.length === 0) return
const authenticated = await authenticateForMessage()
if (!authenticated) return
const text = input.trim()
const files = [...attachments]
setInput("")
setAttachments([])
// Server slash commands (no attachments for commands)
if (text.startsWith("/") && files.length === 0) {
const [cmdName, ...args] = text.split(" ")
const name = cmdName.slice(1)
const match = serverCommands.find((c) => c.name === name)
if (match && client && currentSession) {
client.session
.command(currentSession.id, {
command: name,
arguments: args.join(" "),
agent,
model: model ? `${model.providerID}/${model.modelID}` : undefined,
})
.catch((err) => console.error("Command failed:", err))
return
}
}
// Messages are queued server-side when the session is busy.
// No need to abort - just send and it will be processed after current response.
await sendMessage(text, model || undefined, agent || undefined, files)
}
// In inverted mode, offset 0 = bottom. Show scroll button when scrolled away from bottom.
const handleScroll = useCallback((event: any) => {
const { contentOffset } = event.nativeEvent
setShowScrollButton(contentOffset.y > 200)
}, [])
// Debounce: onEndReached can fire multiple times during a single scroll gesture
const loadingTriggered = useRef(false)
const handleLoadMore = useCallback(() => {
if (hasMore && !loadingMore && !loadingTriggered.current) {
loadingTriggered.current = true
loadOlderMessages()
}
}, [hasMore, loadingMore, loadOlderMessages])
// Reset trigger when loading finishes
useEffect(() => {
if (!loadingMore) loadingTriggered.current = false
}, [loadingMore])
const handlePermissionReply = async (requestID: string, reply: "once" | "always" | "reject") => {
if (!client || !sessionID) return
// Snapshot for rollback
const snapshot = useEvents.getState().permissions[sessionID] || []
// Optimistically remove from UI
useEvents.setState((state) => ({
permissions: {
...state.permissions,
[sessionID]: snapshot.filter((p) => p.id !== requestID),
},
}))
try {
await client.permission.reply(requestID, reply)
} catch (err) {
console.error("Permission reply failed:", err)
// Restore the prompt so the user can retry
useEvents.setState((state) => ({
permissions: { ...state.permissions, [sessionID]: snapshot },
}))
Alert.alert("Reply Failed", "Could not send your response. Please try again.")
}
}
const handleQuestionReply = async (requestID: string, answers: string[][]) => {
if (!client || !sessionID) return
const snapshot = useEvents.getState().questions[sessionID] || []
useEvents.setState((state) => ({
questions: {
...state.questions,
[sessionID]: snapshot.filter((q) => q.id !== requestID),
},
}))
try {
await client.question.reply(requestID, answers)
} catch (err) {
console.error("Question reply failed:", err)
useEvents.setState((state) => ({
questions: { ...state.questions, [sessionID]: snapshot },
}))
Alert.alert("Reply Failed", "Could not send your response. Please try again.")
}
}
const handleQuestionReject = async (requestID: string) => {
if (!client || !sessionID) return
const snapshot = useEvents.getState().questions[sessionID] || []
useEvents.setState((state) => ({
questions: {
...state.questions,
[sessionID]: snapshot.filter((q) => q.id !== requestID),
},
}))
try {
await client.question.reject(requestID)
} catch (err) {
console.error("Question reject failed:", err)
useEvents.setState((state) => ({
questions: { ...state.questions, [sessionID]: snapshot },
}))
Alert.alert("Reject Failed", "Could not send your response. Please try again.")
}
}
const handleModelSelect = useCallback(
(providerID: string, modelID: string) => {
setModel({ providerID, modelID })
},
[setModel],
)
// Current agent display
const currentAgent = agents.find((a) => a.name === agent)
const agentColor = currentAgent?.color || "#8b5cf6"
const modelLabel = model?.modelID ? model.modelID.split("/").pop() || model.modelID : "default"
return (
<>
<Stack.Screen
options={{
title: currentSession?.title || "Session",
headerRight: () => (
<View style={s.headerRight}>
{shortDir && (
<View style={[s.dirBadge, isDark && s.dirBadgeDark]}>
<Ionicons name="folder-outline" size={14} color={isDark ? "#888888" : "#666666"} />
<Text style={[s.dirText, isDark && s.dirTextDark]}>{shortDir}</Text>
</View>
)}
<TouchableOpacity onPress={() => setShowInfo((v) => !v)} hitSlop={8}>
<Ionicons
name={showInfo ? "stats-chart" : "stats-chart-outline"}
size={20}
color={showInfo ? "#3b82f6" : isDark ? "#888888" : "#666666"}
/>
</TouchableOpacity>
</View>
),
}}
/>
<KeyboardAvoidingView
style={[s.container, isDark && s.containerDark]}
behavior={Platform.OS === "ios" ? "padding" : "height"}
keyboardVerticalOffset={Platform.OS === "ios" ? 90 : 0}
>
{/* Session info pulldown */}
<SessionInfo
session={currentSession}
messages={messages || []}
providers={providers}
visible={showInfo}
isDark={isDark}
hasMore={hasMore}
loadingAll={loadingMore}
onLoadAll={() => {
if (hasMore && !loadingMore) loadOlderMessages()
}}
onScrollToTop={() => {
flatListRef.current?.scrollToEnd({ animated: true })
}}
onClose={() => setShowInfo(false)}
/>
{isLoading ? (
<View style={s.loading}>
<ActivityIndicator size="large" color={isDark ? "#ffffff" : "#0a0a0a"} />
</View>
) : (
<View style={s.listWrap}>
<FlatList
ref={flatListRef}
data={messageData}
inverted
keyExtractor={(item) => item.message.id}
renderItem={({ item }) => <MessageBubble message={item.message} parts={item.parts} isDark={isDark} />}
contentContainerStyle={s.messageList}
onScroll={handleScroll}
scrollEventThrottle={100}
onEndReached={handleLoadMore}
onEndReachedThreshold={0.5}
// Prevent jump when older messages are prepended
maintainVisibleContentPosition={{ minIndexForVisible: 0 }}
ListFooterComponent={
loadingMore ? (
<View style={s.loadingMore}>
<ActivityIndicator size="small" color={isDark ? "#888888" : "#666666"} />
<Text style={[s.loadingMoreText, isDark && s.metaDark]}>Loading older messages...</Text>
</View>
) : null
}
ListEmptyComponent={
<View style={s.emptyInverted}>
<Ionicons name="chatbubble-outline" size={48} color={isDark ? "#444444" : "#cccccc"} />
<Text style={[s.emptyText, isDark && s.metaDark]}>Start a conversation</Text>
<Text style={[s.emptyHint, isDark && s.metaDark]}>Type / for commands</Text>
</View>
}
/>
{showScrollButton && (
<TouchableOpacity style={[s.scrollBtn, isDark && s.scrollBtnDark]} onPress={() => scrollToBottom(true)}>
<Ionicons name="chevron-down" size={24} color={isDark ? "#ffffff" : "#0a0a0a"} />
</TouchableOpacity>
)}
</View>
)}
{/* Status */}
{currentSession && <StatusIndicator sessionID={currentSession.id} isDark={isDark} />}
{/* Permissions */}
{permissions.map((perm) => (
<PermissionPrompt
key={perm.id}
permission={perm}
isDark={isDark}
onReply={(reply) => handlePermissionReply(perm.id, reply)}
/>
))}
{/* Questions */}
{questions.map((q) => (
<QuestionPrompt
key={q.id}
request={q}
isDark={isDark}
onReply={(answers) => handleQuestionReply(q.id, answers)}
onReject={() => handleQuestionReject(q.id)}
/>
))}
{/* Slash popover */}
{slashActive && (
<SlashPopover query={slashQuery} commands={allCommands} isDark={isDark} onSelect={handleSlashSelect} />
)}
{/* Agent/model toolbar */}
<View style={[s.toolbar, isDark && s.toolbarDark]}>
<TouchableOpacity
style={[s.agentChip, { borderColor: agentColor }]}
onPress={() => cycleAgent()}
onLongPress={() => cycleAgent(-1)}
>
<View style={[s.agentDot, { backgroundColor: agentColor }]} />
<Text style={[s.agentLabel, isDark && s.textWhite]}>{agent || "build"}</Text>
<Ionicons name="swap-horizontal-outline" size={12} color={isDark ? "#888888" : "#666666"} />
</TouchableOpacity>
<TouchableOpacity
style={[s.modelChip, isDark && s.modelChipDark]}
onPress={() => modelSheetRef.current?.expand()}
>
<Ionicons name="hardware-chip-outline" size={14} color={isDark ? "#888888" : "#666666"} />
<Text style={[s.modelLabel, isDark && s.metaDark]} numberOfLines={1}>
{modelLabel}
</Text>
</TouchableOpacity>
</View>
{/* Attachment preview */}
<ImageAttachments attachments={attachments} isDark={isDark} onRemove={removeAttachment} />
{/* Input */}
<View
style={[s.inputContainer, isDark && s.inputContainerDark, { paddingBottom: Math.max(12, insets.bottom) }]}
>
<View style={s.inputRow}>
{/* Attach button */}
<TouchableOpacity style={s.attachBtn} onPress={pickFromLibrary} onLongPress={pickFromCamera}>
<Ionicons name="add-circle-outline" size={26} color={isDark ? "#888888" : "#666666"} />
</TouchableOpacity>
{/* Clipboard paste button */}
<TouchableOpacity style={s.attachBtn} onPress={pasteFromClipboard}>
<Ionicons name="clipboard-outline" size={22} color={isDark ? "#888888" : "#666666"} />
</TouchableOpacity>
<TextInput
style={[s.input, isDark && s.inputDark, speech.listening && s.inputListening]}
placeholder={speech.listening ? "Listening..." : isSending ? "Send a follow-up..." : "Type a message..."}
placeholderTextColor={speech.listening ? "#ef4444" : isDark ? "#666666" : "#999999"}
value={speech.listening ? speech.transcript : input}
onChangeText={speech.listening ? undefined : setInput}
editable={!speech.listening}
multiline
maxLength={10000}
/>
{/* Stop button: only when busy and no input */}
{isSending && !input.trim() && attachments.length === 0 && !speech.listening && (
<TouchableOpacity style={s.stopBtn} onPress={abortSession}>
<Ionicons name="stop" size={20} color="#ffffff" />
</TouchableOpacity>
)}
{/* Mic button: when no input, not sending, and not listening */}
{!isSending && !input.trim() && attachments.length === 0 && !speech.listening && (
<TouchableOpacity style={s.micBtn} onPress={speech.start}>
<Ionicons name="mic" size={22} color={isDark ? "#888888" : "#666666"} />
</TouchableOpacity>
)}
{/* Listening indicator: tap to stop */}
{speech.listening && (
<TouchableOpacity style={s.micBtnActive} onPress={speech.stop}>
<Ionicons name="mic" size={22} color="#ffffff" />
</TouchableOpacity>
)}
{/* Send button: when there's input */}
{!speech.listening && (input.trim() || attachments.length > 0) && (
<TouchableOpacity style={s.sendBtn} onPress={handleSend}>
<Ionicons name="send" size={20} color="#ffffff" />
</TouchableOpacity>
)}
</View>
</View>
</KeyboardAvoidingView>
{/* Model picker bottom sheet */}
<ModelPicker
sheetRef={modelSheetRef}
providers={providers}
selected={model}
isDark={isDark}
onSelect={handleModelSelect}
/>
</>
)
}
const s = StyleSheet.create({
container: { flex: 1, backgroundColor: "#ffffff" },
containerDark: { backgroundColor: "#0a0a0a" },
loading: { flex: 1, justifyContent: "center", alignItems: "center" },
listWrap: { flex: 1, position: "relative" },
// Messages
messageList: { padding: 16, paddingBottom: 8 },
// Scroll button
scrollBtn: {
position: "absolute",
bottom: 16,
right: 16,
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: "#ffffff",
justifyContent: "center",
alignItems: "center",
shadowColor: "#000",
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.2,
shadowRadius: 4,
elevation: 4,
},
scrollBtnDark: { backgroundColor: "#2a2a2a" },
// Loading more (appears at top in inverted list = ListFooterComponent)
loadingMore: {
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
gap: 8,
paddingVertical: 16,
},
loadingMoreText: { fontSize: 13, color: "#999999" },
// Empty (inverted list flips content, so use transform to un-flip)
emptyInverted: {
flex: 1,
justifyContent: "center",
alignItems: "center",
paddingVertical: 64,
transform: [{ scaleY: -1 }],
},
// Empty
empty: { flex: 1, justifyContent: "center", alignItems: "center", paddingVertical: 64 },
emptyText: { fontSize: 16, color: "#999999", marginTop: 12 },
emptyHint: { fontSize: 13, color: "#bbbbbb", marginTop: 4 },
metaDark: { color: "#666666" },
textWhite: { color: "#ffffff" },
// Toolbar
toolbar: {
flexDirection: "row",
alignItems: "center",
gap: 8,
paddingHorizontal: 12,
paddingVertical: 6,
borderTopWidth: 1,
borderTopColor: "#e5e5e5",
backgroundColor: "#ffffff",
},
toolbarDark: { borderTopColor: "#1a1a1a", backgroundColor: "#0a0a0a" },
agentChip: {
flexDirection: "row",
alignItems: "center",
gap: 5,
borderWidth: 1,
borderRadius: 12,
paddingHorizontal: 10,
paddingVertical: 4,
},
agentDot: { width: 8, height: 8, borderRadius: 4 },
agentLabel: { fontSize: 12, fontWeight: "600", color: "#0a0a0a" },
modelChip: {
flexDirection: "row",
alignItems: "center",
gap: 4,
backgroundColor: "#f5f5f5",
borderRadius: 12,
paddingHorizontal: 10,
paddingVertical: 4,
},
modelChipDark: { backgroundColor: "#1a1a1a" },
modelLabel: { fontSize: 12, color: "#666666", maxWidth: 160 },
// Input
inputContainer: {
padding: 12,
borderTopWidth: 1,
borderTopColor: "#e5e5e5",
backgroundColor: "#ffffff",
},
inputContainerDark: { borderTopColor: "#1a1a1a", backgroundColor: "#0a0a0a" },
inputRow: {
flexDirection: "row",
alignItems: "flex-end",
},
attachBtn: {
width: 36,
height: 40,
justifyContent: "center",
alignItems: "center",
},
input: {
flex: 1,
backgroundColor: "#f5f5f5",
borderRadius: 20,
paddingHorizontal: 16,
paddingVertical: 10,
fontSize: 16,
maxHeight: 120,
color: "#0a0a0a",
},
inputDark: { backgroundColor: "#1a1a1a", color: "#ffffff" },
inputListening: { borderWidth: 1, borderColor: "#ef4444" },
sendBtn: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: "#0a0a0a",
justifyContent: "center",
alignItems: "center",
marginLeft: 8,
},
sendBtnDisabled: { backgroundColor: "#cccccc" },
micBtn: {
width: 40,
height: 40,
borderRadius: 20,
justifyContent: "center",
alignItems: "center",
marginLeft: 8,
},
micBtnActive: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: "#ef4444",
justifyContent: "center",
alignItems: "center",
marginLeft: 8,
},
stopBtn: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: "#ef4444",
justifyContent: "center",
alignItems: "center",
marginLeft: 8,
},
// Header
headerRight: { flexDirection: "row", alignItems: "center", gap: 8 },
dirBadge: {
flexDirection: "row",
alignItems: "center",
gap: 4,
backgroundColor: "#f5f5f5",
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 6,
},
dirBadgeDark: { backgroundColor: "#1a1a1a" },
dirText: { fontSize: 12, color: "#666666", fontWeight: "500" },
dirTextDark: { color: "#888888" },
})

View File

@@ -0,0 +1 @@
{"placeholder": true}

1
assets/favicon.json Normal file
View File

@@ -0,0 +1 @@
{"placeholder": true}

1
assets/icon.json Normal file
View File

@@ -0,0 +1 @@
{"placeholder": true}

1
assets/splash-icon.json Normal file
View File

@@ -0,0 +1 @@
{"placeholder": true}

50
package.json Normal file
View File

@@ -0,0 +1,50 @@
{
"name": "@opencode-ai/mobile",
"version": "0.0.1",
"private": true,
"main": "expo-router/entry",
"scripts": {
"start": "expo start",
"start:clear": "expo start --clear",
"ios": "expo run:ios",
"android": "expo run:android",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@expo/vector-icons": "^15.0.3",
"@gorhom/bottom-sheet": "5.2.8",
"@react-navigation/native": "^7.0.14",
"@tanstack/react-query": "^5.62.0",
"expo": "^54.0.0",
"expo-clipboard": "8.0.8",
"expo-device": "~8.0.10",
"expo-image-manipulator": "~14.0.8",
"expo-image-picker": "17.0.10",
"expo-linking": "~8.0.11",
"expo-local-authentication": "~17.0.8",
"expo-notifications": "~0.32.16",
"expo-router": "~6.0.22",
"expo-secure-store": "~15.0.8",
"expo-speech-recognition": "3.0.1",
"expo-status-bar": "~3.0.9",
"react": "19.1.0",
"react-native": "0.81.5",
"react-native-gesture-handler": "~2.28.0",
"react-native-marked": "8.0.0",
"react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-svg": "15.15.1",
"react-native-worklets": "0.5.1",
"zustand": "^5.0.2"
},
"devDependencies": {
"@types/react": "~19.1.10",
"typescript": "~5.9.2"
},
"expo": {
"install": {
"exclude": []
}
}
}

110
src/components/AuthGate.tsx Normal file
View File

@@ -0,0 +1,110 @@
import { useEffect, type ReactNode } from "react"
import { View, Text, TouchableOpacity, StyleSheet, useColorScheme } from "react-native"
import { Ionicons } from "@expo/vector-icons"
import { useAuth } from "../stores/auth"
interface Props {
children: ReactNode
}
export function AuthGate({ children }: Props) {
const colorScheme = useColorScheme()
const isDark = colorScheme === "dark"
const { isAuthenticated, settings, hasBiometrics, biometricType, authenticate, error } = useAuth()
// If biometric not required, or no biometrics available, show children
if (!settings.requireBiometric || !hasBiometrics) {
return <>{children}</>
}
// If authenticated, show children
if (isAuthenticated) {
return <>{children}</>
}
// Show auth screen
const iconName =
biometricType === 1 // FINGERPRINT
? "finger-print"
: biometricType === 2 // FACIAL_RECOGNITION
? "scan"
: "lock-closed"
return (
<View style={[styles.container, isDark && styles.containerDark]}>
<View style={styles.content}>
<Ionicons name={iconName} size={64} color={isDark ? "#ffffff" : "#0a0a0a"} />
<Text style={[styles.title, isDark && styles.textDark]}>OpenCode Locked</Text>
<Text style={[styles.subtitle, isDark && styles.subtitleDark]}>Authenticate to access your sessions</Text>
{error && <Text style={styles.error}>{error}</Text>}
<TouchableOpacity style={[styles.button, isDark && styles.buttonDark]} onPress={authenticate}>
<Ionicons name={iconName} size={24} color={isDark ? "#0a0a0a" : "#ffffff"} />
<Text style={[styles.buttonText, isDark && styles.buttonTextDark]}>Unlock</Text>
</TouchableOpacity>
</View>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#ffffff",
justifyContent: "center",
alignItems: "center",
},
containerDark: {
backgroundColor: "#0a0a0a",
},
content: {
alignItems: "center",
padding: 32,
},
title: {
fontSize: 24,
fontWeight: "600",
marginTop: 24,
color: "#0a0a0a",
},
textDark: {
color: "#ffffff",
},
subtitle: {
fontSize: 16,
color: "#666666",
marginTop: 8,
textAlign: "center",
},
subtitleDark: {
color: "#888888",
},
error: {
color: "#ef4444",
marginTop: 16,
fontSize: 14,
},
button: {
flexDirection: "row",
alignItems: "center",
backgroundColor: "#0a0a0a",
paddingHorizontal: 32,
paddingVertical: 16,
borderRadius: 12,
marginTop: 32,
gap: 12,
},
buttonDark: {
backgroundColor: "#ffffff",
},
buttonText: {
color: "#ffffff",
fontSize: 18,
fontWeight: "600",
},
buttonTextDark: {
color: "#0a0a0a",
},
})

View File

@@ -0,0 +1,147 @@
import { View, Text, StyleSheet, Platform } from "react-native"
const mono = Platform.OS === "ios" ? "Menlo" : "monospace"
interface Props {
before: string
after: string
isDark: boolean
}
interface DiffLine {
type: "add" | "remove" | "context"
text: string
}
function computeDiff(before: string, after: string): DiffLine[] {
const a = before.split("\n")
const b = after.split("\n")
const lines: DiffLine[] = []
// Simple LCS-based diff
const m = a.length
const n = b.length
const dp: number[][] = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0))
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
dp[i][j] = a[i - 1] === b[j - 1] ? dp[i - 1][j - 1] + 1 : Math.max(dp[i - 1][j], dp[i][j - 1])
}
}
// Backtrack
const result: DiffLine[] = []
let i = m
let j = n
while (i > 0 || j > 0) {
if (i > 0 && j > 0 && a[i - 1] === b[j - 1]) {
result.push({ type: "context", text: a[i - 1] })
i--
j--
} else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
result.push({ type: "add", text: b[j - 1] })
j--
} else {
result.push({ type: "remove", text: a[i - 1] })
i--
}
}
result.reverse()
// Collapse long context runs (show max 3 context lines between changes)
const collapsed: DiffLine[] = []
let contextRun = 0
for (const line of result) {
if (line.type === "context") {
contextRun++
if (contextRun <= 3) {
collapsed.push(line)
} else if (contextRun === 4) {
collapsed.push({ type: "context", text: "..." })
}
} else {
contextRun = 0
collapsed.push(line)
}
}
return collapsed
}
export function DiffView({ before, after, isDark }: Props) {
const lines = computeDiff(before, after)
if (lines.length === 0) return null
return (
<View style={[s.container, isDark && s.containerDark]}>
{lines.map((line, idx) => (
<View
key={idx}
style={[
s.line,
line.type === "add" && (isDark ? s.addDark : s.add),
line.type === "remove" && (isDark ? s.removeDark : s.remove),
]}
>
<Text style={[s.prefix, isDark && s.prefixDark]}>
{line.type === "add" ? "+" : line.type === "remove" ? "-" : " "}
</Text>
<Text
style={[
s.text,
isDark && s.textDark,
line.type === "add" && s.addText,
line.type === "remove" && s.removeText,
]}
selectable
numberOfLines={1}
>
{line.text}
</Text>
</View>
))}
</View>
)
}
const s = StyleSheet.create({
container: {
borderRadius: 6,
overflow: "hidden",
backgroundColor: "#f8f8f8",
marginTop: 6,
},
containerDark: { backgroundColor: "#1a1a1a" },
line: {
flexDirection: "row",
paddingHorizontal: 8,
paddingVertical: 1,
},
add: { backgroundColor: "#dcfce7" },
addDark: { backgroundColor: "#052e16" },
remove: { backgroundColor: "#fee2e2" },
removeDark: { backgroundColor: "#2a0a0a" },
prefix: {
width: 16,
fontSize: 12,
fontFamily: mono,
color: "#999999",
lineHeight: 20,
},
prefixDark: { color: "#666666" },
text: {
flex: 1,
fontSize: 12,
fontFamily: mono,
color: "#0a0a0a",
lineHeight: 20,
},
textDark: { color: "#e5e5e5" },
addText: { color: "#16a34a" },
removeText: { color: "#dc2626" },
})

View File

@@ -0,0 +1,246 @@
import { useState, useCallback, useMemo } from "react"
import { View, Text, TouchableOpacity, StyleSheet } from "react-native"
import { Ionicons } from "@expo/vector-icons"
import BottomSheet, { BottomSheetBackdrop, BottomSheetFlatList, BottomSheetTextInput } from "@gorhom/bottom-sheet"
interface Props {
sheetRef: React.RefObject<BottomSheet | null>
current?: string
recents: string[]
serverHome: string | null
isDark: boolean
onSwitch: (directory?: string) => void
}
export function DirectorySwitcher({ sheetRef, current, recents, serverHome, isDark, onSwitch }: Props) {
const [custom, setCustom] = useState("")
const handleSelect = useCallback(
(dir?: string) => {
onSwitch(dir)
setCustom("")
sheetRef.current?.close()
},
[onSwitch, sheetRef],
)
const handleCustomSubmit = useCallback(() => {
const dir = custom.trim()
if (!dir) return
handleSelect(dir)
}, [custom, handleSelect])
// Build list: server default + recents (excluding current)
const items = useMemo(() => {
const list: Array<{ label: string; dir?: string; active: boolean }> = [
{ label: "Server Default", dir: undefined, active: !current },
]
for (const dir of recents) {
if (dir === current) continue
const short = dir.split("/").filter(Boolean).pop() || dir
list.push({ label: short, dir, active: false })
}
return list
}, [recents, current])
const shortCurrent = current ? current.split("/").filter(Boolean).pop() || current : null
return (
<BottomSheet
ref={sheetRef}
index={-1}
snapPoints={["45%", "70%"]}
enablePanDownToClose
keyboardBehavior="interactive"
keyboardBlurBehavior="restore"
android_keyboardInputMode="adjustResize"
backgroundStyle={isDark ? s.sheetDark : s.sheet}
handleIndicatorStyle={{ backgroundColor: isDark ? "#666666" : "#cccccc" }}
backdropComponent={(props) => (
<BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.5} />
)}
onChange={(idx) => {
if (idx === -1) setCustom("")
}}
>
<View style={s.header}>
<Text style={[s.title, isDark && s.white]}>Switch Project</Text>
{shortCurrent && (
<View style={s.current}>
<Ionicons name="folder" size={14} color="#8b5cf6" />
<Text style={s.currentText} numberOfLines={1}>
{shortCurrent}
</Text>
</View>
)}
</View>
{/* Custom directory input */}
<View style={s.inputWrap}>
<BottomSheetTextInput
style={[s.input, isDark && s.inputDark]}
placeholder={serverHome ? `${serverHome}/...` : "/path/to/project"}
placeholderTextColor={isDark ? "#666666" : "#999999"}
value={custom}
onChangeText={(text) => {
if (serverHome && text === "~") setCustom(serverHome)
else if (serverHome && text.startsWith("~/")) setCustom(serverHome + text.slice(1))
else setCustom(text)
}}
onSubmitEditing={handleCustomSubmit}
returnKeyType="go"
autoCapitalize="none"
autoCorrect={false}
/>
{custom.trim() && (
<TouchableOpacity style={[s.goBtn, isDark && s.goBtnDark]} onPress={handleCustomSubmit}>
<Ionicons name="arrow-forward" size={18} color={isDark ? "#0a0a0a" : "#ffffff"} />
</TouchableOpacity>
)}
</View>
{/* Quick path chips */}
{serverHome && (
<View style={s.chips}>
<TouchableOpacity style={[s.chip, isDark && s.chipDark]} onPress={() => setCustom(serverHome)}>
<Text style={[s.chipText, isDark && s.chipTextDark]}>~</Text>
</TouchableOpacity>
<TouchableOpacity style={[s.chip, isDark && s.chipDark]} onPress={() => setCustom(serverHome + "/")}>
<Text style={[s.chipText, isDark && s.chipTextDark]}>~/</Text>
</TouchableOpacity>
</View>
)}
{/* Recent directories */}
<BottomSheetFlatList
data={items}
keyExtractor={(item: (typeof items)[number], i: number) => item.dir || `default-${i}`}
renderItem={({ item }: { item: (typeof items)[number] }) => (
<TouchableOpacity
style={[s.row, isDark && s.rowDark, item.active && s.rowActive]}
onPress={() => handleSelect(item.dir)}
>
<View style={s.rowIcon}>
<Ionicons
name={item.dir ? "folder-outline" : "server-outline"}
size={20}
color={item.active ? "#8b5cf6" : isDark ? "#888888" : "#666666"}
/>
</View>
<View style={s.rowContent}>
<Text style={[s.rowLabel, isDark && s.white, item.active && s.rowLabelActive]} numberOfLines={1}>
{item.label}
</Text>
{item.dir && (
<Text style={[s.rowPath, isDark && s.dimDark]} numberOfLines={1}>
{item.dir}
</Text>
)}
{!item.dir && <Text style={[s.rowPath, isDark && s.dimDark]}>Uses server's working directory</Text>}
</View>
{item.active && <Ionicons name="checkmark-circle" size={20} color="#8b5cf6" />}
</TouchableOpacity>
)}
contentContainerStyle={s.list}
ListHeaderComponent={
items.length > 1 ? <Text style={[s.section, isDark && s.dimDark]}>Recent Projects</Text> : null
}
/>
</BottomSheet>
)
}
const s = StyleSheet.create({
sheet: { backgroundColor: "#ffffff" },
sheetDark: { backgroundColor: "#1a1a1a" },
header: { paddingHorizontal: 16, paddingBottom: 8, gap: 6 },
title: { fontSize: 18, fontWeight: "700", color: "#0a0a0a" },
white: { color: "#ffffff" },
current: {
flexDirection: "row",
alignItems: "center",
gap: 6,
},
currentText: {
fontSize: 13,
color: "#8b5cf6",
fontWeight: "500",
},
inputWrap: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 16,
paddingBottom: 8,
gap: 8,
},
chips: {
flexDirection: "row",
gap: 8,
paddingHorizontal: 16,
paddingBottom: 12,
},
chip: {
paddingHorizontal: 12,
paddingVertical: 6,
backgroundColor: "#e8e5f0",
borderRadius: 16,
},
chipDark: {
backgroundColor: "#2a2040",
},
chipText: {
fontSize: 13,
fontWeight: "600",
color: "#6d28d9",
},
chipTextDark: {
color: "#c4b5fd",
},
input: {
flex: 1,
backgroundColor: "#f5f5f5",
borderRadius: 10,
paddingHorizontal: 14,
paddingVertical: 10,
fontSize: 15,
color: "#0a0a0a",
},
inputDark: { backgroundColor: "#2a2a2a", color: "#ffffff" },
goBtn: {
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: "#0a0a0a",
justifyContent: "center",
alignItems: "center",
},
goBtnDark: { backgroundColor: "#ffffff" },
list: { paddingBottom: 40 },
section: {
fontSize: 12,
fontWeight: "700",
color: "#999999",
textTransform: "uppercase",
letterSpacing: 0.5,
paddingHorizontal: 16,
paddingTop: 4,
paddingBottom: 8,
},
dimDark: { color: "#666666" },
row: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 16,
paddingVertical: 12,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: "#e5e5e5",
gap: 12,
},
rowDark: { borderBottomColor: "#2a2a2a" },
rowActive: { backgroundColor: "#f5f3ff" },
rowIcon: { width: 28, alignItems: "center" },
rowContent: { flex: 1 },
rowLabel: { fontSize: 15, fontWeight: "500", color: "#0a0a0a" },
rowLabelActive: { color: "#8b5cf6" },
rowPath: { fontSize: 12, color: "#999999", marginTop: 1 },
})

View File

@@ -0,0 +1,82 @@
import { View, Text, Image, TouchableOpacity, StyleSheet, ScrollView } from "react-native"
import { Ionicons } from "@expo/vector-icons"
export interface Attachment {
uri: string
mime: string
filename?: string
width?: number
height?: number
base64?: string
}
interface Props {
attachments: Attachment[]
isDark: boolean
onRemove: (index: number) => void
}
export function ImageAttachments({ attachments, isDark, onRemove }: Props) {
if (attachments.length === 0) return null
return (
<View style={[s.container, isDark && s.containerDark]}>
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={s.scroll}>
{attachments.map((att, idx) => (
<View key={`${att.uri}-${idx}`} style={s.thumb}>
<Image source={{ uri: att.uri }} style={s.image} resizeMode="cover" />
<TouchableOpacity style={[s.remove, isDark && s.removeDark]} onPress={() => onRemove(idx)}>
<Ionicons name="close" size={14} color="#ffffff" />
</TouchableOpacity>
{att.filename && (
<Text style={[s.label, isDark && s.labelDark]} numberOfLines={1}>
{att.filename}
</Text>
)}
</View>
))}
</ScrollView>
</View>
)
}
const s = StyleSheet.create({
container: {
paddingHorizontal: 12,
paddingVertical: 8,
borderTopWidth: 1,
borderTopColor: "#e5e5e5",
backgroundColor: "#ffffff",
},
containerDark: { backgroundColor: "#0a0a0a", borderTopColor: "#1a1a1a" },
scroll: { gap: 8 },
thumb: { position: "relative" },
image: {
width: 72,
height: 72,
borderRadius: 8,
backgroundColor: "#f0f0f0",
},
remove: {
position: "absolute",
top: -6,
right: -6,
width: 22,
height: 22,
borderRadius: 11,
backgroundColor: "#0a0a0a",
justifyContent: "center",
alignItems: "center",
borderWidth: 2,
borderColor: "#ffffff",
},
removeDark: { backgroundColor: "#ef4444", borderColor: "#0a0a0a" },
label: {
fontSize: 10,
color: "#666666",
marginTop: 2,
maxWidth: 72,
textAlign: "center",
},
labelDark: { color: "#888888" },
})

View File

@@ -0,0 +1,162 @@
import { memo } from "react"
import { View, Text, Image, StyleSheet, ScrollView, TouchableOpacity, Dimensions } from "react-native"
import { Ionicons } from "@expo/vector-icons"
import { Markdown } from "../markdown"
import { ToolCallCard } from "./ToolCallCard"
import { ReasoningBlock } from "./ReasoningBlock"
import type { Message, Part } from "../../lib/sdk"
const SCREEN_WIDTH = Dimensions.get("window").width
function isImageMime(mime?: string): boolean {
return !!mime && mime.startsWith("image/")
}
interface Props {
message: Message
parts: Part[]
isDark: boolean
}
// TODO: Replace with streamdown-rn once React 19 types PR lands - it has
// built-in block-level memoization that eliminates re-renders for stable blocks
export const MessageBubble = memo(
function MessageBubble({ message, parts, isDark }: Props) {
const isUser = message.role === "user"
const textParts = parts.filter((p) => p.type === "text")
const reasoningParts = parts.filter((p) => p.type === "reasoning")
const toolParts = parts.filter((p) => p.type === "tool")
const fileParts = parts.filter((p) => p.type === "file" && isImageMime(p.mime))
const text = textParts.map((p) => p.text).join("\n") || ""
const reasoning = reasoningParts.map((p) => p.text).join("\n") || ""
return (
<View
style={[
s.bubble,
isUser ? s.user : s.assistant,
isUser && isDark && s.userDark,
!isUser && isDark && s.assistantDark,
]}
>
{/* Role indicator */}
<View style={s.header}>
<Ionicons
name={isUser ? "person" : "sparkles"}
size={14}
color={isUser ? (isDark ? "#ffffff" : "#0a0a0a") : "#8b5cf6"}
/>
<Text style={[s.role, isUser && s.roleUser, isDark && s.textWhite]}>{isUser ? "You" : "Assistant"}</Text>
{message.model && <Text style={[s.modelTag, isDark && s.modelTagDark]}>{message.model.modelID}</Text>}
{!isUser && message.modelID && <Text style={[s.modelTag, isDark && s.modelTagDark]}>{message.modelID}</Text>}
</View>
{/* Image attachments */}
{fileParts.length > 0 && (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={s.imageRow}
style={s.imageScroll}
>
{fileParts.map((fp) => (
<View key={fp.id} style={s.imageWrap}>
<Image source={{ uri: fp.url }} style={s.attachedImage} resizeMode="cover" />
{fp.filename && (
<Text style={[s.imageLabel, isDark && s.imageLabelDark]} numberOfLines={1}>
{fp.filename}
</Text>
)}
</View>
))}
</ScrollView>
)}
{/* Reasoning (collapsible) */}
{reasoning.length > 0 && <ReasoningBlock text={reasoning} isDark={isDark} />}
{/* Message text */}
{text.length > 0 &&
(isUser ? (
<Text style={[s.messageText, isDark && s.textWhite]} selectable>
{text}
</Text>
) : (
<View style={s.markdownWrap}>
<Markdown>{text}</Markdown>
</View>
))}
{/* Tool calls */}
{toolParts.map((tool) => (
<ToolCallCard key={tool.id} tool={tool} isDark={isDark} />
))}
{/* Tokens/cost for assistant messages */}
{!isUser && message.tokens && (
<Text style={[s.tokens, isDark && s.tokensDark]}>
{message.tokens.input + message.tokens.output} tokens
{message.cost ? ` · $${message.cost.toFixed(4)}` : ""}
</Text>
)}
</View>
)
},
(prev, next) => {
// Only re-render if message content actually changed
// This prevents completed messages from re-rendering during streaming
if (prev.message.id !== next.message.id) return false
if (prev.isDark !== next.isDark) return false
if (prev.parts.length !== next.parts.length) return false
// Compare the last part's text content - this is what changes during streaming
const prevLast = prev.parts[prev.parts.length - 1]
const nextLast = next.parts[next.parts.length - 1]
if (!prevLast && !nextLast) return true
if (!prevLast || !nextLast) return false
return prevLast.type === nextLast.type && prevLast.text === nextLast.text
},
)
const s = StyleSheet.create({
bubble: { marginBottom: 16, padding: 12, borderRadius: 12, maxWidth: "100%" },
user: { backgroundColor: "#f5f5f5", marginLeft: 32 },
userDark: { backgroundColor: "#1a1a1a" },
assistant: { backgroundColor: "#f0f0ff" },
assistantDark: { backgroundColor: "#1a1a2e" },
header: { flexDirection: "row", alignItems: "center", gap: 6, marginBottom: 8 },
role: { fontSize: 13, fontWeight: "600", color: "#666666" },
roleUser: { color: "#0a0a0a" },
textWhite: { color: "#ffffff" },
modelTag: {
fontSize: 11,
color: "#999999",
backgroundColor: "#e5e5e5",
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 4,
overflow: "hidden",
},
modelTagDark: { backgroundColor: "#2a2a2a", color: "#888888" },
messageText: { fontSize: 15, lineHeight: 22, color: "#0a0a0a" },
markdownWrap: { marginHorizontal: -4 },
tokens: { fontSize: 11, color: "#999999", marginTop: 8 },
tokensDark: { color: "#666666" },
// Images
imageScroll: { marginBottom: 8 },
imageRow: { gap: 8 },
imageWrap: { alignItems: "center" },
attachedImage: {
width: Math.min(200, SCREEN_WIDTH * 0.5),
height: Math.min(200, SCREEN_WIDTH * 0.5),
borderRadius: 8,
backgroundColor: "#e5e5e5",
},
imageLabel: { fontSize: 10, color: "#666666", marginTop: 2, maxWidth: 200 },
imageLabelDark: { color: "#888888" },
})

View File

@@ -0,0 +1,186 @@
import { useState, useCallback, useMemo, useRef } from "react"
import { View, Text, TouchableOpacity, StyleSheet } from "react-native"
import { Ionicons } from "@expo/vector-icons"
import BottomSheet, { BottomSheetBackdrop, BottomSheetSectionList, BottomSheetTextInput } from "@gorhom/bottom-sheet"
interface ModelItem {
providerID: string
providerName: string
modelID: string
modelName: string
}
interface Provider {
id: string
name: string
models: Array<{ id: string; name: string }>
}
interface Props {
providers: Provider[]
selected: { providerID: string; modelID: string } | null
isDark: boolean
onSelect: (providerID: string, modelID: string) => void
sheetRef: React.RefObject<BottomSheet | null>
}
export function ModelPicker({ providers, selected, isDark, onSelect, sheetRef }: Props) {
const [search, setSearch] = useState("")
const sections = useMemo(() => {
const list = Array.isArray(providers) ? providers : []
const q = search.toLowerCase()
const result = list
.map((p) => {
const models = (p.models || [])
.filter(
(m) =>
!q ||
m.id.toLowerCase().includes(q) ||
m.name.toLowerCase().includes(q) ||
p.name.toLowerCase().includes(q),
)
.map((m) => ({
providerID: p.id,
providerName: p.name || p.id,
modelID: m.id,
modelName: m.name || m.id,
}))
// Sort selected model to top within its group
if (selected) {
models.sort((a, b) => {
const aActive = a.providerID === selected.providerID && a.modelID === selected.modelID
const bActive = b.providerID === selected.providerID && b.modelID === selected.modelID
return aActive === bActive ? 0 : aActive ? -1 : 1
})
}
return { title: p.name || p.id, data: models }
})
.filter((s) => s.data.length > 0)
// Sort the section containing the selected model to the top
if (selected) {
result.sort((a, b) => {
const aHas = a.data.some((m) => m.providerID === selected.providerID && m.modelID === selected.modelID)
const bHas = b.data.some((m) => m.providerID === selected.providerID && m.modelID === selected.modelID)
return aHas === bHas ? 0 : aHas ? -1 : 1
})
}
return result
}, [providers, search, selected])
const handleSelect = useCallback(
(providerID: string, modelID: string) => {
onSelect(providerID, modelID)
setSearch("")
sheetRef.current?.close()
},
[onSelect, sheetRef],
)
return (
<BottomSheet
ref={sheetRef}
index={-1}
snapPoints={["50%", "80%"]}
enablePanDownToClose
keyboardBehavior="interactive"
keyboardBlurBehavior="restore"
android_keyboardInputMode="adjustResize"
backgroundStyle={isDark ? s.sheetDark : s.sheet}
handleIndicatorStyle={{ backgroundColor: isDark ? "#666666" : "#cccccc" }}
backdropComponent={(props) => (
<BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.5} />
)}
onChange={(idx) => {
if (idx === -1) setSearch("")
}}
>
<View style={s.header}>
<Text style={[s.title, isDark && s.textWhite]}>Select Model</Text>
<BottomSheetTextInput
style={[s.search, isDark && s.searchDark]}
placeholder="Search models..."
placeholderTextColor={isDark ? "#666666" : "#999999"}
value={search}
onChangeText={setSearch}
autoCorrect={false}
autoCapitalize="none"
/>
</View>
<BottomSheetSectionList
sections={sections}
keyExtractor={(item: ModelItem) => `${item.providerID}/${item.modelID}`}
renderSectionHeader={({ section }: { section: { title: string } }) => (
<View style={[s.sectionHeader, isDark && s.sectionHeaderDark]}>
<Text style={[s.sectionTitle, isDark && s.metaDark]}>{section.title}</Text>
</View>
)}
renderItem={({ item }: { item: ModelItem }) => {
const active = selected?.providerID === item.providerID && selected?.modelID === item.modelID
return (
<TouchableOpacity
style={[s.row, isDark && s.rowDark, active && (isDark ? s.rowSelectedDark : s.rowSelected)]}
onPress={() => handleSelect(item.providerID, item.modelID)}
>
<View style={s.rowText}>
<Text style={[s.rowName, isDark && s.textWhite]} numberOfLines={1}>
{item.modelName || item.modelID}
</Text>
<Text style={[s.rowProvider, isDark && s.metaDark]}>{item.providerName || item.providerID}</Text>
</View>
{active && <Ionicons name="checkmark-circle" size={20} color="#8b5cf6" />}
</TouchableOpacity>
)
}}
contentContainerStyle={s.content}
stickySectionHeadersEnabled
/>
</BottomSheet>
)
}
const s = StyleSheet.create({
sheet: { backgroundColor: "#ffffff" },
sheetDark: { backgroundColor: "#1a1a1a" },
header: { paddingHorizontal: 16, paddingBottom: 12, gap: 10 },
title: { fontSize: 18, fontWeight: "700", color: "#0a0a0a" },
textWhite: { color: "#ffffff" },
search: {
backgroundColor: "#f5f5f5",
borderRadius: 10,
paddingHorizontal: 14,
paddingVertical: 10,
fontSize: 15,
color: "#0a0a0a",
},
searchDark: { backgroundColor: "#2a2a2a", color: "#ffffff" },
content: { paddingBottom: 40 },
sectionHeader: {
backgroundColor: "#f5f5f5",
paddingHorizontal: 16,
paddingVertical: 8,
},
sectionHeaderDark: { backgroundColor: "#111111" },
sectionTitle: {
fontSize: 12,
fontWeight: "700",
color: "#999999",
textTransform: "uppercase",
letterSpacing: 0.5,
},
metaDark: { color: "#666666" },
row: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 16,
paddingVertical: 12,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: "#e5e5e5",
},
rowDark: { borderBottomColor: "#2a2a2a" },
rowSelected: { backgroundColor: "#f5f3ff" },
rowSelectedDark: { backgroundColor: "#1f1a2e" },
rowText: { flex: 1 },
rowName: { fontSize: 15, fontWeight: "500", color: "#0a0a0a" },
rowProvider: { fontSize: 12, color: "#999999", marginTop: 1 },
})

View File

@@ -0,0 +1,61 @@
import { View, Text, TouchableOpacity, StyleSheet } from "react-native"
import { Ionicons } from "@expo/vector-icons"
interface Props {
permission: { id: string; permission: string; patterns: string[] }
isDark: boolean
onReply: (reply: "once" | "always" | "reject") => void
}
export function PermissionPrompt({ permission, isDark, onReply }: Props) {
return (
<View style={[s.card, isDark && s.cardDark]}>
<View style={s.header}>
<Ionicons name="shield-outline" size={18} color="#f59e0b" />
<Text style={[s.title, isDark && s.textWhite]}>Permission Required</Text>
</View>
<Text style={[s.type, isDark && s.typeDark]}>
{permission.permission}: {permission.patterns.join(", ")}
</Text>
<View style={s.actions}>
<TouchableOpacity style={[s.btn, s.deny]} onPress={() => onReply("reject")}>
<Text style={s.denyText}>Deny</Text>
</TouchableOpacity>
<TouchableOpacity style={[s.btn, s.always, isDark && s.alwaysDark]} onPress={() => onReply("always")}>
<Text style={[s.alwaysText, isDark && s.textWhite]}>Always</Text>
</TouchableOpacity>
<TouchableOpacity style={[s.btn, s.allow, isDark && s.allowDark]} onPress={() => onReply("once")}>
<Text style={[s.allowText, isDark && s.allowTextDark]}>Allow</Text>
</TouchableOpacity>
</View>
</View>
)
}
const s = StyleSheet.create({
card: {
margin: 12,
padding: 16,
backgroundColor: "#fffbeb",
borderRadius: 12,
borderWidth: 1,
borderColor: "#fef3c7",
},
cardDark: { backgroundColor: "#1a1800", borderColor: "#333300" },
header: { flexDirection: "row", alignItems: "center", gap: 8, marginBottom: 8 },
title: { fontSize: 15, fontWeight: "600", color: "#92400e" },
textWhite: { color: "#ffffff" },
type: { fontSize: 13, color: "#78350f", marginBottom: 12 },
typeDark: { color: "#d4a574" },
actions: { flexDirection: "row", gap: 8 },
btn: { flex: 1, paddingVertical: 10, borderRadius: 8, alignItems: "center" },
deny: { backgroundColor: "#fef2f2" },
denyText: { color: "#dc2626", fontWeight: "600", fontSize: 14 },
always: { backgroundColor: "#f5f5f5" },
alwaysDark: { backgroundColor: "#2a2a2a" },
alwaysText: { color: "#0a0a0a", fontWeight: "600", fontSize: 14 },
allow: { backgroundColor: "#0a0a0a" },
allowDark: { backgroundColor: "#ffffff" },
allowText: { color: "#ffffff", fontWeight: "600", fontSize: 14 },
allowTextDark: { color: "#0a0a0a" },
})

View File

@@ -0,0 +1,190 @@
import { useState } from "react"
import { View, Text, TextInput, TouchableOpacity, StyleSheet } from "react-native"
import { Ionicons } from "@expo/vector-icons"
interface QuestionOption {
label: string
description: string
}
interface Question {
question: string
header: string
options: QuestionOption[]
multiple?: boolean
custom?: boolean
}
interface Props {
request: {
id: string
questions: Question[]
}
isDark: boolean
onReply: (answers: string[][]) => void
onReject: () => void
}
export function QuestionPrompt({ request, isDark, onReply, onReject }: Props) {
const [answers, setAnswers] = useState<string[][]>(request.questions.map(() => []))
const [custom, setCustom] = useState("")
const [showCustom, setShowCustom] = useState(false)
const [current, setCurrent] = useState(0)
const q = request.questions[current]
if (!q) return null
const toggleOption = (label: string) => {
setAnswers((prev) => {
const copy = [...prev]
const selected = copy[current] || []
if (q.multiple) {
copy[current] = selected.includes(label) ? selected.filter((a) => a !== label) : [...selected, label]
} else {
copy[current] = [label]
if (request.questions.length === 1) {
setTimeout(() => onReply(copy), 100)
}
}
return copy
})
}
const submitCustom = () => {
if (!custom.trim()) return
const copy = [...answers]
copy[current] = [custom.trim()]
setAnswers(copy)
setCustom("")
setShowCustom(false)
if (request.questions.length === 1) {
onReply(copy)
}
}
return (
<View style={[s.card, isDark && s.cardDark]}>
<View style={s.header}>
<Ionicons name="chatbubble-ellipses-outline" size={18} color="#8b5cf6" />
<Text style={[s.title, isDark && s.textWhite]}>{q.header || "Question"}</Text>
</View>
<Text style={[s.question, isDark && s.textWhite]}>{q.question}</Text>
<View style={s.options}>
{q.options.map((opt) => {
const selected = (answers[current] || []).includes(opt.label)
return (
<TouchableOpacity
key={opt.label}
style={[
s.option,
isDark && s.optionDark,
selected && s.optionSelected,
selected && isDark && s.optionSelectedDark,
]}
onPress={() => toggleOption(opt.label)}
>
<Text style={[s.optionLabel, isDark && s.textWhite, selected && s.optionLabelSelected]}>{opt.label}</Text>
{opt.description ? <Text style={[s.optionDesc, isDark && s.metaDark]}>{opt.description}</Text> : null}
</TouchableOpacity>
)
})}
{q.custom !== false &&
(showCustom ? (
<View style={s.customRow}>
<TextInput
style={[s.customInput, isDark && s.customInputDark]}
placeholder="Type your answer..."
placeholderTextColor={isDark ? "#666666" : "#999999"}
value={custom}
onChangeText={setCustom}
onSubmitEditing={submitCustom}
autoFocus
/>
<TouchableOpacity onPress={submitCustom} style={s.customSubmit}>
<Ionicons name="send" size={18} color="#8b5cf6" />
</TouchableOpacity>
</View>
) : (
<TouchableOpacity style={[s.option, isDark && s.optionDark]} onPress={() => setShowCustom(true)}>
<Text style={[s.optionLabel, { color: "#8b5cf6" }]}>Type your own answer</Text>
</TouchableOpacity>
))}
</View>
<View style={s.footer}>
<TouchableOpacity onPress={onReject}>
<Text style={[s.dismiss, isDark && s.metaDark]}>Dismiss</Text>
</TouchableOpacity>
{(request.questions.length > 1 || q.multiple) && (
<TouchableOpacity
style={[s.submitBtn, isDark && s.submitBtnDark]}
onPress={() => {
if (current < request.questions.length - 1) {
setCurrent(current + 1)
} else {
onReply(answers)
}
}}
>
<Text style={s.submitText}>{current < request.questions.length - 1 ? "Next" : "Submit"}</Text>
</TouchableOpacity>
)}
</View>
</View>
)
}
const s = StyleSheet.create({
card: {
margin: 12,
padding: 16,
backgroundColor: "#f5f3ff",
borderRadius: 12,
borderWidth: 1,
borderColor: "#ede9fe",
},
cardDark: { backgroundColor: "#1a1a2e", borderColor: "#2a2a3e" },
header: { flexDirection: "row", alignItems: "center", gap: 8, marginBottom: 8 },
title: { fontSize: 15, fontWeight: "600", color: "#6d28d9" },
textWhite: { color: "#ffffff" },
question: { fontSize: 14, lineHeight: 20, color: "#0a0a0a", marginBottom: 12 },
metaDark: { color: "#666666" },
options: { gap: 8 },
option: {
padding: 12,
borderRadius: 8,
backgroundColor: "#ffffff",
borderWidth: 1,
borderColor: "#e5e5e5",
},
optionDark: { backgroundColor: "#2a2a2a", borderColor: "#3a3a3a" },
optionSelected: { borderColor: "#8b5cf6", backgroundColor: "#f5f3ff" },
optionSelectedDark: { borderColor: "#8b5cf6", backgroundColor: "#2a1a3e" },
optionLabel: { fontSize: 14, fontWeight: "600", color: "#0a0a0a" },
optionLabelSelected: { color: "#6d28d9" },
optionDesc: { fontSize: 12, color: "#666666", marginTop: 2 },
customRow: { flexDirection: "row", gap: 8 },
customInput: {
flex: 1,
backgroundColor: "#ffffff",
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 10,
fontSize: 14,
borderWidth: 1,
borderColor: "#e5e5e5",
color: "#0a0a0a",
},
customInputDark: { backgroundColor: "#2a2a2a", borderColor: "#3a3a3a", color: "#ffffff" },
customSubmit: { justifyContent: "center", alignItems: "center", padding: 8 },
footer: { flexDirection: "row", justifyContent: "space-between", alignItems: "center", marginTop: 12 },
dismiss: { fontSize: 14, color: "#999999" },
submitBtn: { backgroundColor: "#8b5cf6", paddingHorizontal: 20, paddingVertical: 10, borderRadius: 8 },
submitBtnDark: { backgroundColor: "#7c3aed" },
submitText: { color: "#ffffff", fontWeight: "600", fontSize: 14 },
})

View File

@@ -0,0 +1,48 @@
import { useState } from "react"
import { View, Text, TouchableOpacity, StyleSheet } from "react-native"
import { Ionicons } from "@expo/vector-icons"
interface Props {
text: string
isDark: boolean
}
export function ReasoningBlock({ text, isDark }: Props) {
const [expanded, setExpanded] = useState(false)
return (
<TouchableOpacity
style={[s.block, isDark && s.blockDark]}
onPress={() => setExpanded(!expanded)}
activeOpacity={0.7}
>
<View style={s.header}>
<Ionicons name="bulb-outline" size={14} color="#f59e0b" />
<Text style={[s.label, isDark && s.labelDark]}>Thinking</Text>
<Ionicons name={expanded ? "chevron-up" : "chevron-down"} size={14} color={isDark ? "#666666" : "#999999"} />
</View>
{expanded && (
<Text style={[s.text, isDark && s.textDark]} selectable>
{text}
</Text>
)}
</TouchableOpacity>
)
}
const s = StyleSheet.create({
block: {
backgroundColor: "#fffbeb",
borderRadius: 8,
padding: 10,
marginBottom: 8,
borderWidth: 1,
borderColor: "#fef3c7",
},
blockDark: { backgroundColor: "#1a1a0a", borderColor: "#333300" },
header: { flexDirection: "row", alignItems: "center", gap: 6 },
label: { fontSize: 12, fontWeight: "600", color: "#92400e", flex: 1 },
labelDark: { color: "#f59e0b" },
text: { fontSize: 13, lineHeight: 20, color: "#78350f", marginTop: 8 },
textDark: { color: "#d4a574" },
})

View File

@@ -0,0 +1,338 @@
import { useMemo } from "react"
import { View, Text, StyleSheet, TouchableOpacity, ActivityIndicator } from "react-native"
import { Ionicons } from "@expo/vector-icons"
import type { Message, Session } from "../../lib/sdk"
import type { Provider } from "../../stores/catalog"
interface Props {
session: Session | null
messages: Message[]
providers: Provider[]
visible: boolean
isDark: boolean
hasMore: boolean
loadingAll: boolean
onLoadAll: () => void
onScrollToTop: () => void
onClose: () => void
}
function compact(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`
return String(n)
}
function formatCost(cost: number): string {
return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(cost)
}
function formatTime(ts: number): string {
const diff = Date.now() - ts
const mins = Math.floor(diff / 60_000)
if (mins < 1) return "just now"
if (mins < 60) return `${mins}m ago`
const hours = Math.floor(mins / 60)
if (hours < 24) return `${hours}h ago`
const days = Math.floor(hours / 24)
if (days < 7) return `${days}d ago`
return new Date(ts).toLocaleDateString(undefined, { month: "short", day: "numeric" })
}
export function SessionInfo({
session,
messages,
providers,
visible,
isDark,
hasMore,
loadingAll,
onLoadAll,
onScrollToTop,
onClose,
}: Props) {
// Match TUI: last assistant message tokens (context window), cumulative cost
const stats = useMemo(() => {
let cost = 0
let last: Message | null = null
for (const msg of messages) {
if (msg.role !== "assistant") continue
if (msg.cost) cost += msg.cost
if (msg.tokens && msg.tokens.output > 0) last = msg
}
// Last assistant message token breakdown (what the TUI shows)
const tokens = last?.tokens
const input = tokens?.input || 0
const output = tokens?.output || 0
const reasoning = tokens?.reasoning || 0
const cacheRead = tokens?.cache?.read || 0
const cacheWrite = tokens?.cache?.write || 0
const total = input + output + reasoning + cacheRead + cacheWrite
// Find context limit from provider catalog
let context = 0
if (last?.providerID && last?.modelID) {
const provider = providers.find((p) => p.id === last!.providerID)
const model = provider?.models.find((m) => m.id === last!.modelID)
context = model?.limit?.context || 0
}
const percent = context > 0 ? Math.round((total / context) * 100) : 0
return { cost, input, output, reasoning, cacheRead, cacheWrite, total, percent, context }
}, [messages, providers])
if (!visible) return null
const hasTokens = stats.total > 0
const hasCost = stats.cost > 0
const summary = session?.summary
const created = session?.time.created
const updated = session?.time.updated
return (
<View style={[s.container, isDark && s.containerDark]}>
{/* Top row: tokens + context % + cost — matches TUI header */}
<View style={s.row}>
<View style={s.costRow}>
<Ionicons name="stats-chart-outline" size={14} color={isDark ? "#888888" : "#666666"} />
{hasTokens && (
<Text style={[s.tokens, isDark && s.textDark]}>
{stats.total.toLocaleString()}
{stats.percent > 0 && <Text style={[s.percent, isDark && s.dimDark]}>{` ${stats.percent}%`}</Text>}
</Text>
)}
{hasCost && <Text style={[s.cost, isDark && s.dimDark]}>({formatCost(stats.cost)})</Text>}
{!hasTokens && !hasCost && <Text style={[s.cost, isDark && s.dimDark]}>No usage data yet</Text>}
</View>
<TouchableOpacity onPress={onClose} hitSlop={8}>
<Ionicons name="close" size={16} color={isDark ? "#666666" : "#999999"} />
</TouchableOpacity>
</View>
{/* Context bar */}
{stats.percent > 0 && (
<View style={[s.bar, isDark && s.barDark]}>
<View
style={[
s.barFill,
{ width: `${Math.min(stats.percent, 100)}%` },
stats.percent > 80 ? s.barWarn : stats.percent > 50 ? s.barMid : s.barOk,
]}
/>
</View>
)}
{/* Token breakdown pills */}
{hasTokens && (
<View style={s.breakdown}>
<TokenPill label="In" value={stats.input} color="#3b82f6" isDark={isDark} />
<TokenPill label="Out" value={stats.output} color="#10b981" isDark={isDark} />
{stats.reasoning > 0 && <TokenPill label="Think" value={stats.reasoning} color="#f59e0b" isDark={isDark} />}
{stats.cacheRead > 0 && <TokenPill label="Cache R" value={stats.cacheRead} color="#8b5cf6" isDark={isDark} />}
{stats.cacheWrite > 0 && (
<TokenPill label="Cache W" value={stats.cacheWrite} color="#ec4899" isDark={isDark} />
)}
</View>
)}
{/* Session metadata */}
<View style={s.meta}>
{created && <MetaItem icon="time-outline" label="Created" value={formatTime(created)} isDark={isDark} />}
{updated && updated !== created && (
<MetaItem icon="refresh-outline" label="Updated" value={formatTime(updated)} isDark={isDark} />
)}
<MetaItem
icon="chatbubbles-outline"
label="Messages"
value={String(messages.length) + (hasMore ? "+" : "")}
isDark={isDark}
/>
{summary && summary.files > 0 && (
<MetaItem
icon="code-outline"
label="Changes"
value={`${summary.files}f +${summary.additions} -${summary.deletions}`}
isDark={isDark}
/>
)}
{session?.share?.url && <MetaItem icon="share-outline" label="Shared" value="Yes" isDark={isDark} />}
</View>
{/* Navigation actions */}
<View style={s.actions}>
{hasMore && (
<TouchableOpacity style={[s.action, isDark && s.actionDark]} onPress={onLoadAll} disabled={loadingAll}>
{loadingAll ? (
<ActivityIndicator size="small" color={isDark ? "#888888" : "#666666"} />
) : (
<Ionicons name="download-outline" size={14} color={isDark ? "#888888" : "#666666"} />
)}
<Text style={[s.actionText, isDark && s.dimDark]}>{loadingAll ? "Loading..." : "Load all messages"}</Text>
</TouchableOpacity>
)}
{messages.length > 0 && (
<TouchableOpacity style={[s.action, isDark && s.actionDark]} onPress={onScrollToTop}>
<Ionicons name="arrow-up-outline" size={14} color={isDark ? "#888888" : "#666666"} />
<Text style={[s.actionText, isDark && s.dimDark]}>Jump to beginning</Text>
</TouchableOpacity>
)}
</View>
</View>
)
}
function MetaItem({ icon, label, value, isDark }: { icon: string; label: string; value: string; isDark: boolean }) {
return (
<View style={s.metaItem}>
<Ionicons name={icon as any} size={12} color={isDark ? "#555555" : "#999999"} />
<Text style={[s.metaLabel, isDark && s.dimDark]}>{label}</Text>
<Text style={[s.metaValue, isDark && s.metaValueDark]}>{value}</Text>
</View>
)
}
function TokenPill({ label, value, color, isDark }: { label: string; value: number; color: string; isDark: boolean }) {
return (
<View style={[s.pill, { borderColor: color + "40" }, isDark && { backgroundColor: color + "15" }]}>
<View style={[s.dot, { backgroundColor: color }]} />
<Text style={[s.pillLabel, isDark && s.dimDark]}>{label}</Text>
<Text style={[s.pillValue, isDark && s.textDark]}>{compact(value)}</Text>
</View>
)
}
const s = StyleSheet.create({
container: {
paddingHorizontal: 12,
paddingVertical: 8,
borderBottomWidth: 1,
borderBottomColor: "#e5e5e5",
backgroundColor: "#fafafa",
gap: 8,
},
containerDark: {
borderBottomColor: "#1a1a1a",
backgroundColor: "#111111",
},
row: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
},
costRow: {
flexDirection: "row",
alignItems: "center",
gap: 6,
},
tokens: {
fontSize: 14,
fontWeight: "700",
color: "#0a0a0a",
fontVariant: ["tabular-nums"],
},
percent: {
fontSize: 13,
fontWeight: "500",
},
cost: {
fontSize: 13,
color: "#999999",
fontVariant: ["tabular-nums"],
},
bar: {
height: 4,
borderRadius: 2,
backgroundColor: "#e5e5e5",
overflow: "hidden",
},
barDark: {
backgroundColor: "#2a2a2a",
},
barFill: {
height: "100%",
borderRadius: 2,
},
barOk: { backgroundColor: "#3b82f6" },
barMid: { backgroundColor: "#f59e0b" },
barWarn: { backgroundColor: "#ef4444" },
breakdown: {
flexDirection: "row",
flexWrap: "wrap",
gap: 6,
},
pill: {
flexDirection: "row",
alignItems: "center",
gap: 4,
borderWidth: 1,
borderRadius: 10,
paddingHorizontal: 8,
paddingVertical: 3,
backgroundColor: "#ffffff",
},
dot: {
width: 6,
height: 6,
borderRadius: 3,
},
pillLabel: {
fontSize: 11,
color: "#666666",
},
pillValue: {
fontSize: 11,
fontWeight: "600",
color: "#0a0a0a",
fontVariant: ["tabular-nums"],
},
meta: {
flexDirection: "row",
flexWrap: "wrap",
gap: 12,
},
metaItem: {
flexDirection: "row",
alignItems: "center",
gap: 4,
},
metaLabel: {
fontSize: 11,
color: "#999999",
},
metaValue: {
fontSize: 11,
fontWeight: "600",
color: "#666666",
},
metaValueDark: {
color: "#aaaaaa",
},
actions: {
flexDirection: "row",
gap: 8,
},
action: {
flexDirection: "row",
alignItems: "center",
gap: 5,
backgroundColor: "#ffffff",
borderWidth: 1,
borderColor: "#e5e5e5",
borderRadius: 8,
paddingHorizontal: 10,
paddingVertical: 6,
},
actionDark: {
backgroundColor: "#1a1a1a",
borderColor: "#2a2a2a",
},
actionText: {
fontSize: 12,
color: "#666666",
fontWeight: "500",
},
textDark: { color: "#e5e5e5" },
dimDark: { color: "#666666" },
})

View File

@@ -0,0 +1,88 @@
import { useMemo } from "react"
import { View, Text, TouchableOpacity, ScrollView, StyleSheet, Keyboard, Platform } from "react-native"
import { Ionicons } from "@expo/vector-icons"
export interface SlashCommand {
trigger: string
title: string
description?: string
icon: string
type: "builtin" | "custom"
}
interface Props {
query: string
commands: SlashCommand[]
isDark: boolean
onSelect: (cmd: SlashCommand) => void
}
export function SlashPopover({ query, commands, isDark, onSelect }: Props) {
const filtered = useMemo(() => {
const q = query.toLowerCase()
return commands.filter((c) => c.trigger.toLowerCase().startsWith(q) || c.title.toLowerCase().includes(q))
}, [query, commands])
if (filtered.length === 0) return null
return (
<View style={[s.popover, isDark && s.popoverDark]}>
<ScrollView keyboardShouldPersistTaps="always" style={s.scroll}>
{filtered.map((cmd) => (
<TouchableOpacity key={cmd.trigger} style={[s.item, isDark && s.itemDark]} onPress={() => onSelect(cmd)}>
<Ionicons
name={cmd.icon as any}
size={18}
color={cmd.type === "custom" ? "#8b5cf6" : isDark ? "#888888" : "#666666"}
/>
<View style={s.textCol}>
<Text style={[s.trigger, isDark && s.textWhite]}>/{cmd.trigger}</Text>
{cmd.description && (
<Text style={[s.desc, isDark && s.metaDark]} numberOfLines={1}>
{cmd.description}
</Text>
)}
</View>
{cmd.type === "custom" && (
<View style={[s.badge, isDark && s.badgeDark]}>
<Text style={s.badgeText}>cmd</Text>
</View>
)}
</TouchableOpacity>
))}
</ScrollView>
</View>
)
}
const s = StyleSheet.create({
popover: {
backgroundColor: "#ffffff",
borderTopWidth: 1,
borderTopColor: "#e5e5e5",
maxHeight: 220,
},
popoverDark: { backgroundColor: "#1a1a1a", borderTopColor: "#2a2a2a" },
scroll: { paddingVertical: 4 },
item: {
flexDirection: "row",
alignItems: "center",
gap: 10,
paddingHorizontal: 16,
paddingVertical: 10,
},
itemDark: {},
textCol: { flex: 1 },
trigger: { fontSize: 14, fontWeight: "600", color: "#0a0a0a" },
textWhite: { color: "#ffffff" },
desc: { fontSize: 12, color: "#999999", marginTop: 1 },
metaDark: { color: "#666666" },
badge: {
backgroundColor: "#f3e8ff",
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 4,
},
badgeDark: { backgroundColor: "#2a1a3e" },
badgeText: { fontSize: 10, color: "#8b5cf6", fontWeight: "600" },
})

View File

@@ -0,0 +1,46 @@
import { View, Text, StyleSheet, ActivityIndicator } from "react-native"
import { useEvents } from "../../stores/events"
import { useSessions } from "../../stores/sessions"
interface Props {
sessionID: string
isDark: boolean
}
export function StatusIndicator({ sessionID, isDark }: Props) {
const status = useEvents((s) => s.sessionStatus[sessionID])
const text = useEvents((s) => s.statusText[sessionID])
const optimistic = useSessions((s) => s.sending[sessionID])
// SSE status is the source of truth. The optimistic `sending` flag only
// covers the gap between the user tapping send and SSE confirming busy.
// Once SSE reports idle, the indicator hides regardless of the optimistic flag.
const sseBusy = status && status.type !== "idle"
const busy = sseBusy || (optimistic && !status)
if (!busy) return null
const label = status?.type === "retry" ? `Retrying (attempt ${status.attempt})...` : text || "Working..."
return (
<View style={[s.bar, isDark && s.barDark]}>
<ActivityIndicator size="small" color="#8b5cf6" />
<Text style={[s.text, isDark && s.textDark]}>{label}</Text>
</View>
)
}
const s = StyleSheet.create({
bar: {
flexDirection: "row",
alignItems: "center",
gap: 8,
paddingHorizontal: 16,
paddingVertical: 8,
backgroundColor: "#f5f3ff",
borderTopWidth: 1,
borderTopColor: "#e5e5e5",
},
barDark: { backgroundColor: "#1a1a2e", borderTopColor: "#2a2a2a" },
text: { fontSize: 13, color: "#6d28d9", fontWeight: "500" },
textDark: { color: "#a78bfa" },
})

View File

@@ -0,0 +1,454 @@
import { useState, useCallback } from "react"
import { View, Text, TouchableOpacity, StyleSheet, ActivityIndicator, ScrollView, Platform } from "react-native"
import { Ionicons } from "@expo/vector-icons"
import type { Part } from "../../lib/sdk"
import { DiffView } from "./DiffView"
const TOOL_ICONS: Record<string, string> = {
read: "glasses-outline",
list: "list-outline",
glob: "search-outline",
grep: "search-outline",
webfetch: "globe-outline",
edit: "code-slash-outline",
write: "create-outline",
apply_patch: "git-merge-outline",
bash: "terminal-outline",
task: "git-branch-outline",
todowrite: "checkbox-outline",
todoread: "checkbox-outline",
question: "chatbubble-ellipses-outline",
codesearch: "search-outline",
websearch: "globe-outline",
}
const mono = Platform.OS === "ios" ? "Menlo" : "monospace"
function statusColor(status: string): string {
if (status === "completed") return "#22c55e"
if (status === "error") return "#ef4444"
if (status === "running") return "#f59e0b"
return "#888888"
}
// --- Tool-specific detail renderers ---
function BashDetail({ input, output, isDark }: { input: unknown; output: unknown; isDark: boolean }) {
const cmd = typeof input === "object" && input !== null ? (input as Record<string, unknown>).command : undefined
const out = typeof output === "string" ? output : undefined
return (
<View style={s.detailSection}>
{typeof cmd === "string" && (
<View style={[s.codeBlock, isDark && s.codeBlockDark]}>
<Text style={[s.codePre, isDark && s.codePteDark]} selectable>
<Text style={s.codePrompt}>$ </Text>
{cmd}
</Text>
</View>
)}
{out !== undefined && out.length > 0 && (
<View style={[s.codeBlock, isDark && s.codeBlockDark, { marginTop: 6 }]}>
<Text style={[s.codePre, isDark && s.codePteDark]} selectable numberOfLines={80}>
{out}
</Text>
</View>
)}
</View>
)
}
function ReadDetail({ input, isDark }: { input: unknown; isDark: boolean }) {
const file = typeof input === "object" && input !== null ? (input as Record<string, unknown>).filePath : undefined
const offset = typeof input === "object" && input !== null ? (input as Record<string, unknown>).offset : undefined
const limit = typeof input === "object" && input !== null ? (input as Record<string, unknown>).limit : undefined
const range = offset || limit ? ` (${offset || 0}..${limit || "end"})` : ""
return (
<View style={s.detailSection}>
{typeof file === "string" && (
<Text style={[s.detailFile, isDark && s.detailFileDark]} selectable numberOfLines={2}>
{file}
{range}
</Text>
)}
</View>
)
}
function WriteDetail({ input, isDark }: { input: unknown; isDark: boolean }) {
const file = typeof input === "object" && input !== null ? (input as Record<string, unknown>).filePath : undefined
const content = typeof input === "object" && input !== null ? (input as Record<string, unknown>).content : undefined
return (
<View style={s.detailSection}>
{typeof file === "string" && (
<Text style={[s.detailFile, isDark && s.detailFileDark]} selectable numberOfLines={2}>
{file}
</Text>
)}
{typeof content === "string" && content.length > 0 && (
<View style={[s.codeBlock, isDark && s.codeBlockDark, { marginTop: 6 }]}>
<Text style={[s.codePre, isDark && s.codePteDark]} selectable numberOfLines={40}>
{content}
</Text>
</View>
)}
</View>
)
}
function EditDetail({ input, output, isDark }: { input: unknown; output: unknown; isDark: boolean }) {
const file = typeof input === "object" && input !== null ? (input as Record<string, unknown>).filePath : undefined
const old = typeof input === "object" && input !== null ? (input as Record<string, unknown>).oldString : undefined
const replacement =
typeof input === "object" && input !== null ? (input as Record<string, unknown>).newString : undefined
// If we have old/new strings, show as diff
if (typeof old === "string" && typeof replacement === "string") {
return (
<View style={s.detailSection}>
{typeof file === "string" && (
<Text style={[s.detailFile, isDark && s.detailFileDark]} selectable numberOfLines={2}>
{file}
</Text>
)}
<DiffView before={old} after={replacement} isDark={isDark} />
</View>
)
}
// Fallback: show raw output
const text = typeof output === "string" ? output : JSON.stringify(output, null, 2)
return (
<View style={s.detailSection}>
{typeof file === "string" && (
<Text style={[s.detailFile, isDark && s.detailFileDark]} selectable numberOfLines={2}>
{file}
</Text>
)}
{text && (
<View style={[s.codeBlock, isDark && s.codeBlockDark, { marginTop: 6 }]}>
<Text style={[s.codePre, isDark && s.codePteDark]} selectable numberOfLines={40}>
{text}
</Text>
</View>
)}
</View>
)
}
function PatchDetail({ input, isDark }: { input: unknown; isDark: boolean }) {
const patch = typeof input === "object" && input !== null ? (input as Record<string, unknown>).patch : undefined
return (
<View style={s.detailSection}>
{typeof patch === "string" && patch.length > 0 && (
<View style={[s.codeBlock, isDark && s.codeBlockDark]}>
<Text style={[s.codePre, isDark && s.codePteDark]} selectable numberOfLines={60}>
{patch}
</Text>
</View>
)}
</View>
)
}
function GlobGrepDetail({ input, output, isDark }: { input: unknown; output: unknown; isDark: boolean }) {
const pattern = typeof input === "object" && input !== null ? (input as Record<string, unknown>).pattern : undefined
const path = typeof input === "object" && input !== null ? (input as Record<string, unknown>).path : undefined
const results = typeof output === "string" ? output : undefined
return (
<View style={s.detailSection}>
{typeof pattern === "string" && (
<Text style={[s.detailMeta, isDark && s.detailMetaDark]}>
Pattern: {pattern}
{typeof path === "string" ? ` in ${path}` : ""}
</Text>
)}
{results && results.length > 0 && (
<View style={[s.codeBlock, isDark && s.codeBlockDark, { marginTop: 6 }]}>
<Text style={[s.codePre, isDark && s.codePteDark]} selectable numberOfLines={30}>
{results}
</Text>
</View>
)}
</View>
)
}
function WebfetchDetail({ input, isDark }: { input: unknown; isDark: boolean }) {
const url = typeof input === "object" && input !== null ? (input as Record<string, unknown>).url : undefined
return (
<View style={s.detailSection}>
{typeof url === "string" && (
<Text style={[s.detailFile, isDark && s.detailFileDark, { color: "#8b5cf6" }]} selectable numberOfLines={3}>
{url}
</Text>
)}
</View>
)
}
function TaskDetail({ input, isDark }: { input: unknown; isDark: boolean }) {
const description =
typeof input === "object" && input !== null ? (input as Record<string, unknown>).description : undefined
const prompt = typeof input === "object" && input !== null ? (input as Record<string, unknown>).prompt : undefined
return (
<View style={s.detailSection}>
{typeof description === "string" && <Text style={[s.detailMeta, isDark && s.detailMetaDark]}>{description}</Text>}
{typeof prompt === "string" && prompt.length > 0 && (
<View style={[s.codeBlock, isDark && s.codeBlockDark, { marginTop: 6 }]}>
<Text style={[s.codePre, isDark && s.codePteDark]} selectable numberOfLines={20}>
{prompt}
</Text>
</View>
)}
</View>
)
}
function TodoDetail({ input, isDark }: { input: unknown; isDark: boolean }) {
const todos = typeof input === "object" && input !== null ? (input as Record<string, unknown>).todos : undefined
if (!Array.isArray(todos)) return null
return (
<View style={s.detailSection}>
{todos.map((t, i) => {
const item = t as Record<string, unknown>
const done = item.status === "completed"
return (
<View key={String(item.id || i)} style={s.todoRow}>
<Ionicons
name={done ? "checkbox" : "square-outline"}
size={16}
color={done ? "#22c55e" : isDark ? "#666666" : "#999999"}
/>
<Text style={[s.todoText, isDark && s.todoTextDark, done && s.todoDone]} numberOfLines={2}>
{String(item.content || item.title || "")}
</Text>
</View>
)
})}
</View>
)
}
function GenericDetail({ input, output, isDark }: { input: unknown; output: unknown; isDark: boolean }) {
const text =
typeof output === "string"
? output
: output !== undefined && output !== null
? JSON.stringify(output, null, 2)
: typeof input === "object" && input !== null
? JSON.stringify(input, null, 2)
: undefined
if (!text || text.length === 0) return null
return (
<View style={s.detailSection}>
<View style={[s.codeBlock, isDark && s.codeBlockDark]}>
<Text style={[s.codePre, isDark && s.codePteDark]} selectable numberOfLines={30}>
{text}
</Text>
</View>
</View>
)
}
function ToolDetail({ tool, isDark }: { tool: Part; isDark: boolean }) {
const name = tool.tool || ""
const input = tool.state?.input
const output = tool.state?.output
switch (name) {
case "bash":
return <BashDetail input={input} output={output} isDark={isDark} />
case "read":
return <ReadDetail input={input} isDark={isDark} />
case "write":
return <WriteDetail input={input} isDark={isDark} />
case "edit":
return <EditDetail input={input} output={output} isDark={isDark} />
case "apply_patch":
return <PatchDetail input={input} isDark={isDark} />
case "glob":
case "grep":
case "list":
case "codesearch":
return <GlobGrepDetail input={input} output={output} isDark={isDark} />
case "webfetch":
case "websearch":
return <WebfetchDetail input={input} isDark={isDark} />
case "task":
return <TaskDetail input={input} isDark={isDark} />
case "todowrite":
return <TodoDetail input={input} isDark={isDark} />
default:
return <GenericDetail input={input} output={output} isDark={isDark} />
}
}
// --- Error display ---
function ErrorBanner({ message, isDark }: { message: string; isDark: boolean }) {
return (
<View style={[s.errorBanner, isDark && s.errorBannerDark]}>
<Ionicons name="alert-circle" size={14} color="#ef4444" />
<Text style={s.errorText} numberOfLines={3} selectable>
{message}
</Text>
</View>
)
}
// --- Duration display ---
function duration(start?: number, end?: number): string | null {
if (!start || !end) return null
const ms = end - start
if (ms < 1000) return `${ms}ms`
return `${(ms / 1000).toFixed(1)}s`
}
// --- Main component ---
interface Props {
tool: Part
isDark: boolean
}
export function ToolCallCard({ tool, isDark }: Props) {
const [expanded, setExpanded] = useState(false)
const icon = (tool.tool && TOOL_ICONS[tool.tool]) || "extension-puzzle-outline"
const status = tool.state?.status || "pending"
const color = statusColor(status)
const error = tool.state?.error?.message
const elapsed = duration(tool.state?.time?.start, tool.state?.time?.end)
const hasDetail = tool.state?.input !== undefined || tool.state?.output !== undefined || error
const toggle = useCallback(() => {
if (hasDetail) setExpanded((v) => !v)
}, [hasDetail])
return (
<TouchableOpacity
style={[
s.card,
isDark && s.cardDark,
status === "error" && s.cardError,
status === "error" && isDark && s.cardErrorDark,
]}
onPress={toggle}
activeOpacity={hasDetail ? 0.7 : 1}
>
{/* Header row */}
<View style={s.header}>
<View style={s.headerLeft}>
<Ionicons name={icon as any} size={16} color={color} />
<Text style={[s.name, isDark && s.nameDark]} numberOfLines={1}>
{tool.state?.title || tool.tool || "Tool"}
</Text>
{elapsed && <Text style={[s.elapsed, isDark && s.elapsedDark]}>{elapsed}</Text>}
</View>
<View style={s.headerRight}>
{status === "running" && <ActivityIndicator size="small" color={color} />}
{status === "completed" && <Ionicons name="checkmark-circle" size={16} color="#22c55e" />}
{status === "error" && <Ionicons name="close-circle" size={16} color="#ef4444" />}
{hasDetail && (
<Ionicons
name={expanded ? "chevron-up" : "chevron-down"}
size={16}
color={isDark ? "#666666" : "#999999"}
/>
)}
</View>
</View>
{/* Error banner */}
{error && !expanded && <ErrorBanner message={error} isDark={isDark} />}
{/* Expanded detail */}
{expanded && (
<ScrollView style={s.detailScroll} nestedScrollEnabled showsVerticalScrollIndicator={false}>
{error && <ErrorBanner message={error} isDark={isDark} />}
<ToolDetail tool={tool} isDark={isDark} />
</ScrollView>
)}
</TouchableOpacity>
)
}
const s = StyleSheet.create({
card: {
backgroundColor: "#ffffff",
padding: 10,
borderRadius: 8,
marginTop: 8,
borderWidth: 1,
borderColor: "#f0f0f0",
},
cardDark: { backgroundColor: "#2a2a2a", borderColor: "#3a3a3a" },
cardError: { borderColor: "#fecaca" },
cardErrorDark: { borderColor: "#7f1d1d" },
header: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
},
headerLeft: { flexDirection: "row", alignItems: "center", gap: 8, flex: 1 },
headerRight: { flexDirection: "row", alignItems: "center", gap: 6 },
name: { fontSize: 13, fontWeight: "500", color: "#0a0a0a", flex: 1 },
nameDark: { color: "#e5e5e5" },
elapsed: { fontSize: 11, color: "#999999" },
elapsedDark: { color: "#666666" },
// Error
errorBanner: {
flexDirection: "row",
alignItems: "flex-start",
gap: 6,
marginTop: 8,
padding: 8,
backgroundColor: "#fef2f2",
borderRadius: 6,
},
errorBannerDark: { backgroundColor: "#1a0a0a" },
errorText: { fontSize: 12, color: "#dc2626", flex: 1, lineHeight: 18 },
// Detail
detailScroll: { maxHeight: 300, marginTop: 8 },
detailSection: { gap: 4 },
detailFile: {
fontSize: 12,
fontFamily: mono,
color: "#6d28d9",
backgroundColor: "#f5f3ff",
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 4,
overflow: "hidden",
},
detailFileDark: { color: "#a78bfa", backgroundColor: "#1a1a2e" },
detailMeta: { fontSize: 12, color: "#666666", lineHeight: 18 },
detailMetaDark: { color: "#888888" },
// Code block
codeBlock: {
backgroundColor: "#f8f8f8",
borderRadius: 6,
padding: 10,
},
codeBlockDark: { backgroundColor: "#1a1a1a" },
codePre: {
fontSize: 12,
fontFamily: mono,
color: "#0a0a0a",
lineHeight: 18,
},
codePteDark: { color: "#e5e5e5" },
codePrompt: { color: "#8b5cf6", fontWeight: "700" },
// Todo
todoRow: {
flexDirection: "row",
alignItems: "flex-start",
gap: 8,
paddingVertical: 3,
},
todoText: { fontSize: 13, color: "#0a0a0a", flex: 1, lineHeight: 20 },
todoTextDark: { color: "#e5e5e5" },
todoDone: { textDecorationLine: "line-through", color: "#999999" },
})

View File

@@ -0,0 +1,12 @@
export { MessageBubble } from "./MessageBubble"
export { ToolCallCard } from "./ToolCallCard"
export { DiffView } from "./DiffView"
export { ReasoningBlock } from "./ReasoningBlock"
export { PermissionPrompt } from "./PermissionPrompt"
export { QuestionPrompt } from "./QuestionPrompt"
export { StatusIndicator } from "./StatusIndicator"
export { SlashPopover, type SlashCommand } from "./SlashPopover"
export { ModelPicker } from "./ModelPicker"
export { ImageAttachments, type Attachment } from "./ImageAttachments"
export { DirectorySwitcher } from "./DirectorySwitcher"
export { SessionInfo } from "./SessionInfo"

View File

@@ -0,0 +1,85 @@
import { useState } from "react"
import { View, Text, TouchableOpacity, StyleSheet, useColorScheme, Platform } from "react-native"
import * as Clipboard from "expo-clipboard"
interface Props {
code: string
language?: string
}
export function CodeBlock({ code, language }: Props) {
const isDark = useColorScheme() === "dark"
const [copied, setCopied] = useState(false)
const copy = async () => {
try {
await Clipboard.setStringAsync(code)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch {}
}
return (
<View style={[styles.container, isDark && styles.containerDark]}>
<View style={[styles.header, isDark && styles.headerDark]}>
<Text style={[styles.language, isDark && styles.languageDark]}>{language || "code"}</Text>
<TouchableOpacity onPress={copy} hitSlop={8}>
<Text style={[styles.copyBtn, isDark && styles.copyBtnDark]}>{copied ? "Copied!" : "Copy"}</Text>
</TouchableOpacity>
</View>
<Text style={[styles.code, isDark && styles.codeDark]} selectable>
{code}
</Text>
</View>
)
}
const styles = StyleSheet.create({
container: {
backgroundColor: "#f5f5f5",
borderRadius: 8,
marginVertical: 8,
overflow: "hidden",
},
containerDark: {
backgroundColor: "#1a1a1a",
},
header: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingHorizontal: 12,
paddingVertical: 6,
backgroundColor: "#e8e8e8",
},
headerDark: {
backgroundColor: "#2a2a2a",
},
language: {
fontSize: 11,
fontWeight: "600",
color: "#666666",
textTransform: "uppercase",
},
languageDark: {
color: "#888888",
},
copyBtn: {
fontSize: 11,
color: "#8b5cf6",
fontWeight: "600",
},
copyBtnDark: {
color: "#a78bfa",
},
code: {
fontFamily: Platform.OS === "ios" ? "Menlo" : "monospace",
fontSize: 13,
lineHeight: 20,
color: "#1a1a1a",
padding: 12,
},
codeDark: {
color: "#e5e5e5",
},
})

View File

@@ -0,0 +1,117 @@
import type { ReactNode } from "react"
import { View, Text, useColorScheme, Platform, type ViewStyle, type TextStyle } from "react-native"
import RNMarkdown, { Renderer } from "react-native-marked"
import { CodeBlock } from "./CodeBlock"
class CustomRenderer extends Renderer {
code(text: string, language?: string, containerStyle?: ViewStyle, _textStyle?: TextStyle) {
return (
<View key={this.getKey()} style={containerStyle}>
<CodeBlock code={text} language={language} />
</View>
)
}
codespan(text: string, styles?: TextStyle): ReactNode {
return (
<Text selectable key={this.getKey()} style={[styles, { fontStyle: "normal", fontWeight: "normal" }]}>
{text}
</Text>
)
}
}
const renderer = new CustomRenderer()
const mono = Platform.OS === "ios" ? "Menlo" : "monospace"
const lightTheme = {
text: { color: "#0a0a0a", fontSize: 15, lineHeight: 22 },
paragraph: { marginTop: 0, marginBottom: 8 },
heading1: { fontSize: 22, fontWeight: "700" as const, color: "#0a0a0a", marginBottom: 8, marginTop: 12 },
heading2: { fontSize: 19, fontWeight: "600" as const, color: "#0a0a0a", marginBottom: 6, marginTop: 10 },
heading3: { fontSize: 16, fontWeight: "600" as const, color: "#0a0a0a", marginBottom: 4, marginTop: 8 },
link: { color: "#8b5cf6" },
blockquote: {
backgroundColor: "transparent",
borderLeftWidth: 3,
borderLeftColor: "#d1d5db",
paddingLeft: 12,
paddingVertical: 2,
marginVertical: 4,
},
code: {
backgroundColor: "#e8e5f0",
color: "#6d28d9",
fontFamily: mono,
fontSize: 13,
paddingHorizontal: 5,
paddingVertical: 2,
borderRadius: 4,
},
codespan: {
backgroundColor: "#e8e5f0",
color: "#6d28d9",
fontFamily: mono,
fontSize: 13,
paddingHorizontal: 4,
paddingVertical: 1,
borderRadius: 3,
},
list: { marginBottom: 4 },
listItem: { marginBottom: 2 },
hr: { backgroundColor: "#e5e5e5", height: 1, marginVertical: 12 },
strong: { fontWeight: "700" as const },
em: { fontStyle: "italic" as const },
strikethrough: { textDecorationLine: "line-through" as const },
image: { borderRadius: 8 },
}
const darkTheme = {
...lightTheme,
text: { ...lightTheme.text, color: "#e5e5e5" },
heading1: { ...lightTheme.heading1, color: "#ffffff" },
heading2: { ...lightTheme.heading2, color: "#ffffff" },
heading3: { ...lightTheme.heading3, color: "#ffffff" },
link: { color: "#a78bfa" },
blockquote: {
...lightTheme.blockquote,
borderLeftColor: "#4a4a5a",
},
code: {
...lightTheme.code,
backgroundColor: "#2a2040",
color: "#c4b5fd",
},
codespan: {
...lightTheme.codespan,
backgroundColor: "#2a2040",
color: "#c4b5fd",
},
hr: { ...lightTheme.hr, backgroundColor: "#2a2a2a" },
}
interface Props {
children: string
}
export function Markdown({ children }: Props) {
const isDark = useColorScheme() === "dark"
const theme = isDark ? darkTheme : lightTheme
if (!children?.trim()) return null
return (
<RNMarkdown
value={children}
renderer={renderer}
styles={theme}
flatListProps={{
scrollEnabled: false,
initialNumToRender: 50,
style: { backgroundColor: "transparent" },
contentContainerStyle: { backgroundColor: "transparent" },
}}
/>
)
}

View File

@@ -0,0 +1,2 @@
export { Markdown } from "./Markdown"
export { CodeBlock } from "./CodeBlock"

174
src/lib/notifications.ts Normal file
View File

@@ -0,0 +1,174 @@
import * as Notifications from "expo-notifications"
import * as Device from "expo-device"
import { Platform, AppState } from "react-native"
// ---------------------------------------------------------------------------
// Categories — every notification belongs to exactly one
// ---------------------------------------------------------------------------
export const categories = ["permissions", "questions", "completed", "errors", "connection"] as const
export type Category = (typeof categories)[number]
// Per-category metadata used by both the notification module and the settings UI
export const categoryMeta: Record<Category, { label: string; description: string; icon: string }> = {
permissions: {
label: "Permission Requests",
description: "When a tool needs approval to proceed",
icon: "shield-checkmark",
},
questions: {
label: "Questions",
description: "When the assistant needs your input",
icon: "help-circle",
},
completed: {
label: "Task Completed",
description: "When a session finishes processing",
icon: "checkmark-circle",
},
errors: {
label: "Errors",
description: "When a session encounters a problem",
icon: "alert-circle",
},
connection: {
label: "Connection",
description: "When the app loses connection for an extended period",
icon: "wifi",
},
}
// Default state — permissions and questions on (they block work), the rest off
export const defaultPreferences: Record<Category, boolean> = {
permissions: true,
questions: true,
completed: false,
errors: true,
connection: true,
}
// ---------------------------------------------------------------------------
// Notification payload — what callers pass in
// ---------------------------------------------------------------------------
export interface Payload {
category: Category
title: string
body: string
sessionId: string
dedupeKey?: string
dedupeCooldownMs?: number
}
// Data embedded in the notification for tap handling
interface NotificationData {
category: Category
sessionId: string
}
// ---------------------------------------------------------------------------
// Preferences accessor — injected to avoid circular imports with the store
// ---------------------------------------------------------------------------
type PreferencesAccessor = () => Record<Category, boolean>
let preferences: PreferencesAccessor = () => defaultPreferences
const dedupeSentAt = new Map<string, number>()
export function configure(accessor: PreferencesAccessor) {
preferences = accessor
}
// ---------------------------------------------------------------------------
// Setup & permissions
// ---------------------------------------------------------------------------
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: false,
shouldShowBanner: true,
shouldShowList: true,
}),
})
export async function setup(): Promise<boolean> {
if (!Device.isDevice) return false
const { status: existing } = await Notifications.getPermissionsAsync()
if (existing === "granted") {
await ensureChannel()
return true
}
const { status } = await Notifications.requestPermissionsAsync()
if (status !== "granted") return false
await ensureChannel()
return true
}
export async function granted(): Promise<boolean> {
const { status } = await Notifications.getPermissionsAsync()
return status === "granted"
}
async function ensureChannel() {
if (Platform.OS !== "android") return
await Notifications.setNotificationChannelAsync("prompts", {
name: "Session Prompts & Updates",
importance: Notifications.AndroidImportance.HIGH,
vibrationPattern: [0, 250, 250, 250],
sound: "default",
})
}
// ---------------------------------------------------------------------------
// Send — single entry point, decides whether to fire
// ---------------------------------------------------------------------------
export async function send(payload: Payload) {
const prefs = preferences()
if (!prefs[payload.category]) return
if (AppState.currentState === "active") return
if (!(await granted())) return
if (payload.dedupeKey) {
const now = Date.now()
const lastSent = dedupeSentAt.get(payload.dedupeKey) || 0
const cooldown = payload.dedupeCooldownMs ?? 30_000
if (now - lastSent < cooldown) return
dedupeSentAt.set(payload.dedupeKey, now)
}
await Notifications.scheduleNotificationAsync({
content: {
title: payload.title,
body: payload.body,
data: {
category: payload.category,
sessionId: payload.sessionId,
} satisfies NotificationData as Record<string, unknown>,
sound: "default",
...(Platform.OS === "android" ? { channelId: "prompts" } : {}),
},
trigger: null,
})
}
// ---------------------------------------------------------------------------
// Tap handler — returns cleanup function
// ---------------------------------------------------------------------------
export function onTap(handler: (data: NotificationData) => void) {
const subscription = Notifications.addNotificationResponseReceivedListener((response) => {
const raw = response.notification.request.content.data
const data = raw as unknown as NotificationData | undefined
if (!data) return
if (data.sessionId || data.category === "connection") {
handler(data)
}
})
return () => subscription.remove()
}

434
src/lib/sdk.ts Normal file
View File

@@ -0,0 +1,434 @@
// SDK client wrapper for React Native
// We create our own lightweight client that mirrors the opencode SDK patterns
// but works in React Native environment
// expo/fetch provides WinterCG-compliant fetch with ReadableStream support for SSE
import { fetch as expoFetch } from "expo/fetch"
export interface ClientConfig {
baseUrl: string
directory?: string
auth?: {
username: string
password: string
}
}
export interface Session {
id: string
slug: string
projectID: string
directory: string
parentID?: string
title: string
version: string
share?: { url: string }
time: {
created: number
updated: number
compacting?: number
archived?: number
}
summary?: {
additions: number
deletions: number
files: number
}
}
export interface Message {
id: string
sessionID: string
role: "user" | "assistant"
parentID?: string
time: {
created: number
completed?: number
}
// User message fields
agent?: string
model?: { providerID: string; modelID: string }
// Assistant message fields
modelID?: string
providerID?: string
cost?: number
tokens?: {
input: number
output: number
reasoning?: number
cache?: { read: number; write: number }
}
error?: { message: string }
finish?: string
}
// API returns messages with parts embedded
export interface MessageWithParts {
info: Message
parts: Part[]
}
export interface Part {
id: string
sessionID?: string
messageID: string
type:
| "text"
| "reasoning"
| "tool"
| "file"
| "snapshot"
| "patch"
| "step-start"
| "step-finish"
| "subtask"
| "retry"
| "compaction"
| "agent"
// Text / reasoning part
text?: string
// Tool part
tool?: string
callID?: string
state?: {
status: "pending" | "running" | "completed" | "error"
input?: unknown
output?: unknown
title?: string
error?: { message: string }
time?: { start?: number; end?: number }
}
// Timing
time?: { start?: number; end?: number }
// File part
mime?: string
url?: string
filename?: string
}
export interface Agent {
name: string
description?: string
mode: "subagent" | "primary" | "all"
native?: boolean
hidden?: boolean
topP?: number
temperature?: number
color?: string
model?: { modelID: string; providerID: string }
prompt?: string
options: Record<string, unknown>
steps?: number
}
export interface Command {
name: string
description?: string
agent?: string
model?: string
mcp?: boolean
template: string
subtask?: boolean
hints: string[]
}
export interface Project {
id: string
name?: string
path: {
cwd: string
root: string
absolute: string
}
}
export interface Event {
type: string
properties: Record<string, unknown>
}
export interface HealthResponse {
healthy: boolean
version: string
}
const REQUEST_TIMEOUT_MS = 30_000
function createHeaders(config: ClientConfig): HeadersInit {
const headers: HeadersInit = {
"Content-Type": "application/json",
}
if (config.directory) {
const encoded = /[^\x00-\x7F]/.test(config.directory) ? encodeURIComponent(config.directory) : config.directory
headers["x-opencode-directory"] = encoded
}
if (config.auth) {
const credentials = btoa(`${config.auth.username}:${config.auth.password}`)
headers["Authorization"] = `Basic ${credentials}`
}
return headers
}
async function request<T>(config: ClientConfig, path: string, options: RequestInit = {}): Promise<T> {
const url = `${config.baseUrl}${path}`
const headers = { ...createHeaders(config), ...options.headers }
const response = await fetchWithTimeout(url, {
...options,
headers,
})
if (!response.ok) {
const error = await response.text()
throw new Error(`API Error: ${response.status} - ${error}`)
}
return response.json()
}
async function fetchWithTimeout(url: string, options: RequestInit = {}): Promise<Response> {
const parentSignal = options.signal
if (parentSignal?.aborted) throw new Error("Request aborted")
const controller = new AbortController()
let timedOut = false
const timeout = setTimeout(() => {
timedOut = true
controller.abort()
}, REQUEST_TIMEOUT_MS)
const onParentAbort = () => controller.abort()
parentSignal?.addEventListener("abort", onParentAbort)
try {
return await fetch(url, { ...options, signal: controller.signal })
} catch (error) {
if (timedOut) {
throw new Error(`Request timed out after ${REQUEST_TIMEOUT_MS}ms`)
}
throw error
} finally {
clearTimeout(timeout)
parentSignal?.removeEventListener("abort", onParentAbort)
}
}
export function createClient(config: ClientConfig) {
return {
global: {
health: () => request<HealthResponse>(config, "/global/health"),
// SSE event stream - returns async iterator
// Pass an AbortSignal to cancel the connection
async *events(signal?: AbortSignal): AsyncGenerator<Event> {
const url = `${config.baseUrl}/global/event`
const headers = createHeaders(config)
// Remove Content-Type for SSE (it's text/event-stream)
delete (headers as Record<string, string>)["Content-Type"]
// Must use expo/fetch for ReadableStream support on native
const response = await expoFetch(url, { headers, signal })
if (!response.ok || !response.body) {
throw new Error(`Failed to connect to event stream: ${response.status}`)
}
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ""
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split("\n")
buffer = lines.pop() || ""
for (const line of lines) {
if (line.startsWith("data: ")) {
const data = line.slice(6)
if (data && data !== "[DONE]") {
try {
yield JSON.parse(data)
} catch (err) {
console.warn("[SSE] Failed to parse event:", data.slice(0, 200), err)
}
}
}
}
}
} finally {
reader.releaseLock()
}
},
},
project: {
list: () => request<Project[]>(config, "/project"),
current: () => request<Project>(config, "/project/current"),
},
path: {
get: () =>
request<{ home: string; state: string; config: string; worktree: string; directory: string }>(config, "/path"),
},
session: {
list: (params?: { roots?: boolean; limit?: number; search?: string }) => {
const query = new URLSearchParams()
if (params?.roots) query.set("roots", "true")
if (params?.limit) query.set("limit", String(params.limit))
if (params?.search) query.set("search", params.search)
const qs = query.toString()
return request<Session[]>(config, `/session${qs ? `?${qs}` : ""}`)
},
get: (sessionID: string) => request<Session>(config, `/session/${sessionID}`),
create: (params?: { title?: string }) =>
request<Session>(config, "/session", {
method: "POST",
body: JSON.stringify(params || {}),
}),
delete: (sessionID: string) => request<void>(config, `/session/${sessionID}`, { method: "DELETE" }),
update: (sessionID: string, params: { title?: string; time?: { archived?: number } }) =>
request<Session>(config, `/session/${sessionID}`, {
method: "PATCH",
body: JSON.stringify(params),
}),
messages: (sessionID: string, params?: { limit?: number }) => {
const query = new URLSearchParams()
if (params?.limit) query.set("limit", String(params.limit))
const qs = query.toString()
return request<MessageWithParts[]>(config, `/session/${sessionID}/message${qs ? `?${qs}` : ""}`)
},
// Sends a message and returns the response
// Fire-and-forget async prompt - SSE events drive all real-time updates
prompt: async (
sessionID: string,
params: {
parts: Array<{ type: "text"; text: string } | { type: "file"; mime: string; url: string; filename?: string }>
model?: { providerID: string; modelID: string }
agent?: string
variant?: string
},
): Promise<void> => {
const url = `${config.baseUrl}/session/${sessionID}/prompt_async`
const headers = createHeaders(config)
const body = JSON.stringify(params)
const response = await fetchWithTimeout(url, {
method: "POST",
headers,
body,
})
if (!response.ok) {
const error = await response.text()
throw new Error(`Failed to send message: ${response.status} - ${error}`)
}
},
command: async (
sessionID: string,
params: {
command: string
arguments: string
agent?: string
model?: string
variant?: string
parts?: Array<{ type: "file"; mime: string; url: string; filename?: string }>
},
): Promise<void> => {
const url = `${config.baseUrl}/session/${sessionID}/command`
const headers = createHeaders(config)
const response = await fetchWithTimeout(url, {
method: "POST",
headers,
body: JSON.stringify({ ...params, sessionID }),
})
if (!response.ok) {
const error = await response.text()
throw new Error(`Failed to run command: ${response.status} - ${error}`)
}
},
abort: (sessionID: string) => request<boolean>(config, `/session/${sessionID}/abort`, { method: "POST" }),
diff: (sessionID: string, messageID?: string) => {
const qs = messageID ? `?messageID=${messageID}` : ""
return request<unknown[]>(config, `/session/${sessionID}/diff${qs}`)
},
},
permission: {
list: () =>
request<Array<{ id: string; sessionID: string; tool: string; input: unknown }>>(config, "/permission"),
reply: (requestID: string, reply: "once" | "always" | "reject") =>
request<boolean>(config, `/permission/${requestID}/reply`, {
method: "POST",
body: JSON.stringify({ reply }),
}),
},
question: {
list: () => request<Array<{ id: string; sessionID: string; questions: unknown[] }>>(config, "/question"),
reply: (requestID: string, answers: string[][]) =>
request<boolean>(config, `/question/${requestID}/reply`, {
method: "POST",
body: JSON.stringify({ answers }),
}),
reject: (requestID: string) =>
request<boolean>(config, `/question/${requestID}/reject`, {
method: "POST",
}),
},
agent: {
list: () => request<Agent[]>(config, "/agent"),
},
command: {
list: () => request<Command[]>(config, "/command"),
},
provider: {
list: () =>
request<{
all: Array<{
id: string
name: string
models: Record<
string,
{
id: string
name: string
attachment: boolean
reasoning: boolean
tool_call: boolean
cost?: { input: number; output: number }
limit: { context: number; output: number }
status?: "alpha" | "beta" | "deprecated" | "active"
}
>
}>
default: Record<string, string>
connected: string[]
}>(config, "/provider"),
},
config: {
get: () => request<unknown>(config, "/config"),
},
}
}
export type Client = ReturnType<typeof createClient>

80
src/lib/speech.ts Normal file
View File

@@ -0,0 +1,80 @@
import { useState, useCallback, useRef } from "react"
import { ExpoSpeechRecognitionModule, useSpeechRecognitionEvent } from "expo-speech-recognition"
interface SpeechState {
listening: boolean
transcript: string
error: string | null
}
interface SpeechActions {
start: () => Promise<void>
stop: () => void
cancel: () => void
}
export function useSpeech(onResult: (text: string) => void): SpeechState & SpeechActions {
const [listening, setListening] = useState(false)
const [transcript, setTranscript] = useState("")
const [error, setError] = useState<string | null>(null)
const pending = useRef("")
useSpeechRecognitionEvent("start", () => {
setListening(true)
setError(null)
setTranscript("")
pending.current = ""
})
useSpeechRecognitionEvent("end", () => {
setListening(false)
// Deliver final transcript
if (pending.current.trim()) {
onResult(pending.current.trim())
}
setTranscript("")
pending.current = ""
})
useSpeechRecognitionEvent("result", (event) => {
const text = event.results[0]?.transcript || ""
pending.current = text
setTranscript(text)
})
useSpeechRecognitionEvent("error", (event) => {
// "no-speech" is not really an error — user just didn't say anything
if (event.error === "no-speech") {
setListening(false)
return
}
setError(event.message || event.error)
setListening(false)
})
const start = useCallback(async () => {
const result = await ExpoSpeechRecognitionModule.requestPermissionsAsync()
if (!result.granted) {
setError("Microphone permission denied")
return
}
ExpoSpeechRecognitionModule.start({
lang: "en-US",
interimResults: true,
continuous: true,
})
}, [])
const stop = useCallback(() => {
ExpoSpeechRecognitionModule.stop()
}, [])
const cancel = useCallback(() => {
pending.current = ""
ExpoSpeechRecognitionModule.abort()
setListening(false)
setTranscript("")
}, [])
return { listening, transcript, error, start, stop, cancel }
}

32
src/lib/types.ts Normal file
View File

@@ -0,0 +1,32 @@
// Connection types for multiple server support
export type ConnectionType = "local" | "tunnel" | "cloud"
export interface ServerConnection {
id: string
name: string
type: ConnectionType
url: string
// For auth
username?: string
// Password stored separately in SecureStore
// Directory to use for this connection
directory?: string
// When last successfully connected
lastConnected?: number
// Is this the active connection?
active?: boolean
}
export interface AppSettings {
// Require biometric auth to open app
requireBiometric: boolean
// Require biometric to send messages
requireBiometricForMessages: boolean
// Theme preference
theme: "light" | "dark" | "system"
// Show notifications for task completion
notifications: boolean
}
// Re-export SDK types we'll use frequently
export type { Session, Message, Part, Project, Event, HealthResponse } from "./sdk"

135
src/stores/auth.ts Normal file
View File

@@ -0,0 +1,135 @@
import { create } from "zustand"
import * as LocalAuthentication from "expo-local-authentication"
import * as SecureStore from "expo-secure-store"
const AUTH_SETTINGS_KEY = "opencode_auth_settings"
interface AuthSettings {
requireBiometric: boolean
requireBiometricForMessages: boolean
}
interface AuthState {
isAuthenticated: boolean
isLoading: boolean
hasBiometrics: boolean
biometricType: LocalAuthentication.AuthenticationType | null
settings: AuthSettings
error: string | null
// Actions
initialize: () => Promise<void>
authenticate: () => Promise<boolean>
authenticateForMessage: () => Promise<boolean>
updateSettings: (settings: Partial<AuthSettings>) => Promise<void>
lock: () => void
}
const DEFAULT_SETTINGS: AuthSettings = {
requireBiometric: false,
requireBiometricForMessages: false,
}
export const useAuth = create<AuthState>((set, get) => ({
isAuthenticated: false,
isLoading: true,
hasBiometrics: false,
biometricType: null,
settings: DEFAULT_SETTINGS,
error: null,
initialize: async () => {
try {
set({ isLoading: true, error: null })
// Check biometric availability
const hasHardware = await LocalAuthentication.hasHardwareAsync()
const isEnrolled = await LocalAuthentication.isEnrolledAsync()
const hasBiometrics = hasHardware && isEnrolled
let biometricType: LocalAuthentication.AuthenticationType | null = null
if (hasBiometrics) {
const types = await LocalAuthentication.supportedAuthenticationTypesAsync()
biometricType = types[0] || null
}
// Load settings
const stored = await SecureStore.getItemAsync(AUTH_SETTINGS_KEY)
const settings: AuthSettings = stored ? JSON.parse(stored) : DEFAULT_SETTINGS
// If biometric is not required, auto-authenticate
const isAuthenticated = !settings.requireBiometric
set({
hasBiometrics,
biometricType,
settings,
isAuthenticated,
isLoading: false,
})
} catch (error) {
set({
error: "Failed to initialize authentication",
isLoading: false,
isAuthenticated: true, // Fail open for usability
})
}
},
authenticate: async () => {
const { settings, hasBiometrics } = get()
if (!settings.requireBiometric || !hasBiometrics) {
set({ isAuthenticated: true })
return true
}
try {
const result = await LocalAuthentication.authenticateAsync({
promptMessage: "Authenticate to access OpenCode",
fallbackLabel: "Use passcode",
disableDeviceFallback: false,
})
if (result.success) {
set({ isAuthenticated: true, error: null })
return true
}
set({ error: result.error || "Authentication failed" })
return false
} catch (error) {
set({ error: "Authentication error" })
return false
}
},
authenticateForMessage: async () => {
const { settings, hasBiometrics, isAuthenticated } = get()
if (!isAuthenticated) return false
if (!settings.requireBiometricForMessages || !hasBiometrics) return true
try {
const result = await LocalAuthentication.authenticateAsync({
promptMessage: "Authenticate to send message",
fallbackLabel: "Use passcode",
disableDeviceFallback: false,
})
return result.success
} catch {
return false
}
},
updateSettings: async (updates) => {
const settings = { ...get().settings, ...updates }
await SecureStore.setItemAsync(AUTH_SETTINGS_KEY, JSON.stringify(settings))
set({ settings })
},
lock: () => {
set({ isAuthenticated: false })
},
}))

139
src/stores/catalog.ts Normal file
View File

@@ -0,0 +1,139 @@
import { create } from "zustand"
import { useConnections } from "./connections"
import type { Agent, Command } from "../lib/sdk"
export interface ProviderModel {
id: string
name: string
reasoning: boolean
attachment: boolean
limit?: { context: number; output: number }
}
export interface Provider {
id: string
name: string
connected: boolean
models: ProviderModel[]
}
interface ModelSelection {
providerID: string
modelID: string
}
interface CatalogState {
agents: Agent[]
commands: Command[]
providers: Provider[]
defaults: Record<string, string>
// Current selections
agent: string // agent name, e.g. "build"
model: ModelSelection | null
loaded: boolean
// Actions
load: () => Promise<void>
setAgent: (name: string) => void
setModel: (selection: ModelSelection | null) => void
cycleAgent: (direction?: 1 | -1) => void
}
export const useCatalog = create<CatalogState>((set, get) => ({
agents: [],
commands: [],
providers: [],
defaults: {},
agent: "",
model: null,
loaded: false,
load: async () => {
const client = useConnections.getState().client
if (!client) return
const [agentResult, commandResult, providerResult] = await Promise.all([
client.agent.list().catch(() => [] as Agent[]),
client.command.list().catch(() => [] as Command[]),
client.provider.list().catch(() => null),
])
const agents = Array.isArray(agentResult) ? agentResult : []
const commands = Array.isArray(commandResult) ? commandResult : []
// Parse provider response: { all: [...], default: {...}, connected: [...] }
const raw = providerResult
const connected = new Set(Array.isArray(raw?.connected) ? raw.connected : [])
const defaults = raw?.default || {}
const providers: Provider[] = Array.isArray(raw?.all)
? raw.all
.filter((p) => connected.has(p.id))
.map((p) => ({
id: p.id,
name: p.name || p.id,
connected: connected.has(p.id),
models: Object.values(p.models || {})
.filter((m) => m.status !== "deprecated")
.map((m) => ({
id: m.id,
name: m.name || m.id,
reasoning: m.reasoning ?? false,
attachment: m.attachment ?? false,
limit: m.limit,
})),
}))
.filter((p) => p.models.length > 0)
: []
console.log(
"[catalog] loaded:",
agents.length,
"agents,",
commands.length,
"commands,",
providers.length,
"providers (" + providers.reduce((n, p) => n + p.models.length, 0) + " models)",
)
// Filter out hidden agents
const visible = agents.filter((a) => !a.hidden)
// Default agent
const current = get().agent
const agent = current && visible.some((a) => a.name === current) ? current : visible[0]?.name || "build"
// Default model: use default agent's model, or first connected provider's default model
const existing = get().model
const fallback = (() => {
const defaultAgent = visible[0]
if (defaultAgent?.model) return defaultAgent.model
for (const p of providers) {
const defaultModelID = defaults[p.id]
const match = defaultModelID ? p.models.find((m) => m.id === defaultModelID) : p.models[0]
if (match) return { providerID: p.id, modelID: match.id }
}
return null
})()
const model = existing || fallback
set({ agents: visible, commands, providers, defaults, agent, model, loaded: true })
},
setAgent: (name) => {
const match = get().agents.find((a) => a.name === name)
if (!match) return
const model = match.model || get().model
set({ agent: name, model })
},
setModel: (selection) => set({ model: selection }),
cycleAgent: (direction = 1) => {
const { agents, agent } = get()
const primary = agents.filter((a) => a.mode === "primary" || a.mode === "all")
if (primary.length < 2) return
const idx = primary.findIndex((a) => a.name === agent)
const next = (idx + direction + primary.length) % primary.length
get().setAgent(primary[next].name)
},
}))

312
src/stores/connections.ts Normal file
View File

@@ -0,0 +1,312 @@
import { create } from "zustand"
import * as SecureStore from "expo-secure-store"
import type { ServerConnection, ConnectionType } from "../lib/types"
import { createClient, type Client, type Project } from "../lib/sdk"
const CONNECTIONS_KEY = "opencode_connections"
const PASSWORDS_PREFIX = "opencode_password_"
const RECENT_DIRS_KEY = "opencode_recent_dirs"
const MAX_RECENT_DIRS = 10
// Cached auth so we can create directory-scoped clients without async SecureStore lookups
interface ClientBase {
baseUrl: string
auth?: { username: string; password: string }
}
interface ConnectionsState {
connections: ServerConnection[]
activeConnection: ServerConnection | null
client: Client | null
clientBase: ClientBase | null
currentProject: Project | null
serverHome: string | null // Home directory on the server machine (for ~ expansion)
recentDirectories: string[]
isLoading: boolean
error: string | null
// Actions
loadConnections: () => Promise<void>
addConnection: (connection: Omit<ServerConnection, "id">, password?: string) => Promise<void>
removeConnection: (id: string) => Promise<void>
setActiveConnection: (id: string) => Promise<void>
testConnection: (connection: ServerConnection, password?: string) => Promise<boolean>
updateConnection: (id: string, updates: Partial<ServerConnection>) => Promise<void>
refreshProject: () => Promise<void>
// Create a one-off client pointing at a specific directory (for cross-project operations)
clientForDirectory: (directory: string) => Client | null
// Switch the active connection's directory and reload
switchDirectory: (directory?: string) => Promise<void>
// Record a directory as recently used
addRecentDirectory: (directory: string) => Promise<void>
}
function generateId(): string {
return Math.random().toString(36).slice(2, 11)
}
function buildClient(
url: string,
directory?: string,
auth?: { username: string; password: string },
): { client: Client; base: ClientBase } {
const base: ClientBase = { baseUrl: url, auth }
const client = createClient({ baseUrl: url, directory, auth })
return { client, base }
}
export const useConnections = create<ConnectionsState>((set, get) => ({
connections: [],
activeConnection: null,
client: null,
clientBase: null,
serverHome: null,
currentProject: null,
recentDirectories: [],
isLoading: true,
error: null,
loadConnections: async () => {
try {
set({ isLoading: true, error: null })
const [stored, recentRaw] = await Promise.all([
SecureStore.getItemAsync(CONNECTIONS_KEY),
SecureStore.getItemAsync(RECENT_DIRS_KEY),
])
const connections: ServerConnection[] = stored ? JSON.parse(stored) : []
const recentDirectories: string[] = recentRaw ? JSON.parse(recentRaw) : []
// Find active connection
const active = connections.find((c) => c.active) || null
// Create client for active connection
let client: Client | null = null
let base: ClientBase | null = null
let project: Project | null = null
let home: string | null = null
if (active) {
const password = await SecureStore.getItemAsync(`${PASSWORDS_PREFIX}${active.id}`)
const auth = active.username && password ? { username: active.username, password } : undefined
const built = buildClient(active.url, active.directory, auth)
client = built.client
base = built.base
// Fetch current project info and server paths
try {
const [proj, paths] = await Promise.all([
client.project.current().catch(() => null),
client.path.get().catch(() => null),
])
project = proj
home = paths?.home || null
} catch {
// Server might be offline
}
}
set({
connections,
activeConnection: active,
client,
clientBase: base,
currentProject: project,
serverHome: home,
recentDirectories,
isLoading: false,
})
} catch (error) {
set({ error: "Failed to load connections", isLoading: false })
}
},
addConnection: async (connection, password) => {
const id = generateId()
const newConnection: ServerConnection = {
...connection,
id,
active: get().connections.length === 0, // First connection is active
}
const connections = [...get().connections, newConnection]
// Store password separately if provided
if (password) {
await SecureStore.setItemAsync(`${PASSWORDS_PREFIX}${id}`, password)
}
await SecureStore.setItemAsync(CONNECTIONS_KEY, JSON.stringify(connections))
// If this is the first/active connection, create client
let client = get().client
let base = get().clientBase
let activeConnection = get().activeConnection
if (newConnection.active) {
activeConnection = newConnection
const auth = newConnection.username && password ? { username: newConnection.username, password } : undefined
const built = buildClient(newConnection.url, newConnection.directory, auth)
client = built.client
base = built.base
}
set({ connections, activeConnection, client, clientBase: base })
},
removeConnection: async (id) => {
const connections = get().connections.filter((c) => c.id !== id)
// Remove stored password
await SecureStore.deleteItemAsync(`${PASSWORDS_PREFIX}${id}`)
await SecureStore.setItemAsync(CONNECTIONS_KEY, JSON.stringify(connections))
// If removing active connection, clear client
const wasActive = get().activeConnection?.id === id
if (wasActive) {
const newActive = connections[0] || null
if (newActive) {
// Mark new connection as active
newActive.active = true
await SecureStore.setItemAsync(CONNECTIONS_KEY, JSON.stringify(connections))
const password = await SecureStore.getItemAsync(`${PASSWORDS_PREFIX}${newActive.id}`)
const auth = newActive.username && password ? { username: newActive.username, password } : undefined
const built = buildClient(newActive.url, newActive.directory, auth)
set({ connections, activeConnection: newActive, client: built.client, clientBase: built.base })
} else {
set({ connections, activeConnection: null, client: null, clientBase: null })
}
} else {
set({ connections })
}
},
setActiveConnection: async (id) => {
const connections = get().connections.map((c) => ({
...c,
active: c.id === id,
}))
await SecureStore.setItemAsync(CONNECTIONS_KEY, JSON.stringify(connections))
const active = connections.find((c) => c.id === id) || null
let client: Client | null = null
let base: ClientBase | null = null
let project: Project | null = null
let home: string | null = null
if (active) {
const password = await SecureStore.getItemAsync(`${PASSWORDS_PREFIX}${active.id}`)
const auth = active.username && password ? { username: active.username, password } : undefined
const built = buildClient(active.url, active.directory, auth)
client = built.client
base = built.base
try {
const [proj, paths] = await Promise.all([
client.project.current().catch(() => null),
client.path.get().catch(() => null),
])
project = proj
home = paths?.home || null
} catch {
// Server might be offline
}
// Update last connected time
active.lastConnected = Date.now()
await SecureStore.setItemAsync(CONNECTIONS_KEY, JSON.stringify(connections))
}
set({ connections, activeConnection: active, client, clientBase: base, currentProject: project, serverHome: home })
},
testConnection: async (connection, password) => {
try {
const client = createClient({
baseUrl: connection.url,
directory: connection.directory,
auth: connection.username && password ? { username: connection.username, password } : undefined,
})
await client.global.health()
return true
} catch {
return false
}
},
updateConnection: async (id, updates) => {
const connections = get().connections.map((c) => (c.id === id ? { ...c, ...updates } : c))
await SecureStore.setItemAsync(CONNECTIONS_KEY, JSON.stringify(connections))
// If updating active connection, recreate client
if (get().activeConnection?.id === id) {
const active = connections.find((c) => c.id === id)!
const password = await SecureStore.getItemAsync(`${PASSWORDS_PREFIX}${id}`)
const auth = active.username && password ? { username: active.username, password } : undefined
const built = buildClient(active.url, active.directory, auth)
try {
const [project, paths] = await Promise.all([
built.client.project.current().catch(() => null),
built.client.path.get().catch(() => null),
])
set({
connections,
activeConnection: active,
client: built.client,
clientBase: built.base,
currentProject: project,
serverHome: paths?.home || null,
})
} catch {
set({
connections,
activeConnection: active,
client: built.client,
clientBase: built.base,
currentProject: null,
})
}
} else {
set({ connections })
}
},
refreshProject: async () => {
const client = get().client
if (!client) return
try {
const project = await client.project.current()
set({ currentProject: project })
} catch {
set({ currentProject: null })
}
},
clientForDirectory: (directory) => {
const base = get().clientBase
if (!base) return null
// Reuse current client if directory matches
const active = get().activeConnection
if (active?.directory === directory) return get().client
return createClient({ baseUrl: base.baseUrl, directory, auth: base.auth })
},
switchDirectory: async (directory) => {
const active = get().activeConnection
if (!active) return
// Update connection directory and recreate client
const dir = directory?.trim() || undefined
await get().updateConnection(active.id, { directory: dir })
// Record in recents if it's a real directory
if (dir) await get().addRecentDirectory(dir)
},
addRecentDirectory: async (directory) => {
const current = get().recentDirectories
// Move to front, dedup, cap at MAX
const updated = [directory, ...current.filter((d) => d !== directory)].slice(0, MAX_RECENT_DIRS)
set({ recentDirectories: updated })
await SecureStore.setItemAsync(RECENT_DIRS_KEY, JSON.stringify(updated))
},
}))

371
src/stores/events.ts Normal file
View File

@@ -0,0 +1,371 @@
import { create } from "zustand"
import { useConnections } from "./connections"
import { useSessions } from "./sessions"
import { send as notify } from "../lib/notifications"
import type { Client, Part, Session, Message } from "../lib/sdk"
// Session status from the server
type SessionStatus = { type: "idle" } | { type: "busy" } | { type: "retry"; attempt: number; message: string }
// Tool status labels derived from part type
const TOOL_STATUS: Record<string, string> = {
read: "Gathering context...",
list: "Searching codebase...",
grep: "Searching codebase...",
glob: "Searching codebase...",
webfetch: "Searching web...",
edit: "Making edits...",
write: "Making edits...",
apply_patch: "Making edits...",
bash: "Running command...",
task: "Delegating...",
todowrite: "Planning...",
todoread: "Planning...",
}
function statusFromPart(part: Part): string {
if (part.type === "reasoning") return "Thinking..."
if (part.type === "tool" && part.tool) return TOOL_STATUS[part.tool] || `Running ${part.tool}...`
if (part.type === "text") return "Writing..."
return "Working..."
}
interface EventsState {
connected: boolean
reconnectAttempts: number
lastDisconnectAt: number | null
sessionStatus: Record<string, SessionStatus>
statusText: Record<string, string>
// Permissions & questions (pending per session)
permissions: Record<
string,
Array<{
id: string
sessionID: string
permission: string
patterns: string[]
metadata: Record<string, unknown>
tool?: { messageID: string; callID: string }
}>
>
questions: Record<
string,
Array<{
id: string
sessionID: string
questions: Array<{
question: string
header: string
options: Array<{ label: string; description: string }>
multiple?: boolean
custom?: boolean
}>
tool?: { messageID: string; callID: string }
}>
>
connect: () => void
disconnect: () => void
}
let controller: AbortController | null = null
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
const RECONNECT_DELAYS_MS = [1000, 2000, 4000, 8000, 15000] as const
const STABLE_CONNECTION_MS = 10_000
const PROLONGED_DISCONNECT_MS = 30_000
// Re-fetch pending permissions and questions from the server for a session.
// Called when entering a session to recover from missed SSE events or failed
// optimistic removals.
export async function refreshPending(client: Client, sessionID: string) {
try {
const [perms, questions] = await Promise.all([client.permission.list(), client.question.list()])
const sessionPerms = (perms || []).filter((p: Record<string, unknown>) => p.sessionID === sessionID)
const sessionQuestions = (questions || []).filter((q: Record<string, unknown>) => q.sessionID === sessionID)
useEvents.setState((state) => ({
permissions: { ...state.permissions, [sessionID]: sessionPerms as any },
questions: { ...state.questions, [sessionID]: sessionQuestions as any },
}))
} catch (err) {
console.warn("[Events] Failed to refresh pending:", err)
}
}
export const useEvents = create<EventsState>((set, get) => ({
connected: false,
reconnectAttempts: 0,
lastDisconnectAt: null,
sessionStatus: {},
statusText: {},
permissions: {},
questions: {},
connect: () => {
controller?.abort()
controller = null
if (reconnectTimer) {
clearTimeout(reconnectTimer)
reconnectTimer = null
}
const client = useConnections.getState().client
if (!client) return
controller = new AbortController()
const currentController = controller
set({ connected: true })
console.log("[SSE] Connecting to event stream...")
// Run in background
;(async () => {
let reconnectScheduled = false
const stableTimer = setTimeout(() => {
if (!currentController.signal.aborted) {
set({ reconnectAttempts: 0, lastDisconnectAt: null })
}
}, STABLE_CONNECTION_MS)
const scheduleReconnect = (reason: unknown) => {
if (reconnectScheduled || currentController.signal.aborted) return
reconnectScheduled = true
const state = get()
const reconnectAttempts = state.reconnectAttempts + 1
const lastDisconnectAt = state.lastDisconnectAt ?? Date.now()
const disconnectedFor = Date.now() - lastDisconnectAt
set({ connected: false, reconnectAttempts, lastDisconnectAt })
if (disconnectedFor >= PROLONGED_DISCONNECT_MS) {
notify({
category: "connection",
title: "Connection interrupted",
body: "Trying to reconnect to your server",
sessionId: "",
dedupeKey: "sse-prolonged-disconnect",
dedupeCooldownMs: 60_000,
})
}
const baseDelay = RECONNECT_DELAYS_MS[Math.min(reconnectAttempts - 1, RECONNECT_DELAYS_MS.length - 1)]
const jitteredDelay = Math.min(15_000, Math.round(baseDelay * (0.75 + Math.random() * 0.5)))
console.warn(`[SSE] Connection lost, reconnecting in ${jitteredDelay}ms:`, reason)
reconnectTimer = setTimeout(() => {
reconnectTimer = null
get().connect()
}, jitteredDelay)
}
try {
for await (const event of client.global.events(currentController.signal)) {
if (currentController.signal.aborted) break
const payload = (event as any).payload || event
const type = payload.type as string
const props = payload.properties || {}
switch (type) {
case "session.status": {
const sessionID = props.sessionID as string
const status = props.status as SessionStatus
if (!sessionID) break
// Detect busy → idle transition for completion notification
const previous = get().sessionStatus[sessionID]
const completed = previous?.type === "busy" && status.type === "idle"
set((state) => ({
sessionStatus: { ...state.sessionStatus, [sessionID]: status },
// Clear status text when idle
statusText: status.type === "idle" ? { ...state.statusText, [sessionID]: "" } : state.statusText,
}))
// SSE is the source of truth — update sending state unconditionally
if (status.type === "idle") {
useSessions.setState((state) => ({
sending: { ...state.sending, [sessionID]: false },
}))
// Refresh messages if this is the session the user is viewing
const sessions = useSessions.getState()
if (sessions.currentSession?.id === sessionID) {
sessions.refreshMessages()
}
}
if (completed) {
const match = useSessions.getState().sessions.find((s) => s.id === sessionID)
notify({
category: "completed",
title: "Task completed",
body: match?.title || "Session finished processing",
sessionId: sessionID,
})
}
break
}
case "message.updated": {
const info = props.info as Message | undefined
if (!info) break
useSessions.getState().handleEvent({ type, properties: { info } } as any)
break
}
case "message.part.updated": {
const part = props.part as Part | undefined
if (!part) break
// Update status text from the latest part
const sessionID = (part as any).sessionID as string
if (sessionID) {
set((state) => ({
statusText: { ...state.statusText, [sessionID]: statusFromPart(part) },
}))
}
useSessions.getState().handleEvent({ type, properties: { part } } as any)
break
}
case "session.updated": {
const info = props.info as Session | undefined
if (!info) break
useSessions.getState().handleEvent({ type, properties: { info } } as any)
break
}
case "session.created": {
const info = props.info as Session | undefined
if (!info) break
// Add to sessions list
useSessions.setState((state) => {
const exists = state.sessions.some((s) => s.id === info.id)
if (exists) return {}
return { sessions: [info, ...state.sessions] }
})
break
}
case "session.error": {
const error = props.error as { message?: string } | undefined
const sessionID = props.sessionID as string
if (!sessionID) break
// Clear sending state unconditionally — SSE is truth
useSessions.setState((state) => ({
sending: { ...state.sending, [sessionID]: false },
// Surface error only if user is viewing this session
...(state.currentSession?.id === sessionID
? { error: error?.message || "Session error occurred" }
: {}),
}))
if (useSessions.getState().currentSession?.id === sessionID) {
useSessions.getState().refreshMessages()
}
notify({
category: "errors",
title: "Session error",
body: error?.message || "Something went wrong",
sessionId: sessionID,
})
break
}
case "permission.asked": {
const req = props as any
if (!req.id || !req.sessionID) break
const existing = get().permissions[req.sessionID] || []
if (existing.some((item) => item.id === req.id)) break
set((state) => ({
permissions: {
...state.permissions,
[req.sessionID]: [...(state.permissions[req.sessionID] || []), req],
},
}))
notify({
category: "permissions",
title: req.permission || "Permission requested",
body: req.patterns?.join(", ") || "A tool needs your approval",
sessionId: req.sessionID,
})
break
}
case "permission.replied": {
const sessionID = props.sessionID as string
const requestID = props.requestID as string
if (!sessionID || !requestID) break
set((state) => ({
permissions: {
...state.permissions,
[sessionID]: (state.permissions[sessionID] || []).filter((p) => p.id !== requestID),
},
}))
break
}
case "question.asked": {
const req = props as any
if (!req.id || !req.sessionID) break
const existing = get().questions[req.sessionID] || []
if (existing.some((item) => item.id === req.id)) break
set((state) => ({
questions: {
...state.questions,
[req.sessionID]: [...(state.questions[req.sessionID] || []), req],
},
}))
notify({
category: "questions",
title: req.questions?.[0]?.header || "Input needed",
body: req.questions?.[0]?.question || "The assistant has a question",
sessionId: req.sessionID,
})
break
}
case "question.replied":
case "question.rejected": {
const sessionID = props.sessionID as string
const requestID = props.requestID as string
if (!sessionID || !requestID) break
set((state) => ({
questions: {
...state.questions,
[sessionID]: (state.questions[sessionID] || []).filter((q) => q.id !== requestID),
},
}))
break
}
}
}
scheduleReconnect(new Error("Event stream closed"))
} catch (err) {
scheduleReconnect(err)
} finally {
clearTimeout(stableTimer)
if (currentController.signal.aborted) {
console.log("[SSE] Disconnected (aborted)")
}
}
})()
},
disconnect: () => {
console.log("[SSE] Disconnecting")
if (reconnectTimer) {
clearTimeout(reconnectTimer)
reconnectTimer = null
}
controller?.abort()
controller = null
set({
connected: false,
reconnectAttempts: 0,
lastDisconnectAt: null,
sessionStatus: {},
statusText: {},
permissions: {},
questions: {},
})
},
}))

374
src/stores/sessions.ts Normal file
View File

@@ -0,0 +1,374 @@
import { create } from "zustand"
import type { Session, Message, Part, Event, MessageWithParts, Client } from "../lib/sdk"
import { useConnections } from "./connections"
import { useSettings } from "./settings"
// Helper to convert API response to our internal format
function parseMessages(response: MessageWithParts[]): { messages: Message[]; parts: Record<string, Part[]> } {
const messages: Message[] = []
const parts: Record<string, Part[]> = {}
for (const item of response || []) {
messages.push(item.info)
parts[item.info.id] = item.parts || []
}
return { messages, parts }
}
function pageSize(): number {
return useSettings.getState().pageSize
}
interface SessionsState {
sessions: Session[]
currentSession: Session | null
messages: Message[]
parts: Record<string, Part[]>
isLoading: boolean
// Per-session optimistic sending flag — bridging gap between user tap and SSE busy
sending: Record<string, boolean>
loadingMore: boolean
hasMore: boolean
error: string | null
// Actions
loadSessions: () => Promise<void>
selectSession: (sessionID: string, directory?: string) => Promise<void>
loadOlderMessages: () => Promise<void>
createSession: (title?: string) => Promise<Session | null>
deleteSession: (sessionID: string) => Promise<void>
sendMessage: (
text: string,
model?: { providerID: string; modelID: string },
agent?: string,
files?: Array<{ uri: string; mime: string; filename?: string; base64?: string }>,
) => Promise<void>
abortSession: () => Promise<void>
refreshMessages: () => Promise<void>
// Event handling
handleEvent: (event: Event) => void
}
// Get the right client for a session's directory
function clientFor(directory?: string): Client | null {
const connState = useConnections.getState()
if (!directory) return connState.client
const connDir = connState.activeConnection?.directory
if (directory !== connDir) return connState.clientForDirectory(directory)
return connState.client
}
export const useSessions = create<SessionsState>((set, get) => ({
sessions: [],
currentSession: null,
messages: [],
parts: {},
isLoading: false,
sending: {},
loadingMore: false,
hasMore: false,
error: null,
loadSessions: async () => {
const client = useConnections.getState().client
if (!client) {
set({ error: "No active connection" })
return
}
try {
set({ isLoading: true, error: null })
const sessions = await client.session.list({ roots: true, limit: 50 })
set({ sessions, isLoading: false })
} catch (error) {
set({ error: "Failed to load sessions", isLoading: false })
}
},
selectSession: async (sessionID, directory) => {
// Use directory-specific client if the session belongs to a different project
const connState = useConnections.getState()
const client = directory ? connState.clientForDirectory(directory) : connState.client
if (!client) {
set({ error: "No active connection" })
return
}
try {
// Reset optimistic sending — SSE sessionStatus is the source of truth
set((state) => ({
isLoading: true,
error: null,
hasMore: false,
loadingMore: false,
sending: { ...state.sending, [sessionID]: false },
}))
const [session, messagesResponse] = await Promise.all([
client.session.get(sessionID),
client.session.messages(sessionID, { limit: pageSize() }),
])
// Parse the API response format: array of { info, parts }
const { messages, parts } = parseMessages(messagesResponse)
set({
currentSession: session,
messages,
parts,
isLoading: false,
// If we got exactly PAGE_SIZE messages, there are probably more
hasMore: messagesResponse.length >= pageSize(),
})
} catch (err) {
console.error("Failed to load session:", err)
set({ error: "Failed to load session", isLoading: false })
}
},
loadOlderMessages: async () => {
const client = clientFor(get().currentSession?.directory)
const session = get().currentSession
if (!client || !session) return
if (get().loadingMore || !get().hasMore) return
try {
set({ loadingMore: true })
// Fetch ALL messages for this session
const response = await client.session.messages(session.id)
const { messages: all, parts: allParts } = parseMessages(response)
// Merge: use all messages from full fetch, but keep any temp/optimistic messages
const existing = get().messages
const temp = existing.filter((m) => m.id.startsWith("temp-"))
const merged = [...all, ...temp]
set({
messages: merged,
parts: { ...allParts, ...Object.fromEntries(temp.map((m) => [m.id, get().parts[m.id] || []])) },
loadingMore: false,
hasMore: false, // We loaded everything
})
} catch (error) {
console.error("Failed to load older messages:", error)
set({ loadingMore: false })
}
},
createSession: async (title) => {
const client = useConnections.getState().client
if (!client) {
set({ error: "No active connection" })
return null
}
try {
const session = await client.session.create({ title })
// Don't optimistically add to sessions list — let loadSessions() handle it
// to avoid duplicate key errors from race conditions
set({
currentSession: session,
messages: [],
parts: {},
hasMore: false,
loadingMore: false,
})
return session
} catch (error) {
set({ error: "Failed to create session" })
return null
}
},
deleteSession: async (sessionID) => {
const client = useConnections.getState().client
if (!client) {
set({ error: "No active connection" })
return
}
try {
await client.session.delete(sessionID)
set((state) => ({
sessions: state.sessions.filter((s) => s.id !== sessionID),
currentSession: state.currentSession?.id === sessionID ? null : state.currentSession,
messages: state.currentSession?.id === sessionID ? [] : state.messages,
parts: state.currentSession?.id === sessionID ? {} : state.parts,
}))
} catch (error) {
set({ error: "Failed to delete session" })
}
},
sendMessage: async (text, model, agent, files) => {
const client = clientFor(get().currentSession?.directory)
const session = get().currentSession
if (!client || !session) {
set({ error: "No active session" })
return
}
try {
set((state) => ({ sending: { ...state.sending, [session.id]: true }, error: null }))
// Add user message optimistically
const ts = Date.now()
const userMessage: Message = {
id: `temp-${ts}`,
sessionID: session.id,
role: "user",
time: { created: ts },
model,
agent,
}
const optimisticParts: Part[] = []
if (text) {
optimisticParts.push({
id: `temp-part-text-${ts}`,
messageID: userMessage.id,
type: "text",
text,
})
}
if (files) {
for (let i = 0; i < files.length; i++) {
const f = files[i]
optimisticParts.push({
id: `temp-part-file-${ts}-${i}`,
messageID: userMessage.id,
type: "file",
mime: f.mime,
url: f.uri,
filename: f.filename,
})
}
}
set((state) => ({
messages: [...state.messages, userMessage],
parts: { ...state.parts, [userMessage.id]: optimisticParts },
}))
// Build prompt parts - images are already converted to JPEG with base64 by toJpeg()
const promptParts: Array<
{ type: "text"; text: string } | { type: "file"; mime: string; url: string; filename?: string }
> = []
if (text) {
promptParts.push({ type: "text", text })
}
if (files) {
for (const f of files) {
const url = f.base64 ? `data:${f.mime};base64,${f.base64}` : f.uri
promptParts.push({ type: "file", mime: f.mime, url, filename: f.filename })
}
}
// Fire and forget - SSE events will update messages/parts/status in real-time
client.session.prompt(session.id, { parts: promptParts, model, agent }).catch((err) => {
console.error("Failed to send message:", err)
set((state) => ({ error: String(err), sending: { ...state.sending, [session.id]: false } }))
get().refreshMessages()
})
} catch (err) {
console.error("[sendMessage] error:", err)
set((state) => ({ error: String(err), sending: { ...state.sending, [session.id]: false } }))
get().refreshMessages()
}
},
abortSession: async () => {
const client = clientFor(get().currentSession?.directory)
const session = get().currentSession
if (!client || !session) return
try {
await client.session.abort(session.id)
set((state) => ({ sending: { ...state.sending, [session.id]: false } }))
} catch {
set({ error: "Failed to abort session" })
}
},
refreshMessages: async () => {
const client = clientFor(get().currentSession?.directory)
const session = get().currentSession
if (!client || !session) return
try {
const response = await client.session.messages(session.id)
const { messages, parts } = parseMessages(response)
set({ messages, parts })
} catch (error) {
set({ error: "Failed to refresh messages" })
}
},
handleEvent: (event) => {
const { currentSession } = get()
if (!currentSession) return
const props = (event as any).properties || {}
switch (event.type) {
case "message.updated": {
const message = (props.info || props.message) as Message | undefined
if (!message || message.sessionID !== currentSession.id) return
set((state) => {
// Remove temp messages when real ones arrive
const filtered = state.messages.filter((m) => !m.id.startsWith("temp-") || m.id === message.id)
const exists = filtered.some((m) => m.id === message.id)
return {
messages: exists ? filtered.map((m) => (m.id === message.id ? message : m)) : [...filtered, message],
}
})
break
}
case "message.part.updated": {
const part = props.part as Part | undefined
if (!part) return
// Only handle parts for current session
if (part.sessionID && part.sessionID !== currentSession.id) return
set((state) => {
const messageParts = state.parts[part.messageID] || []
const exists = messageParts.some((p) => p.id === part.id)
return {
parts: {
...state.parts,
[part.messageID]: exists
? messageParts.map((p) => (p.id === part.id ? part : p))
: [...messageParts, part],
},
}
})
break
}
case "message.removed": {
const messageID = props.messageID as string
if (!messageID) return
set((state) => ({
messages: state.messages.filter((m) => m.id !== messageID),
parts: Object.fromEntries(Object.entries(state.parts).filter(([k]) => k !== messageID)),
}))
break
}
case "session.updated": {
const session = (props.info || props) as Session | undefined
if (!session?.id) return
set((state) => ({
sessions: state.sessions.map((s) => (s.id === session.id ? session : s)),
currentSession: state.currentSession?.id === session.id ? session : state.currentSession,
}))
break
}
}
},
}))

59
src/stores/settings.ts Normal file
View File

@@ -0,0 +1,59 @@
import { create } from "zustand"
import * as SecureStore from "expo-secure-store"
import { type Category, defaultPreferences } from "../lib/notifications"
const SETTINGS_KEY = "opencode_settings"
interface Settings {
pageSize: number
notifications: Record<Category, boolean>
}
const DEFAULTS: Settings = {
pageSize: 25,
notifications: { ...defaultPreferences },
}
interface SettingsState extends Settings {
loaded: boolean
load: () => Promise<void>
setPageSize: (size: number) => Promise<void>
setNotification: (category: Category, enabled: boolean) => Promise<void>
}
function snapshot(get: () => SettingsState): Settings {
return { pageSize: get().pageSize, notifications: get().notifications }
}
async function persist(settings: Settings) {
await SecureStore.setItemAsync(SETTINGS_KEY, JSON.stringify(settings))
}
export const useSettings = create<SettingsState>((set, get) => ({
...DEFAULTS,
loaded: false,
load: async () => {
const raw = await SecureStore.getItemAsync(SETTINGS_KEY)
if (raw) {
const parsed = JSON.parse(raw) as Partial<Settings>
// Merge stored notifications with defaults so new categories get their default
const notifications = { ...DEFAULTS.notifications, ...parsed.notifications }
set({ ...DEFAULTS, ...parsed, notifications, loaded: true })
return
}
set({ loaded: true })
},
setPageSize: async (size) => {
const clamped = Math.max(10, Math.min(200, size))
set({ pageSize: clamped })
await persist({ ...snapshot(get), pageSize: clamped })
},
setNotification: async (category, enabled) => {
const notifications = { ...get().notifications, [category]: enabled }
set({ notifications })
await persist({ ...snapshot(get), notifications })
},
}))

31
src/types/expo-icons.d.ts vendored Normal file
View File

@@ -0,0 +1,31 @@
// Override @expo/vector-icons types to fix React 18/19 JSX element type mismatch.
// The library bundles @types/react@18 internally which conflicts with our React 19.
// This declaration makes Ionicons (and other icon sets) work as valid JSX elements.
declare module "@expo/vector-icons" {
import type { TextProps } from "react-native"
interface IconProps extends TextProps {
name: string
size?: number
color?: string
style?: any
}
interface IconComponent extends React.FC<IconProps> {
glyphMap: Record<string, number>
}
export const Ionicons: IconComponent
export const MaterialIcons: IconComponent
export const MaterialCommunityIcons: IconComponent
export const FontAwesome: IconComponent
export const FontAwesome5: IconComponent
export const Feather: IconComponent
export const AntDesign: IconComponent
export const Entypo: IconComponent
export const EvilIcons: IconComponent
export const Foundation: IconComponent
export const Octicons: IconComponent
export const SimpleLineIcons: IconComponent
export const Zocial: IconComponent
}

100
src/types/react-native-marked.d.ts vendored Normal file
View File

@@ -0,0 +1,100 @@
// Override react-native-marked types to fix React 18/19 ReactNode mismatch.
// The library bundles @types/react@18 internally which conflicts with our React 19.
// This declaration re-exports the module with React 19-compatible types.
declare module "react-native-marked" {
import type { ReactNode } from "react"
import type { FlatListProps, ImageStyle, TextStyle, ViewStyle } from "react-native"
import type { Hooks, Tokenizer } from "marked"
export interface RendererInterface {
paragraph(children: ReactNode[], styles?: ViewStyle): ReactNode
blockquote(children: ReactNode[], styles?: ViewStyle): ReactNode
heading(text: string | ReactNode[], styles?: TextStyle, depth?: number): ReactNode
code(text: string, language?: string, containerStyle?: ViewStyle, textStyle?: TextStyle): ReactNode
hr(styles?: ViewStyle): ReactNode
listItem(children: ReactNode[], styles?: ViewStyle): ReactNode
list(
ordered: boolean,
li: ReactNode[],
listStyle?: ViewStyle,
textStyle?: TextStyle,
startIndex?: number,
): ReactNode
escape(text: string, styles?: TextStyle): ReactNode
link(children: string | ReactNode[], href: string, styles?: TextStyle, title?: string): ReactNode
image(uri: string, alt?: string, style?: ImageStyle, title?: string): ReactNode
strong(children: string | ReactNode[], styles?: TextStyle): ReactNode
em(children: string | ReactNode[], styles?: TextStyle): ReactNode
codespan(text: string, styles?: TextStyle): ReactNode
br(): ReactNode
del(children: string | ReactNode[], styles?: TextStyle): ReactNode
text(text: string | ReactNode[], styles?: TextStyle): ReactNode
html(text: string | ReactNode[], styles?: TextStyle): ReactNode
linkImage(href: string, imageUrl: string, alt?: string, style?: ImageStyle, title?: string | null): ReactNode
table(
header: ReactNode[][],
rows: ReactNode[][][],
tableStyle?: ViewStyle,
rowStyle?: ViewStyle,
cellStyle?: ViewStyle,
): ReactNode
}
export class Renderer implements RendererInterface {
paragraph(children: ReactNode[], styles?: ViewStyle): ReactNode
blockquote(children: ReactNode[], styles?: ViewStyle): ReactNode
heading(text: string | ReactNode[], styles?: TextStyle): ReactNode
code(text: string, language?: string, containerStyle?: ViewStyle, textStyle?: TextStyle): ReactNode
hr(styles?: ViewStyle): ReactNode
listItem(children: ReactNode[], styles?: ViewStyle): ReactNode
list(
ordered: boolean,
li: ReactNode[],
listStyle?: ViewStyle,
textStyle?: TextStyle,
startIndex?: number,
): ReactNode
escape(text: string, styles?: TextStyle): ReactNode
link(children: string | ReactNode[], href: string, styles?: TextStyle, title?: string): ReactNode
image(uri: string, alt?: string, style?: ImageStyle, title?: string): ReactNode
strong(children: string | ReactNode[], styles?: TextStyle): ReactNode
em(children: string | ReactNode[], styles?: TextStyle): ReactNode
codespan(text: string, styles?: TextStyle): ReactNode
br(): ReactNode
del(children: string | ReactNode[], styles?: TextStyle): ReactNode
text(text: string | ReactNode[], styles?: TextStyle): ReactNode
html(text: string | ReactNode[], styles?: TextStyle): ReactNode
linkImage(href: string, imageUrl: string, alt?: string, style?: ImageStyle, title?: string | null): ReactNode
table(
header: ReactNode[][],
rows: ReactNode[][][],
tableStyle?: ViewStyle,
rowStyle?: ViewStyle,
cellStyle?: ViewStyle,
): ReactNode
getKey(): string
}
export interface UserTheme {
colors?: Record<string, string>
spacing?: Record<string, number>
}
export interface MarkedStyles {
[key: string]: ViewStyle | TextStyle | ImageStyle | undefined
}
export interface MarkdownProps {
value: string
flatListProps?: Omit<FlatListProps<ReactNode>, "data" | "renderItem" | "horizontal">
theme?: UserTheme
renderer?: RendererInterface
styles?: MarkedStyles | Record<string, unknown>
baseUrl?: string
tokenizer?: Tokenizer
hooks?: Hooks
}
const Markdown: React.FC<MarkdownProps>
export default Markdown
}

10
tsconfig.json Normal file
View File

@@ -0,0 +1,10 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"]
}