diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index e3329b1dad9..a7fa9ec0092 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -74,6 +74,33 @@ jobs:
test -f apps/desktop/dist-electron/preload.cjs
grep -nE "desktopBridge|getLocalEnvironmentBootstrap|PICK_FOLDER_CHANNEL|wsUrl" apps/desktop/dist-electron/preload.cjs
+ mobile_native_static_analysis:
+ name: Mobile Native Static Analysis
+ runs-on: blacksmith-12vcpu-macos-26
+ timeout-minutes: 10
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+
+ - name: Setup Bun
+ uses: oven-sh/setup-bun@v2
+ with:
+ bun-version-file: package.json
+
+ - name: Setup Node
+ uses: actions/setup-node@v6
+ with:
+ node-version-file: package.json
+
+ - name: Install dependencies
+ run: bun install --frozen-lockfile
+
+ - name: Install mobile native static analysis tools
+ run: brew bundle install --file apps/mobile/Brewfile
+
+ - name: Lint mobile native sources
+ run: node scripts/mobile-native-static-check.ts
+
release_smoke:
name: Release Smoke
runs-on: blacksmith-8vcpu-ubuntu-2404
diff --git a/.oxfmtrc.json b/.oxfmtrc.json
index 3d65d9c93bb..3e731cbaffd 100644
--- a/.oxfmtrc.json
+++ b/.oxfmtrc.json
@@ -9,8 +9,11 @@
"bun.lock",
"*.tsbuildinfo",
"**/routeTree.gen.ts",
+ "apps/mobile/android/**",
+ "apps/mobile/ios/**",
"apps/web/public/mockServiceWorker.js",
"apps/web/src/lib/vendor/qrcodegen.ts",
+ "apps/mobile/uniwind-types.d.ts",
"*.icon/**"
],
"sortPackageJson": {},
diff --git a/.oxlintrc.json b/.oxlintrc.json
index de3a72ae112..fe9d133e091 100644
--- a/.oxlintrc.json
+++ b/.oxlintrc.json
@@ -6,7 +6,10 @@
"node_modules",
"bun.lock",
"*.tsbuildinfo",
- "**/routeTree.gen.ts"
+ "**/routeTree.gen.ts",
+ "apps/mobile/android/**",
+ "apps/mobile/ios/**",
+ "apps/mobile/uniwind-types.d.ts"
],
"plugins": ["eslint", "oxc", "react", "unicorn", "typescript"],
"jsPlugins": ["./oxlint-plugin-t3code/index.ts"],
@@ -16,6 +19,7 @@
"perf": "warn"
},
"rules": {
+ "unicorn/no-array-sort": "off",
"react-in-jsx-scope": "off",
"eslint/no-shadow": "off",
"eslint/no-await-in-loop": "off",
diff --git a/AGENTS.md b/AGENTS.md
index cea5090cce0..70766bbd7b7 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -3,6 +3,7 @@
## Task Completion Requirements
- All of `bun fmt`, `bun lint`, and `bun typecheck` must pass before considering tasks completed.
+ - If changing native mobile code, `bun lint:mobile` must also pass.
- NEVER run `bun test`. Always use `bun run test` (runs Vitest).
## Project Snapshot
diff --git a/apps/mobile/.editorconfig b/apps/mobile/.editorconfig
new file mode 100644
index 00000000000..ab33c441a98
--- /dev/null
+++ b/apps/mobile/.editorconfig
@@ -0,0 +1,12 @@
+root = false
+
+[*.{kt,kts}]
+indent_size = 2
+ktlint_code_style = android_studio
+ktlint_standard_blank-line-between-when-conditions = disabled
+ktlint_standard_class-signature = disabled
+ktlint_standard_function-signature = disabled
+ktlint_standard_import-ordering = disabled
+ktlint_standard_multiline-expression-wrapping = disabled
+ktlint_standard_trailing-comma-on-call-site = disabled
+ktlint_standard_when-entry-bracing = disabled
diff --git a/apps/mobile/.gitignore b/apps/mobile/.gitignore
new file mode 100644
index 00000000000..d914c328fe1
--- /dev/null
+++ b/apps/mobile/.gitignore
@@ -0,0 +1,41 @@
+# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
+
+# dependencies
+node_modules/
+
+# Expo
+.expo/
+dist/
+web-build/
+expo-env.d.ts
+
+# Native
+.kotlin/
+*.orig.*
+*.jks
+*.p8
+*.p12
+*.key
+*.mobileprovision
+
+# Metro
+.metro-health-check*
+
+# debug
+npm-debug.*
+yarn-debug.*
+yarn-error.*
+
+# macOS
+.DS_Store
+*.pem
+
+# local env files
+.env*.local
+
+# typescript
+*.tsbuildinfo
+
+# generated native folders
+/ios
+/android
diff --git a/apps/mobile/.swiftlint.yml b/apps/mobile/.swiftlint.yml
new file mode 100644
index 00000000000..83fc429b731
--- /dev/null
+++ b/apps/mobile/.swiftlint.yml
@@ -0,0 +1,58 @@
+included:
+ - ios/T3Code
+ - modules/t3-terminal/ios
+ - modules/t3-review-diff/ios
+
+excluded:
+ - ios/Pods
+ - ios/build
+ - modules/t3-terminal/Vendor
+
+reporter: xcode
+
+disabled_rules:
+ - file_length
+ - function_body_length
+ - identifier_name
+ - line_length
+ - lower_acl_than_parent
+ - modifier_order
+ - trailing_comma
+ - type_body_length
+
+opt_in_rules:
+ - array_init
+ - closure_end_indentation
+ - closure_spacing
+ - collection_alignment
+ - contains_over_filter_count
+ - contains_over_filter_is_empty
+ - contains_over_first_not_nil
+ - contains_over_range_nil_comparison
+ - empty_collection_literal
+ - empty_count
+ - empty_string
+ - enum_case_associated_values_count
+ - fallthrough
+ - fatal_error_message
+ - first_where
+ - flatmap_over_map_reduce
+ - force_unwrapping
+ - implicitly_unwrapped_optional
+ - last_where
+ - legacy_multiple
+ - legacy_random
+ - operator_usage_whitespace
+ - overridden_super_call
+ - prefer_self_type_over_type_of_self
+ - private_action
+ - private_outlet
+ - prohibited_super_call
+ - reduce_into
+ - redundant_nil_coalescing
+ - sorted_first_last
+ - static_operator
+ - toggle_bool
+ - unneeded_parentheses_in_closure_argument
+ - vertical_parameter_alignment_on_call
+ - yoda_condition
diff --git a/apps/mobile/Brewfile b/apps/mobile/Brewfile
new file mode 100644
index 00000000000..7f11bfd49ee
--- /dev/null
+++ b/apps/mobile/Brewfile
@@ -0,0 +1,3 @@
+brew "swiftlint"
+brew "ktlint"
+brew "detekt"
diff --git a/apps/mobile/README.md b/apps/mobile/README.md
new file mode 100644
index 00000000000..bf86896f8d4
--- /dev/null
+++ b/apps/mobile/README.md
@@ -0,0 +1,81 @@
+# T3 Code Mobile
+
+> [!WARNING]
+> T3 Code Mobile is currently in development and is not distributed yet. If you want to try it out, you can build it from source.
+
+## Quickstart
+
+> [!NOTE]
+> Uses native modules so using Expo Go is not supported. You need to use the Expo Dev Client.
+
+This app has three variants:
+
+- `development`: Expo dev client, installable side-by-side as `T3 Code Dev`
+- `preview`: persistent internal preview build, installable side-by-side as `T3 Code Preview`
+- `production`: store/release build as `T3 Code`
+
+Run commands from `apps/mobile`.
+
+## Development
+
+Start Metro for the dev client:
+
+```bash
+bun run dev:client
+```
+
+Build and run the local iOS dev client:
+
+```bash
+bun run ios:dev
+```
+
+Build and run the local iOS preview app:
+
+```bash
+bun run ios:preview
+```
+
+Force the review diff highlighter engine:
+
+```bash
+EXPO_PUBLIC_REVIEW_HIGHLIGHTER_ENGINE=javascript bun run ios:dev
+```
+
+`javascript` is the default and recommended setting for the review diff screen. Set `EXPO_PUBLIC_REVIEW_HIGHLIGHTER_ENGINE=native` only when you explicitly want to test the native Shiki engine.
+
+Inspect the resolved Expo config for a variant:
+
+```bash
+bun run config:dev
+bun run config:preview
+```
+
+Run static checks for mobile native code:
+
+```bash
+node ../../scripts/mobile-native-static-check.ts
+```
+
+The native lint task runs SwiftLint for Swift plus ktlint and detekt for Kotlin. Missing native tools are reported as warnings and skipped locally. CI installs the default toolset from `apps/mobile/Brewfile` before running the native checks.
+
+## EAS Builds
+
+Create a cloud dev-client build:
+
+```bash
+bun run eas:ios:dev
+```
+
+Create a persistent preview build:
+
+```bash
+bun run eas:ios:preview
+```
+
+Android equivalents:
+
+```bash
+bun run eas:android:dev
+bun run eas:android:preview
+```
diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts
new file mode 100644
index 00000000000..e1f0ac431c6
--- /dev/null
+++ b/apps/mobile/app.config.ts
@@ -0,0 +1,147 @@
+import type { ExpoConfig } from "expo/config";
+
+type AppVariant = "development" | "preview" | "production";
+
+const APP_VARIANT = resolveAppVariant(process.env.APP_VARIANT);
+
+const VARIANT_CONFIG: Record<
+ AppVariant,
+ {
+ readonly appName: string;
+ readonly scheme: string;
+ readonly iosIcon: string;
+ readonly iosBundleIdentifier: string;
+ readonly androidPackage: string;
+ }
+> = {
+ development: {
+ appName: "T3 Code Dev",
+ scheme: "t3code-dev",
+ iosIcon: "./assets/icon-composer-dev.icon",
+ iosBundleIdentifier: "com.t3tools.t3code.dev",
+ androidPackage: "com.t3tools.t3code.dev",
+ },
+ preview: {
+ appName: "T3 Code Preview",
+ scheme: "t3code-preview",
+ iosIcon: "./assets/icon-composer-prod.icon",
+ iosBundleIdentifier: "com.t3tools.t3code.preview",
+ androidPackage: "com.t3tools.t3code.preview",
+ },
+ production: {
+ appName: "T3 Code",
+ scheme: "t3code",
+ iosIcon: "./assets/icon-composer-prod.icon",
+ iosBundleIdentifier: "com.t3tools.t3code",
+ androidPackage: "com.t3tools.t3code",
+ },
+};
+
+function resolveAppVariant(value: string | undefined): AppVariant {
+ switch (value) {
+ case "development":
+ case "preview":
+ case "production":
+ return value;
+ default:
+ return "production";
+ }
+}
+
+const variant = VARIANT_CONFIG[APP_VARIANT];
+const allowsCleartextTraffic = APP_VARIANT === "development";
+
+const config: ExpoConfig = {
+ name: variant.appName,
+ slug: "t3-code",
+ scheme: variant.scheme,
+ version: "0.1.0",
+ runtimeVersion: {
+ policy: "appVersion",
+ },
+ orientation: "portrait",
+ icon: "./assets/icon.png",
+ userInterfaceStyle: "automatic",
+ updates: {
+ enabled: true,
+ url: "https://u.expo.dev/d763fcb8-d37c-41ea-a773-b54a0ab4a454",
+ checkAutomatically: "ON_LOAD",
+ fallbackToCacheTimeout: 0,
+ },
+ splash: {
+ image: "./assets/splash-icon.png",
+ resizeMode: "contain",
+ backgroundColor: "#ffffff",
+ },
+ ios: {
+ icon: variant.iosIcon,
+ supportsTablet: true,
+ bundleIdentifier: variant.iosBundleIdentifier,
+ infoPlist: {
+ ...(allowsCleartextTraffic
+ ? {
+ NSAppTransportSecurity: {
+ NSAllowsArbitraryLoads: true,
+ },
+ }
+ : {}),
+ ITSAppUsesNonExemptEncryption: false,
+ },
+ },
+ android: {
+ icon: "./assets/icon.png",
+ package: variant.androidPackage,
+ adaptiveIcon: {
+ backgroundColor: "#E6F4FE",
+ foregroundImage: "./assets/android-icon-foreground.png",
+ backgroundImage: "./assets/android-icon-background.png",
+ monochromeImage: "./assets/android-icon-monochrome.png",
+ },
+ predictiveBackGestureEnabled: false,
+ },
+ web: {
+ favicon: "./assets/favicon.png",
+ },
+ plugins: [
+ [
+ "expo-camera",
+ {
+ cameraPermission: "Allow T3 Code to access your camera so you can scan pairing QR codes.",
+ barcodeScannerEnabled: true,
+ },
+ ],
+ [
+ "expo-splash-screen",
+ {
+ image: "./assets/splash-icon.png",
+ resizeMode: "contain",
+ backgroundColor: "#ffffff",
+ imageWidth: 220,
+ dark: {
+ image: "./assets/splash-icon.png",
+ backgroundColor: "#0a0a0a",
+ },
+ },
+ ],
+ [
+ "expo-build-properties",
+ {
+ ios: {
+ deploymentTarget: "16.1",
+ },
+ },
+ ],
+ "expo-secure-store",
+ "expo-router",
+ ...(allowsCleartextTraffic ? ["./plugins/withAndroidCleartextTraffic.cjs"] : []),
+ ],
+ extra: {
+ appVariant: APP_VARIANT,
+ eas: {
+ projectId: "d763fcb8-d37c-41ea-a773-b54a0ab4a454",
+ },
+ },
+ owner: "pingdotgg",
+};
+
+export default config;
diff --git a/apps/mobile/assets/android-icon-background.png b/apps/mobile/assets/android-icon-background.png
new file mode 100644
index 00000000000..b33d7978d72
Binary files /dev/null and b/apps/mobile/assets/android-icon-background.png differ
diff --git a/apps/mobile/assets/android-icon-foreground.png b/apps/mobile/assets/android-icon-foreground.png
new file mode 100644
index 00000000000..b33d7978d72
Binary files /dev/null and b/apps/mobile/assets/android-icon-foreground.png differ
diff --git a/apps/mobile/assets/android-icon-monochrome.png b/apps/mobile/assets/android-icon-monochrome.png
new file mode 100644
index 00000000000..b33d7978d72
Binary files /dev/null and b/apps/mobile/assets/android-icon-monochrome.png differ
diff --git a/apps/mobile/assets/favicon.png b/apps/mobile/assets/favicon.png
new file mode 100644
index 00000000000..e0e1b9659b8
Binary files /dev/null and b/apps/mobile/assets/favicon.png differ
diff --git a/apps/mobile/assets/icon-composer-dev.icon/Assets/T3.svg b/apps/mobile/assets/icon-composer-dev.icon/Assets/T3.svg
new file mode 100644
index 00000000000..b12706fdfc2
--- /dev/null
+++ b/apps/mobile/assets/icon-composer-dev.icon/Assets/T3.svg
@@ -0,0 +1,3 @@
+
diff --git a/apps/mobile/assets/icon-composer-dev.icon/Assets/Texturelabs_Paper_381XL.jpg b/apps/mobile/assets/icon-composer-dev.icon/Assets/Texturelabs_Paper_381XL.jpg
new file mode 100644
index 00000000000..d98c41a4e3e
Binary files /dev/null and b/apps/mobile/assets/icon-composer-dev.icon/Assets/Texturelabs_Paper_381XL.jpg differ
diff --git a/apps/mobile/assets/icon-composer-dev.icon/Assets/gpt-image-1.5-jd70szmrd03p36z4zv48ycsbax81egvr.png b/apps/mobile/assets/icon-composer-dev.icon/Assets/gpt-image-1.5-jd70szmrd03p36z4zv48ycsbax81egvr.png
new file mode 100644
index 00000000000..de5a82d8c49
Binary files /dev/null and b/apps/mobile/assets/icon-composer-dev.icon/Assets/gpt-image-1.5-jd70szmrd03p36z4zv48ycsbax81egvr.png differ
diff --git a/apps/mobile/assets/icon-composer-dev.icon/icon.json b/apps/mobile/assets/icon-composer-dev.icon/icon.json
new file mode 100644
index 00000000000..fd3fb6819a9
--- /dev/null
+++ b/apps/mobile/assets/icon-composer-dev.icon/icon.json
@@ -0,0 +1,45 @@
+{
+ "fill": {
+ "solid": "display-p3:0.00000,0.00000,0.00000,1.00000"
+ },
+ "groups": [
+ {
+ "layers": [
+ {
+ "hidden": false,
+ "image-name": "gpt-image-1.5-jd70szmrd03p36z4zv48ycsbax81egvr.png",
+ "name": "gpt-image-1.5-jd70szmrd03p36z4zv48ycsbax81egvr",
+ "position": {
+ "scale": 1.1,
+ "translation-in-points": [0, 0]
+ }
+ },
+ {
+ "image-name": "T3.svg",
+ "name": "T3",
+ "position": {
+ "scale": 10,
+ "translation-in-points": [0, 0]
+ }
+ },
+ {
+ "hidden": true,
+ "image-name": "Texturelabs_Paper_381XL.jpg",
+ "name": "Texturelabs_Paper_381XL"
+ }
+ ],
+ "shadow": {
+ "kind": "neutral",
+ "opacity": 0.5
+ },
+ "translucency": {
+ "enabled": true,
+ "value": 0.5
+ }
+ }
+ ],
+ "supported-platforms": {
+ "circles": ["watchOS"],
+ "squares": "shared"
+ }
+}
diff --git a/apps/mobile/assets/icon-composer-prod.icon/Assets/T3.svg b/apps/mobile/assets/icon-composer-prod.icon/Assets/T3.svg
new file mode 100644
index 00000000000..b12706fdfc2
--- /dev/null
+++ b/apps/mobile/assets/icon-composer-prod.icon/Assets/T3.svg
@@ -0,0 +1,3 @@
+
diff --git a/apps/mobile/assets/icon-composer-prod.icon/icon.json b/apps/mobile/assets/icon-composer-prod.icon/icon.json
new file mode 100644
index 00000000000..8f7579311f2
--- /dev/null
+++ b/apps/mobile/assets/icon-composer-prod.icon/icon.json
@@ -0,0 +1,31 @@
+{
+ "fill": {
+ "solid": "display-p3:0.00000,0.00000,0.00000,1.00000"
+ },
+ "groups": [
+ {
+ "layers": [
+ {
+ "image-name": "T3.svg",
+ "name": "T3",
+ "position": {
+ "scale": 10,
+ "translation-in-points": [0, 0]
+ }
+ }
+ ],
+ "shadow": {
+ "kind": "neutral",
+ "opacity": 0.5
+ },
+ "translucency": {
+ "enabled": true,
+ "value": 0.5
+ }
+ }
+ ],
+ "supported-platforms": {
+ "circles": ["watchOS"],
+ "squares": "shared"
+ }
+}
diff --git a/apps/mobile/assets/icon.png b/apps/mobile/assets/icon.png
new file mode 100644
index 00000000000..b33d6f337b0
Binary files /dev/null and b/apps/mobile/assets/icon.png differ
diff --git a/apps/mobile/assets/splash-icon.png b/apps/mobile/assets/splash-icon.png
new file mode 100644
index 00000000000..b33d6f337b0
Binary files /dev/null and b/apps/mobile/assets/splash-icon.png differ
diff --git a/apps/mobile/babel.config.js b/apps/mobile/babel.config.js
new file mode 100644
index 00000000000..5c85f81242a
--- /dev/null
+++ b/apps/mobile/babel.config.js
@@ -0,0 +1,6 @@
+module.exports = function (api) {
+ api.cache(true);
+ return {
+ presets: [["babel-preset-expo", { unstable_transformImportMeta: true }]],
+ };
+};
diff --git a/apps/mobile/deps/react-native-nitro-markdown-0.5.0.tgz b/apps/mobile/deps/react-native-nitro-markdown-0.5.0.tgz
new file mode 100644
index 00000000000..8fafc389681
Binary files /dev/null and b/apps/mobile/deps/react-native-nitro-markdown-0.5.0.tgz differ
diff --git a/apps/mobile/detekt.yml b/apps/mobile/detekt.yml
new file mode 100644
index 00000000000..0f65db659b7
--- /dev/null
+++ b/apps/mobile/detekt.yml
@@ -0,0 +1,19 @@
+config:
+ validation: true
+
+build:
+ maxIssues: 0
+
+complexity:
+ LongMethod:
+ active: true
+ threshold: 80
+ TooManyFunctions:
+ active: false
+
+style:
+ MagicNumber:
+ active: false
+ MaxLineLength:
+ active: true
+ maxLineLength: 120
diff --git a/apps/mobile/eas.json b/apps/mobile/eas.json
new file mode 100644
index 00000000000..cb0075613b8
--- /dev/null
+++ b/apps/mobile/eas.json
@@ -0,0 +1,37 @@
+{
+ "cli": {
+ "version": ">= 18.4.0",
+ "appVersionSource": "remote"
+ },
+ "build": {
+ "development": {
+ "env": {
+ "APP_VARIANT": "development"
+ },
+ "channel": "development",
+ "developmentClient": true,
+ "distribution": "internal"
+ },
+ "preview": {
+ "env": {
+ "APP_VARIANT": "preview"
+ },
+ "channel": "preview",
+ "distribution": "internal"
+ },
+ "production": {
+ "env": {
+ "APP_VARIANT": "production"
+ },
+ "channel": "production",
+ "autoIncrement": true
+ }
+ },
+ "submit": {
+ "production": {
+ "ios": {
+ "ascAppId": "6761315631"
+ }
+ }
+ }
+}
diff --git a/apps/mobile/global.css b/apps/mobile/global.css
new file mode 100644
index 00000000000..bbd1cb0be0a
--- /dev/null
+++ b/apps/mobile/global.css
@@ -0,0 +1,202 @@
+@import "tailwindcss";
+@import "uniwind";
+
+/* ─── Theme tokens ──────────────────────────────────────────────────── */
+@layer theme {
+ :root {
+ @variant light {
+ /* Page backgrounds */
+ --color-screen: #f2f2f7;
+ --color-sheet: rgba(242, 242, 247, 0.98);
+
+ /* Card / surface */
+ --color-card: #ffffff;
+ --color-card-alt: #f5f5f5;
+ --color-card-translucent: rgba(255, 255, 255, 0.94);
+
+ /* Text */
+ --color-foreground: #262626;
+ --color-foreground-secondary: #525252;
+ --color-foreground-muted: #737373;
+ --color-foreground-tertiary: #a3a3a3;
+
+ /* Borders & separators */
+ --color-border: rgba(0, 0, 0, 0.08);
+ --color-border-subtle: rgba(0, 0, 0, 0.06);
+ --color-separator: rgba(0, 0, 0, 0.04);
+
+ /* Subtle backgrounds (badges, pills, overlays) */
+ --color-subtle: rgba(0, 0, 0, 0.04);
+ --color-subtle-strong: rgba(0, 0, 0, 0.08);
+
+ /* Primary action */
+ --color-primary: #262626;
+ --color-primary-foreground: #ffffff;
+ --color-primary-shadow: rgba(0, 0, 0, 0.18);
+
+ /* Secondary action */
+ --color-secondary: #ffffff;
+ --color-secondary-foreground: #262626;
+ --color-secondary-border: rgba(0, 0, 0, 0.08);
+
+ /* Danger */
+ --color-danger: #fef2f2;
+ --color-danger-border: rgba(239, 68, 68, 0.12);
+ --color-danger-foreground: #dc2626;
+
+ /* Inputs */
+ --color-input: #ffffff;
+ --color-input-border: rgba(0, 0, 0, 0.1);
+ --color-placeholder: #a3a3a3;
+
+ /* Icons */
+ --color-icon: #262626;
+ --color-icon-muted: #525252;
+ --color-icon-subtle: #a3a3a3;
+
+ /* Header / glass chrome */
+ --color-header: rgba(255, 255, 255, 0.97);
+ --color-header-border: rgba(0, 0, 0, 0.06);
+
+ /* StatusBar */
+ --color-status-bar: #f2f2f7;
+
+ /* Markdown */
+ --color-md-body: #111111;
+ --color-md-strong: #000000;
+ --color-md-link: #2563eb;
+ --color-md-blockquote-border: rgba(0, 0, 0, 0.08);
+ --color-md-blockquote-bg: rgba(0, 0, 0, 0.02);
+ --color-md-code-bg: rgba(0, 0, 0, 0.04);
+ --color-md-code-text: #262626;
+ --color-md-user-code-bg: rgba(255, 255, 255, 0.22);
+ --color-md-user-code-text: #ffffff;
+ --color-md-user-fence-bg: rgba(0, 0, 0, 0.16);
+ --color-md-user-fence-text: #ffffff;
+ --color-md-hr: rgba(0, 0, 0, 0.08);
+
+ /* iMessage-style user bubble */
+ --color-user-bubble: #007aff;
+ --color-user-bubble-foreground: #ffffff;
+ --color-user-bubble-foreground-muted: rgba(255, 255, 255, 0.78);
+
+ /* Drawer / modal backdrop */
+ --color-backdrop: rgba(0, 0, 0, 0.22);
+ --color-drawer: rgba(255, 255, 255, 0.99);
+ --color-drawer-shadow: rgba(0, 0, 0, 0.12);
+
+ /* Misc */
+ --color-dot-separator: rgba(0, 0, 0, 0.2);
+ --color-wordmark: #262626;
+ --color-chevron: rgba(0, 0, 0, 0.2);
+ }
+
+ @variant dark {
+ /* Page backgrounds */
+ --color-screen: #0a0a0a;
+ --color-sheet: rgba(14, 14, 14, 0.98);
+
+ /* Card / surface */
+ --color-card: #171717;
+ --color-card-alt: #1c1c1c;
+ --color-card-translucent: rgba(17, 17, 17, 0.94);
+
+ /* Text */
+ --color-foreground: #f5f5f5;
+ --color-foreground-secondary: #a3a3a3;
+ --color-foreground-muted: #737373;
+ --color-foreground-tertiary: #525252;
+
+ /* Borders & separators */
+ --color-border: rgba(255, 255, 255, 0.06);
+ --color-border-subtle: rgba(255, 255, 255, 0.04);
+ --color-separator: rgba(255, 255, 255, 0.03);
+
+ /* Subtle backgrounds (badges, pills, overlays) */
+ --color-subtle: rgba(255, 255, 255, 0.04);
+ --color-subtle-strong: rgba(255, 255, 255, 0.08);
+
+ /* Primary action */
+ --color-primary: #f5f5f5;
+ --color-primary-foreground: #0a0a0a;
+ --color-primary-shadow: rgba(0, 0, 0, 0.22);
+
+ /* Secondary action */
+ --color-secondary: rgba(255, 255, 255, 0.04);
+ --color-secondary-foreground: #f5f5f5;
+ --color-secondary-border: rgba(255, 255, 255, 0.06);
+
+ /* Danger */
+ --color-danger: rgba(239, 68, 68, 0.14);
+ --color-danger-border: rgba(248, 113, 113, 0.18);
+ --color-danger-foreground: #fca5a5;
+
+ /* Inputs */
+ --color-input: #141414;
+ --color-input-border: rgba(255, 255, 255, 0.08);
+ --color-placeholder: #737373;
+
+ /* Icons */
+ --color-icon: #f5f5f5;
+ --color-icon-muted: #a3a3a3;
+ --color-icon-subtle: #737373;
+
+ /* Header / glass chrome */
+ --color-header: rgba(10, 10, 10, 0.97);
+ --color-header-border: rgba(255, 255, 255, 0.06);
+
+ /* StatusBar */
+ --color-status-bar: #0a0a0a;
+
+ /* Markdown */
+ --color-md-body: #e5e5e5;
+ --color-md-strong: #f5f5f5;
+ --color-md-link: #60a5fa;
+ --color-md-blockquote-border: rgba(255, 255, 255, 0.1);
+ --color-md-blockquote-bg: rgba(255, 255, 255, 0.03);
+ --color-md-code-bg: rgba(255, 255, 255, 0.06);
+ --color-md-code-text: #e5e5e5;
+ --color-md-user-code-bg: rgba(255, 255, 255, 0.18);
+ --color-md-user-code-text: #ffffff;
+ --color-md-user-fence-bg: rgba(0, 0, 0, 0.28);
+ --color-md-user-fence-text: #ffffff;
+ --color-md-hr: rgba(255, 255, 255, 0.08);
+
+ /* iMessage-style user bubble */
+ --color-user-bubble: #0a84ff;
+ --color-user-bubble-foreground: #ffffff;
+ --color-user-bubble-foreground-muted: rgba(255, 255, 255, 0.78);
+
+ /* Drawer / modal backdrop */
+ --color-backdrop: rgba(0, 0, 0, 0.48);
+ --color-drawer: rgba(14, 14, 14, 0.99);
+ --color-drawer-shadow: rgba(0, 0, 0, 0.32);
+
+ /* Misc */
+ --color-dot-separator: rgba(255, 255, 255, 0.2);
+ --color-wordmark: #f5f5f5;
+ --color-chevron: rgba(255, 255, 255, 0.2);
+ }
+ }
+}
+
+/* ─── Font family ───────────────────────────────────────────────────── */
+@theme {
+ --font-sans: "DMSans_400Regular";
+ --font-medium: "DMSans_500Medium";
+ --font-bold: "DMSans_700Bold";
+}
+
+/* ─── Custom utilities ──────────────────────────────────────────────── */
+@utility font-t3-medium {
+ font-family: "DMSans_500Medium";
+}
+
+@utility font-t3-bold {
+ font-family: "DMSans_700Bold";
+}
+
+@utility font-t3-extrabold {
+ font-family: "DMSans_700Bold";
+ font-weight: 800;
+}
diff --git a/apps/mobile/index.ts b/apps/mobile/index.ts
new file mode 100644
index 00000000000..642e1b77be9
--- /dev/null
+++ b/apps/mobile/index.ts
@@ -0,0 +1,2 @@
+import "react-native-gesture-handler";
+import "expo-router/entry";
diff --git a/apps/mobile/metro.config.js b/apps/mobile/metro.config.js
new file mode 100644
index 00000000000..d314f6206c6
--- /dev/null
+++ b/apps/mobile/metro.config.js
@@ -0,0 +1,45 @@
+const fs = require("node:fs");
+const path = require("node:path");
+const { getDefaultConfig } = require("expo/metro-config");
+const { withUniwindConfig } = require("uniwind/metro");
+
+/** @type {import("expo/metro-config").MetroConfig} */
+const config = getDefaultConfig(__dirname);
+const workspaceRoot = path.resolve(__dirname, "../..");
+const mobileShikiRoot = path.dirname(require.resolve("shiki/package.json", { paths: [__dirname] }));
+const resolveShikiDependencyRoot = (packageName) => {
+ const entryPath = require.resolve(packageName, { paths: [mobileShikiRoot] });
+ let currentDir = path.dirname(entryPath);
+
+ while (!fs.existsSync(path.join(currentDir, "package.json"))) {
+ const parentDir = path.dirname(currentDir);
+ if (parentDir === currentDir) {
+ throw new Error(`Could not resolve package root for ${packageName}`);
+ }
+ currentDir = parentDir;
+ }
+
+ return currentDir;
+};
+
+config.watchFolders = [...new Set([...(config.watchFolders ?? []), workspaceRoot])];
+config.resolver = {
+ ...config.resolver,
+ extraNodeModules: {
+ // oxlint-disable-next-line unicorn/no-useless-fallback-in-spread
+ ...(config.resolver?.extraNodeModules ?? {}),
+ shiki: mobileShikiRoot,
+ "@shikijs/core": resolveShikiDependencyRoot("@shikijs/core"),
+ "@shikijs/engine-javascript": resolveShikiDependencyRoot("@shikijs/engine-javascript"),
+ "@shikijs/engine-oniguruma": resolveShikiDependencyRoot("@shikijs/engine-oniguruma"),
+ "@shikijs/langs": resolveShikiDependencyRoot("@shikijs/langs"),
+ "@shikijs/themes": resolveShikiDependencyRoot("@shikijs/themes"),
+ "@shikijs/types": resolveShikiDependencyRoot("@shikijs/types"),
+ "@shikijs/vscode-textmate": resolveShikiDependencyRoot("@shikijs/vscode-textmate"),
+ },
+};
+
+module.exports = withUniwindConfig(config, {
+ cssEntryFile: "./global.css",
+ polyfills: { rem: 14 },
+});
diff --git a/apps/mobile/modules/t3-review-diff/T3ReviewDiffNative.podspec b/apps/mobile/modules/t3-review-diff/T3ReviewDiffNative.podspec
new file mode 100644
index 00000000000..e40ba073168
--- /dev/null
+++ b/apps/mobile/modules/t3-review-diff/T3ReviewDiffNative.podspec
@@ -0,0 +1,19 @@
+require 'json'
+
+package = JSON.parse(File.read(File.join(__dir__, 'package.json')))
+
+Pod::Spec.new do |s|
+ s.name = 'T3ReviewDiffNative'
+ s.version = package['version']
+ s.summary = 'Native review diff debug surface for T3 Code mobile.'
+ s.description = 'Native iOS review diff renderer used to prototype fast mobile review scrolling.'
+ s.homepage = 'https://t3tools.com'
+ s.license = { :type => 'UNLICENSED' }
+ s.author = { 'T3 Tools' => 'hello@t3tools.com' }
+ s.platforms = { :ios => '16.1' }
+ s.source = { :path => '.' }
+ s.source_files = 'ios/**/*.{h,m,mm,swift}'
+ s.frameworks = 'CoreGraphics', 'UIKit'
+ s.swift_version = '5.9'
+ s.dependency 'ExpoModulesCore'
+end
diff --git a/apps/mobile/modules/t3-review-diff/expo-module.config.json b/apps/mobile/modules/t3-review-diff/expo-module.config.json
new file mode 100644
index 00000000000..fe6b11b649c
--- /dev/null
+++ b/apps/mobile/modules/t3-review-diff/expo-module.config.json
@@ -0,0 +1,7 @@
+{
+ "platforms": ["apple"],
+ "apple": {
+ "modules": ["T3ReviewDiffModule"],
+ "podspecPath": "T3ReviewDiffNative.podspec"
+ }
+}
diff --git a/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffModule.swift b/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffModule.swift
new file mode 100644
index 00000000000..f196716599b
--- /dev/null
+++ b/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffModule.swift
@@ -0,0 +1,63 @@
+import ExpoModulesCore
+
+public class T3ReviewDiffModule: Module {
+ public func definition() -> ModuleDefinition {
+ Name("T3ReviewDiffSurface")
+
+ View(T3ReviewDiffView.self) {
+ Prop("rowsJson") { (view: T3ReviewDiffView, rowsJson: String) in
+ view.setRowsJson(rowsJson)
+ }
+
+ Prop("tokensJson") { (view: T3ReviewDiffView, tokensJson: String) in
+ view.setTokensJson(tokensJson)
+ }
+
+ Prop("tokensPatchJson") { (view: T3ReviewDiffView, tokensPatchJson: String) in
+ view.setTokensPatchJson(tokensPatchJson)
+ }
+
+ Prop("tokensResetKey") { (view: T3ReviewDiffView, tokensResetKey: String) in
+ view.setTokensResetKey(tokensResetKey)
+ }
+
+ Prop("collapsedFileIdsJson") { (view: T3ReviewDiffView, collapsedFileIdsJson: String) in
+ view.setCollapsedFileIdsJson(collapsedFileIdsJson)
+ }
+
+ Prop("viewedFileIdsJson") { (view: T3ReviewDiffView, viewedFileIdsJson: String) in
+ view.setViewedFileIdsJson(viewedFileIdsJson)
+ }
+
+ Prop("selectedRowIdsJson") { (view: T3ReviewDiffView, selectedRowIdsJson: String) in
+ view.setSelectedRowIdsJson(selectedRowIdsJson)
+ }
+
+ Prop("collapsedCommentIdsJson") { (view: T3ReviewDiffView, collapsedCommentIdsJson: String) in
+ view.setCollapsedCommentIdsJson(collapsedCommentIdsJson)
+ }
+
+ Prop("appearanceScheme") { (view: T3ReviewDiffView, appearanceScheme: String) in
+ view.setAppearanceScheme(appearanceScheme)
+ }
+
+ Prop("themeJson") { (view: T3ReviewDiffView, themeJson: String) in
+ view.setThemeJson(themeJson)
+ }
+
+ Prop("styleJson") { (view: T3ReviewDiffView, styleJson: String) in
+ view.setStyleJson(styleJson)
+ }
+
+ Prop("rowHeight") { (view: T3ReviewDiffView, rowHeight: Double) in
+ view.setRowHeight(CGFloat(rowHeight))
+ }
+
+ Prop("contentWidth") { (view: T3ReviewDiffView, contentWidth: Double) in
+ view.setContentWidth(CGFloat(contentWidth))
+ }
+
+ Events("onDebug", "onToggleFile", "onToggleViewedFile", "onPressLine", "onToggleComment")
+ }
+ }
+}
diff --git a/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift b/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift
new file mode 100644
index 00000000000..a4b5e57d667
--- /dev/null
+++ b/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift
@@ -0,0 +1,2311 @@
+import ExpoModulesCore
+import UIKit
+
+private struct ReviewDiffNativeRow: Decodable {
+ let kind: String
+ let id: String
+ let fileId: String?
+ let filePath: String?
+ let previousPath: String?
+ let changeType: String?
+ let additions: Int?
+ let deletions: Int?
+ let text: String?
+ let content: String?
+ let change: String?
+ let oldLineNumber: Int?
+ let newLineNumber: Int?
+ let wordDiffRanges: [ReviewDiffNativeWordDiffRange]?
+ let commentText: String?
+ let commentRangeLabel: String?
+ let commentSectionTitle: String?
+}
+
+private struct ReviewDiffNativeWordDiffRange: Decodable {
+ let start: Int
+ let end: Int
+}
+
+private struct ReviewDiffNativeToken: Decodable {
+ let content: String
+ let color: String?
+ let fontStyle: Int?
+}
+
+private struct ReviewDiffNativeTokenPatch: Decodable {
+ let resetKey: String?
+ let chunkIndex: Int?
+ let tokensByRowId: [String: [ReviewDiffNativeToken]]?
+}
+
+private struct ReviewDiffNativeThemePayload: Decodable {
+ let background: String?
+ let text: String?
+ let mutedText: String?
+ let headerBackground: String?
+ let border: String?
+ let hunkBackground: String?
+ let hunkText: String?
+ let addBackground: String?
+ let deleteBackground: String?
+ let addBar: String?
+ let deleteBar: String?
+ let addText: String?
+ let deleteText: String?
+}
+
+private struct ReviewDiffNativeTheme {
+ let background: UIColor
+ let text: UIColor
+ let mutedText: UIColor
+ let headerBackground: UIColor
+ let border: UIColor
+ let hunkBackground: UIColor
+ let hunkText: UIColor
+ let addBackground: UIColor
+ let deleteBackground: UIColor
+ let addBar: UIColor
+ let deleteBar: UIColor
+ let addText: UIColor
+ let deleteText: UIColor
+
+ static func resolve(_ scheme: String) -> ReviewDiffNativeTheme {
+ resolve(scheme, payload: nil)
+ }
+
+ static func resolve(
+ _ scheme: String,
+ payload: ReviewDiffNativeThemePayload?
+ ) -> ReviewDiffNativeTheme {
+ let fallback = fallback(scheme)
+ guard let payload else {
+ return fallback
+ }
+
+ return ReviewDiffNativeTheme(
+ background: UIColor(reviewDiffHex: payload.background) ?? fallback.background,
+ text: UIColor(reviewDiffHex: payload.text) ?? fallback.text,
+ mutedText: UIColor(reviewDiffHex: payload.mutedText) ?? fallback.mutedText,
+ headerBackground: UIColor(reviewDiffHex: payload.headerBackground) ?? fallback.headerBackground,
+ border: UIColor(reviewDiffHex: payload.border) ?? fallback.border,
+ hunkBackground: UIColor(reviewDiffHex: payload.hunkBackground) ?? fallback.hunkBackground,
+ hunkText: UIColor(reviewDiffHex: payload.hunkText) ?? fallback.hunkText,
+ addBackground: UIColor(reviewDiffHex: payload.addBackground) ?? fallback.addBackground,
+ deleteBackground: UIColor(reviewDiffHex: payload.deleteBackground) ?? fallback.deleteBackground,
+ addBar: UIColor(reviewDiffHex: payload.addBar) ?? fallback.addBar,
+ deleteBar: UIColor(reviewDiffHex: payload.deleteBar) ?? fallback.deleteBar,
+ addText: UIColor(reviewDiffHex: payload.addText) ?? fallback.addText,
+ deleteText: UIColor(reviewDiffHex: payload.deleteText) ?? fallback.deleteText
+ )
+ }
+
+ private static func fallback(_ scheme: String) -> ReviewDiffNativeTheme {
+ if scheme == "dark" {
+ return ReviewDiffNativeTheme(
+ background: UIColor(red: 0.07, green: 0.07, blue: 0.07, alpha: 1),
+ text: UIColor(red: 0.90, green: 0.90, blue: 0.90, alpha: 1),
+ mutedText: UIColor(red: 0.52, green: 0.52, blue: 0.52, alpha: 1),
+ headerBackground: UIColor(red: 0.10, green: 0.10, blue: 0.10, alpha: 1),
+ border: UIColor(red: 0.16, green: 0.16, blue: 0.16, alpha: 1),
+ hunkBackground: UIColor(red: 0.03, green: 0.14, blue: 0.18, alpha: 1),
+ hunkText: UIColor(red: 0.41, green: 0.82, blue: 1.00, alpha: 1),
+ addBackground: UIColor(red: 0.02, green: 0.16, blue: 0.10, alpha: 1),
+ deleteBackground: UIColor(red: 0.18, green: 0.05, blue: 0.08, alpha: 1),
+ addBar: UIColor(red: 0.02, green: 0.82, blue: 0.54, alpha: 1),
+ deleteBar: UIColor(red: 1.00, green: 0.36, blue: 0.50, alpha: 1),
+ addText: UIColor(red: 0.10, green: 0.84, blue: 0.56, alpha: 1),
+ deleteText: UIColor(red: 1.00, green: 0.38, blue: 0.52, alpha: 1)
+ )
+ }
+
+ return ReviewDiffNativeTheme(
+ background: UIColor.white,
+ text: UIColor(red: 0.16, green: 0.16, blue: 0.16, alpha: 1),
+ mutedText: UIColor(red: 0.47, green: 0.47, blue: 0.47, alpha: 1),
+ headerBackground: UIColor.white,
+ border: UIColor(red: 0.88, green: 0.88, blue: 0.90, alpha: 1),
+ hunkBackground: UIColor(red: 0.83, green: 0.92, blue: 0.98, alpha: 1),
+ hunkText: UIColor(red: 0.00, green: 0.45, blue: 0.74, alpha: 1),
+ addBackground: UIColor(red: 0.85, green: 0.94, blue: 0.92, alpha: 1),
+ deleteBackground: UIColor(red: 0.96, green: 0.85, blue: 0.90, alpha: 1),
+ addBar: UIColor(red: 0.02, green: 0.80, blue: 0.52, alpha: 1),
+ deleteBar: UIColor(red: 1.00, green: 0.34, blue: 0.48, alpha: 1),
+ addText: UIColor(red: 0.00, green: 0.56, blue: 0.34, alpha: 1),
+ deleteText: UIColor(red: 0.90, green: 0.10, blue: 0.24, alpha: 1)
+ )
+ }
+}
+
+private struct ReviewDiffNativeStylePayload: Decodable {
+ let rowHeight: Double?
+ let contentWidth: Double?
+ let changeBarWidth: Double?
+ let gutterWidth: Double?
+ let codePadding: Double?
+ let textVerticalInset: Double?
+ let fileHeaderHeight: Double?
+ let fileHeaderHorizontalMargin: Double?
+ let fileHeaderVerticalMargin: Double?
+ let fileHeaderCornerRadius: Double?
+ let fileHeaderHorizontalPadding: Double?
+ let fileHeaderPathRightPadding: Double?
+ let fileHeaderCountColumnWidth: Double?
+ let fileHeaderCountGap: Double?
+ let codeFontSize: Double?
+ let codeFontWeight: String?
+ let lineNumberFontSize: Double?
+ let lineNumberFontWeight: String?
+ let hunkFontSize: Double?
+ let hunkFontWeight: String?
+ let fileHeaderFontSize: Double?
+ let fileHeaderFontWeight: String?
+ let fileHeaderMetaFontSize: Double?
+ let fileHeaderMetaFontWeight: String?
+ let fileHeaderSubtextFontSize: Double?
+ let fileHeaderSubtextFontWeight: String?
+ let fileHeaderStatusFontSize: Double?
+ let fileHeaderStatusFontWeight: String?
+ let emptyStateFontSize: Double?
+ let emptyStateFontWeight: String?
+}
+
+private struct ReviewDiffNativeStyle {
+ let rowHeight: CGFloat
+ let contentWidth: CGFloat
+ let changeBarWidth: CGFloat
+ let gutterWidth: CGFloat
+ let codePadding: CGFloat
+ let textVerticalInset: CGFloat
+ let fileHeaderHeight: CGFloat
+ let fileHeaderHorizontalMargin: CGFloat
+ let fileHeaderVerticalMargin: CGFloat
+ let fileHeaderCornerRadius: CGFloat
+ let fileHeaderHorizontalPadding: CGFloat
+ let fileHeaderPathRightPadding: CGFloat
+ let fileHeaderCountColumnWidth: CGFloat
+ let fileHeaderCountGap: CGFloat
+ let codeFontSize: CGFloat
+ let codeFontWeight: UIFont.Weight
+ let lineNumberFontSize: CGFloat
+ let lineNumberFontWeight: UIFont.Weight
+ let hunkFontSize: CGFloat
+ let hunkFontWeight: UIFont.Weight
+ let fileHeaderFontSize: CGFloat
+ let fileHeaderFontWeight: UIFont.Weight
+ let fileHeaderMetaFontSize: CGFloat
+ let fileHeaderMetaFontWeight: UIFont.Weight
+ let fileHeaderSubtextFontSize: CGFloat
+ let fileHeaderSubtextFontWeight: UIFont.Weight
+ let fileHeaderStatusFontSize: CGFloat
+ let fileHeaderStatusFontWeight: UIFont.Weight
+ let emptyStateFontSize: CGFloat
+ let emptyStateFontWeight: UIFont.Weight
+
+ static func resolve(_ payload: ReviewDiffNativeStylePayload?) -> ReviewDiffNativeStyle {
+ ReviewDiffNativeStyle(
+ rowHeight: metric(payload?.rowHeight, fallback: 24),
+ contentWidth: metric(payload?.contentWidth, fallback: 2800),
+ changeBarWidth: metric(payload?.changeBarWidth, fallback: 4),
+ gutterWidth: metric(payload?.gutterWidth, fallback: 50),
+ codePadding: metric(payload?.codePadding, fallback: 8),
+ textVerticalInset: metric(payload?.textVerticalInset, fallback: 3),
+ fileHeaderHeight: metric(payload?.fileHeaderHeight, fallback: 54),
+ fileHeaderHorizontalMargin: metric(payload?.fileHeaderHorizontalMargin, fallback: 8),
+ fileHeaderVerticalMargin: metric(payload?.fileHeaderVerticalMargin, fallback: 6),
+ fileHeaderCornerRadius: metric(payload?.fileHeaderCornerRadius, fallback: 10),
+ fileHeaderHorizontalPadding: metric(payload?.fileHeaderHorizontalPadding, fallback: 12),
+ fileHeaderPathRightPadding: metric(payload?.fileHeaderPathRightPadding, fallback: 128),
+ fileHeaderCountColumnWidth: metric(payload?.fileHeaderCountColumnWidth, fallback: 42),
+ fileHeaderCountGap: metric(payload?.fileHeaderCountGap, fallback: 6),
+ codeFontSize: metric(payload?.codeFontSize, fallback: 12),
+ codeFontWeight: fontWeight(payload?.codeFontWeight, fallback: .bold),
+ lineNumberFontSize: metric(payload?.lineNumberFontSize, fallback: 11),
+ lineNumberFontWeight: fontWeight(payload?.lineNumberFontWeight, fallback: .bold),
+ hunkFontSize: metric(payload?.hunkFontSize, fallback: 12),
+ hunkFontWeight: fontWeight(payload?.hunkFontWeight, fallback: .bold),
+ fileHeaderFontSize: metric(payload?.fileHeaderFontSize, fallback: 13),
+ fileHeaderFontWeight: fontWeight(payload?.fileHeaderFontWeight, fallback: .bold),
+ fileHeaderMetaFontSize: metric(payload?.fileHeaderMetaFontSize, fallback: 12),
+ fileHeaderMetaFontWeight: fontWeight(payload?.fileHeaderMetaFontWeight, fallback: .bold),
+ fileHeaderSubtextFontSize: metric(payload?.fileHeaderSubtextFontSize, fallback: 11),
+ fileHeaderSubtextFontWeight: fontWeight(payload?.fileHeaderSubtextFontWeight, fallback: .medium),
+ fileHeaderStatusFontSize: metric(payload?.fileHeaderStatusFontSize, fallback: 10),
+ fileHeaderStatusFontWeight: fontWeight(payload?.fileHeaderStatusFontWeight, fallback: .semibold),
+ emptyStateFontSize: metric(payload?.emptyStateFontSize, fallback: 13),
+ emptyStateFontWeight: fontWeight(payload?.emptyStateFontWeight, fallback: .semibold)
+ )
+ }
+
+ private static func metric(_ value: Double?, fallback: CGFloat) -> CGFloat {
+ guard let value, value.isFinite, value > 0 else {
+ return fallback
+ }
+ return CGFloat(value)
+ }
+
+ private static func fontWeight(_ value: String?, fallback: UIFont.Weight) -> UIFont.Weight {
+ switch value?.lowercased() {
+ case "ultralight", "ultra-light":
+ return .ultraLight
+ case "thin":
+ return .thin
+ case "light":
+ return .light
+ case "regular":
+ return .regular
+ case "medium":
+ return .medium
+ case "semibold", "semi-bold":
+ return .semibold
+ case "bold":
+ return .bold
+ case "heavy":
+ return .heavy
+ case "black":
+ return .black
+ default:
+ return fallback
+ }
+ }
+
+ func applyingOverrides(rowHeight: CGFloat?, contentWidth: CGFloat?) -> ReviewDiffNativeStyle {
+ ReviewDiffNativeStyle(
+ rowHeight: rowHeight ?? self.rowHeight,
+ contentWidth: contentWidth ?? self.contentWidth,
+ changeBarWidth: changeBarWidth,
+ gutterWidth: gutterWidth,
+ codePadding: codePadding,
+ textVerticalInset: textVerticalInset,
+ fileHeaderHeight: fileHeaderHeight,
+ fileHeaderHorizontalMargin: fileHeaderHorizontalMargin,
+ fileHeaderVerticalMargin: fileHeaderVerticalMargin,
+ fileHeaderCornerRadius: fileHeaderCornerRadius,
+ fileHeaderHorizontalPadding: fileHeaderHorizontalPadding,
+ fileHeaderPathRightPadding: fileHeaderPathRightPadding,
+ fileHeaderCountColumnWidth: fileHeaderCountColumnWidth,
+ fileHeaderCountGap: fileHeaderCountGap,
+ codeFontSize: codeFontSize,
+ codeFontWeight: codeFontWeight,
+ lineNumberFontSize: lineNumberFontSize,
+ lineNumberFontWeight: lineNumberFontWeight,
+ hunkFontSize: hunkFontSize,
+ hunkFontWeight: hunkFontWeight,
+ fileHeaderFontSize: fileHeaderFontSize,
+ fileHeaderFontWeight: fileHeaderFontWeight,
+ fileHeaderMetaFontSize: fileHeaderMetaFontSize,
+ fileHeaderMetaFontWeight: fileHeaderMetaFontWeight,
+ fileHeaderSubtextFontSize: fileHeaderSubtextFontSize,
+ fileHeaderSubtextFontWeight: fileHeaderSubtextFontWeight,
+ fileHeaderStatusFontSize: fileHeaderStatusFontSize,
+ fileHeaderStatusFontWeight: fileHeaderStatusFontWeight,
+ emptyStateFontSize: emptyStateFontSize,
+ emptyStateFontWeight: emptyStateFontWeight
+ )
+ }
+}
+
+public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate {
+ private let scrollView = UIScrollView()
+ private let contentView = ReviewDiffContentView()
+ private var rows: [ReviewDiffNativeRow] = []
+ private var appearanceScheme: String = "light"
+ private var themePayload: ReviewDiffNativeThemePayload?
+ private var stylePayload: ReviewDiffNativeStylePayload?
+ private var rowHeightOverride: CGFloat?
+ private var contentWidthOverride: CGFloat?
+ private var lastMetricsDebugKey = ""
+ private var lastVisibleRangeDebugKey = ""
+ private var tokensResetKey = ""
+
+ let onDebug = EventDispatcher()
+ let onToggleFile = EventDispatcher()
+ let onToggleViewedFile = EventDispatcher()
+ let onPressLine = EventDispatcher()
+ let onToggleComment = EventDispatcher()
+
+ public required init(appContext: AppContext? = nil) {
+ super.init(appContext: appContext)
+
+ clipsToBounds = true
+ backgroundColor = contentView.theme.background
+ scrollView.contentInsetAdjustmentBehavior = .never
+ scrollView.delegate = self
+ scrollView.clipsToBounds = true
+ scrollView.alwaysBounceVertical = true
+ scrollView.alwaysBounceHorizontal = false
+ scrollView.showsVerticalScrollIndicator = true
+ scrollView.showsHorizontalScrollIndicator = false
+ scrollView.backgroundColor = contentView.theme.background
+ addSubview(scrollView)
+
+ contentView.backgroundColor = contentView.theme.background
+ contentView.onToggleFile = { [weak self] fileId in
+ self?.onToggleFile(["fileId": fileId])
+ }
+ contentView.onToggleViewedFile = { [weak self] fileId in
+ self?.onToggleViewedFile(["fileId": fileId])
+ }
+ contentView.onPressLine = { [weak self] payload in
+ self?.onPressLine(payload)
+ }
+ contentView.onToggleComment = { [weak self] commentId in
+ self?.onToggleComment(["commentId": commentId])
+ }
+ contentView.onDrawMetrics = { [weak self] metrics in
+ self?.emitDebug("draw-metrics", metrics)
+ }
+ scrollView.addSubview(contentView)
+ }
+
+ public override func layoutSubviews() {
+ super.layoutSubviews()
+ scrollView.frame = bounds
+ updateContentMetrics()
+ }
+
+ public func scrollViewDidScroll(_ scrollView: UIScrollView) {
+ if scrollView.isTracking || scrollView.isDragging || scrollView.isDecelerating {
+ contentView.isVerticalScrollActive = true
+ }
+ updateViewportFrame()
+ }
+
+ public func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
+ guard !decelerate else {
+ return
+ }
+
+ finishVerticalScroll()
+ }
+
+ public func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
+ finishVerticalScroll()
+ }
+
+ public func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) {
+ finishVerticalScroll()
+ }
+
+ func setRowsJson(_ rowsJson: String) {
+ guard let data = rowsJson.data(using: .utf8) else {
+ return
+ }
+
+ do {
+ rows = try JSONDecoder().decode([ReviewDiffNativeRow].self, from: data)
+ contentView.rows = rows
+ emitDebug("rows-decoded", [
+ "rows": rows.count,
+ "firstKind": rows.first?.kind ?? "none",
+ ])
+ updateContentMetrics()
+ } catch {
+ rows = []
+ contentView.rows = []
+ updateContentMetrics()
+ emitDebug("rows-decode-failed", [
+ "error": error.localizedDescription,
+ ])
+ }
+ }
+
+ func setTokensJson(_ tokensJson: String) {
+ guard let data = tokensJson.data(using: .utf8) else {
+ return
+ }
+
+ do {
+ contentView.tokensByRowId = try JSONDecoder().decode(
+ [String: [ReviewDiffNativeToken]].self,
+ from: data
+ )
+ } catch {
+ contentView.tokensByRowId = [:]
+ emitDebug("tokens-decode-failed", [
+ "error": error.localizedDescription,
+ ])
+ }
+ }
+
+ func setTokensPatchJson(_ tokensPatchJson: String) {
+ guard let data = tokensPatchJson.data(using: .utf8) else {
+ return
+ }
+
+ do {
+ let patch = try JSONDecoder().decode(ReviewDiffNativeTokenPatch.self, from: data)
+ if let resetKey = patch.resetKey, resetKey != tokensResetKey {
+ tokensResetKey = resetKey
+ contentView.tokensByRowId = [:]
+ }
+
+ let tokensByRowId = patch.tokensByRowId ?? [:]
+ if tokensByRowId.isEmpty {
+ return
+ }
+
+ contentView.mergeTokensByRowId(tokensByRowId)
+ if let chunkIndex = patch.chunkIndex, chunkIndex < 5 || chunkIndex.isMultiple(of: 10) {
+ emitDebug("tokens-patch-decoded", [
+ "chunkIndex": chunkIndex,
+ "rows": tokensByRowId.count,
+ "totalRows": contentView.tokensByRowId.count,
+ ])
+ }
+ } catch {
+ emitDebug("tokens-patch-decode-failed", [
+ "error": error.localizedDescription,
+ ])
+ }
+ }
+
+ func setTokensResetKey(_ tokensResetKey: String) {
+ guard tokensResetKey != self.tokensResetKey else {
+ return
+ }
+
+ self.tokensResetKey = tokensResetKey
+ contentView.tokensByRowId = [:]
+ emitDebug("tokens-reset", [
+ "resetKey": tokensResetKey,
+ ])
+ }
+
+ func setCollapsedFileIdsJson(_ collapsedFileIdsJson: String) {
+ let nextCollapsedFileIds = decodeFileIdSet(collapsedFileIdsJson)
+ let changedFileIds = contentView.collapsedFileIds.symmetricDifference(nextCollapsedFileIds)
+ let scrollAnchor: ReviewDiffScrollAnchor?
+ if changedFileIds.count == 1, let changedFileId = changedFileIds.first {
+ scrollAnchor = contentView.scrollAnchor(forFileId: changedFileId)
+ } else {
+ scrollAnchor = nil
+ }
+
+ contentView.collapsedFileIds = nextCollapsedFileIds
+ updateContentMetrics()
+
+ if let scrollAnchor,
+ let headerOffset = contentView.fileHeaderOffset(forFileId: scrollAnchor.fileId) {
+ let targetOffset = headerOffset - scrollAnchor.screenY
+ let maxOffset = max(scrollView.contentSize.height - scrollView.bounds.height, 0)
+ let clampedOffset = min(max(targetOffset, 0), maxOffset)
+ scrollView.setContentOffset(CGPoint(x: 0, y: clampedOffset), animated: false)
+ updateViewportFrame()
+ }
+ }
+
+ func setViewedFileIdsJson(_ viewedFileIdsJson: String) {
+ contentView.viewedFileIds = decodeFileIdSet(viewedFileIdsJson)
+ }
+
+ func setSelectedRowIdsJson(_ selectedRowIdsJson: String) {
+ contentView.selectedRowIds = decodeFileIdSet(selectedRowIdsJson)
+ }
+
+ func setCollapsedCommentIdsJson(_ collapsedCommentIdsJson: String) {
+ contentView.collapsedCommentIds = decodeFileIdSet(collapsedCommentIdsJson)
+ updateContentMetrics()
+ }
+
+ private func decodeFileIdSet(_ json: String) -> Set {
+ guard let data = json.data(using: .utf8) else {
+ return []
+ }
+
+ do {
+ return Set(try JSONDecoder().decode([String].self, from: data))
+ } catch {
+ emitDebug("file-id-set-decode-failed", [
+ "error": error.localizedDescription,
+ ])
+ return []
+ }
+ }
+
+ func setAppearanceScheme(_ appearanceScheme: String) {
+ self.appearanceScheme = appearanceScheme
+ applyTheme()
+ }
+
+ func setThemeJson(_ themeJson: String) {
+ guard let data = themeJson.data(using: .utf8) else {
+ themePayload = nil
+ applyTheme()
+ return
+ }
+
+ do {
+ themePayload = try JSONDecoder().decode(ReviewDiffNativeThemePayload.self, from: data)
+ } catch {
+ themePayload = nil
+ emitDebug("theme-decode-failed", [
+ "error": error.localizedDescription,
+ ])
+ }
+
+ applyTheme()
+ }
+
+ private func updateContentMetrics() {
+ let style = contentView.style
+ let height = max(bounds.height, contentView.contentHeight)
+ let width = bounds.width
+ scrollView.contentSize = CGSize(width: bounds.width, height: height)
+ contentView.frame = CGRect(
+ x: 0,
+ y: scrollView.contentOffset.y,
+ width: max(width, 1),
+ height: max(bounds.height, 1)
+ )
+ contentView.viewportWidth = bounds.width
+ contentView.verticalOffset = scrollView.contentOffset.y
+ contentView.invalidateVisibleViewport()
+ contentView.setNeedsDisplay()
+
+ let debugKey = "\(rows.count):\(Int(bounds.width)):\(Int(bounds.height)):\(Int(height))"
+ if debugKey != lastMetricsDebugKey {
+ lastMetricsDebugKey = debugKey
+ emitDebug("metrics", [
+ "rows": rows.count,
+ "boundsWidth": Double(bounds.width),
+ "boundsHeight": Double(bounds.height),
+ "contentHeight": Double(height),
+ "contentWidth": Double(style.contentWidth),
+ "fileHeaderHeight": Double(style.fileHeaderHeight),
+ "rowHeight": Double(style.rowHeight),
+ ])
+ }
+ }
+
+ private func emitDebug(_ message: String, _ details: [String: Any]) {
+ var payload = details
+ payload["message"] = message
+ onDebug(payload)
+ }
+
+ private func finishVerticalScroll() {
+ contentView.isVerticalScrollActive = false
+ updateViewportFrame()
+ emitVisibleRange(reason: "scroll-end")
+ }
+
+ private func emitVisibleRange(reason: String) {
+ guard let range = contentView.currentVisibleRowRange() else {
+ return
+ }
+
+ let debugKey = "\(range.firstRowIndex):\(range.lastRowIndex):\(Int(scrollView.bounds.height))"
+ guard debugKey != lastVisibleRangeDebugKey else {
+ return
+ }
+
+ lastVisibleRangeDebugKey = debugKey
+ emitDebug("visible-range", [
+ "reason": reason,
+ "firstRowIndex": range.firstRowIndex,
+ "lastRowIndex": range.lastRowIndex,
+ "totalRows": rows.count,
+ ])
+ }
+
+ private func applyTheme() {
+ contentView.theme = ReviewDiffNativeTheme.resolve(appearanceScheme, payload: themePayload)
+ backgroundColor = contentView.theme.background
+ scrollView.backgroundColor = contentView.theme.background
+ contentView.backgroundColor = contentView.theme.background
+ contentView.invalidateVisibleViewport()
+ }
+
+ func setStyleJson(_ styleJson: String) {
+ guard let data = styleJson.data(using: .utf8) else {
+ stylePayload = nil
+ applyStyle()
+ return
+ }
+
+ do {
+ stylePayload = try JSONDecoder().decode(ReviewDiffNativeStylePayload.self, from: data)
+ } catch {
+ stylePayload = nil
+ emitDebug("style-decode-failed", [
+ "error": error.localizedDescription,
+ ])
+ }
+
+ applyStyle()
+ }
+
+ func setRowHeight(_ rowHeight: CGFloat) {
+ rowHeightOverride = rowHeight.isFinite && rowHeight > 0 ? rowHeight : nil
+ applyStyle()
+ }
+
+ func setContentWidth(_ contentWidth: CGFloat) {
+ contentWidthOverride = contentWidth.isFinite && contentWidth > 0 ? contentWidth : nil
+ applyStyle()
+ }
+
+ private func applyStyle() {
+ contentView.style = ReviewDiffNativeStyle
+ .resolve(stylePayload)
+ .applyingOverrides(rowHeight: rowHeightOverride, contentWidth: contentWidthOverride)
+ updateContentMetrics()
+ }
+
+ private func updateViewportFrame() {
+ contentView.frame = CGRect(
+ x: 0,
+ y: scrollView.contentOffset.y,
+ width: max(bounds.width, 1),
+ height: max(bounds.height, 1)
+ )
+ contentView.verticalOffset = scrollView.contentOffset.y
+ contentView.invalidateVisibleViewport()
+ }
+}
+
+private enum ReviewDiffHorizontalPanKind {
+ case code
+ case fileHeaderPath
+}
+
+private struct ReviewDiffFileHeaderPathLayout {
+ let displayPath: String
+ let rect: CGRect
+}
+
+private struct ReviewDiffFileHeaderInteractiveRects {
+ let chevron: CGRect
+ let icon: CGRect
+ let checkbox: CGRect
+}
+
+private struct ReviewDiffStickyFileHeaderIndex {
+ let position: Int
+ let rowIndex: Int
+}
+
+private struct ReviewDiffStickyFileHeaderTarget {
+ let rowIndex: Int
+ let row: ReviewDiffNativeRow
+ let rect: CGRect
+}
+
+private struct ReviewDiffScrollAnchor {
+ let fileId: String
+ let screenY: CGFloat
+}
+
+private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate {
+ var rows: [ReviewDiffNativeRow] = [] {
+ didSet {
+ stopHorizontalDeceleration()
+ horizontalOffsetsByFileId.removeAll()
+ headerPathOffsetsByFileId.removeAll()
+ activePanFileId = nil
+ activePanKind = nil
+ tokenAttributedStringsByRowId.removeAll()
+ rebuildRowLayout()
+ setNeedsDisplayForVisibleBounds()
+ }
+ }
+ var tokensByRowId: [String: [ReviewDiffNativeToken]] = [:] {
+ didSet {
+ tokenAttributedStringsByRowId.removeAll()
+ clampHorizontalOffsets()
+ setNeedsDisplayForVisibleBounds()
+ }
+ }
+
+ func mergeTokensByRowId(_ tokensPatch: [String: [ReviewDiffNativeToken]]) {
+ tokensPatch.forEach { rowId, tokens in
+ tokensByRowId[rowId] = tokens
+ tokenAttributedStringsByRowId.removeValue(forKey: rowId)
+ }
+ clampHorizontalOffsets()
+ setNeedsDisplayForVisibleBounds()
+ }
+
+ var collapsedFileIds: Set = [] {
+ didSet {
+ rebuildRowLayout()
+ clampHorizontalOffsets()
+ setNeedsDisplayForVisibleBounds()
+ }
+ }
+ var viewedFileIds: Set = [] {
+ didSet {
+ setNeedsDisplayForVisibleBounds()
+ }
+ }
+ var selectedRowIds: Set = [] {
+ didSet {
+ setNeedsDisplayForVisibleBounds()
+ }
+ }
+ var collapsedCommentIds: Set = [] {
+ didSet {
+ rebuildRowLayout()
+ setNeedsDisplayForVisibleBounds()
+ }
+ }
+ var style = ReviewDiffNativeStyle.resolve(nil) {
+ didSet {
+ tokenAttributedStringsByRowId.removeAll()
+ rebuildRowLayout()
+ clampHorizontalOffsets()
+ setNeedsDisplayForVisibleBounds()
+ }
+ }
+ var viewportWidth: CGFloat = 0 {
+ didSet {
+ clampHorizontalOffsets()
+ setNeedsDisplayForVisibleBounds()
+ }
+ }
+ var verticalOffset: CGFloat = 0
+ var theme = ReviewDiffNativeTheme.resolve("light") {
+ didSet {
+ tokenColorsByHex.removeAll()
+ tokenAttributedStringsByRowId.removeAll()
+ setNeedsDisplayForVisibleBounds()
+ }
+ }
+ private(set) var contentHeight: CGFloat = 0
+
+ private var rowOffsets: [CGFloat] = []
+ private var fileHeaderRowIndices: [Int] = []
+ private var contentWidthsByFileId: [String: CGFloat] = [:]
+ private var tokenColorsByHex: [String: UIColor] = [:]
+ private var tokenAttributedStringsByRowId: [String: NSAttributedString] = [:]
+ private var codeCharacterWidth: CGFloat = 8
+ private var panStartHorizontalOffset: CGFloat = 0
+ private var activePanFileId: String?
+ private var activePanKind: ReviewDiffHorizontalPanKind?
+ private var horizontalOffsetsByFileId: [String: CGFloat] = [:]
+ private var headerPathOffsetsByFileId: [String: CGFloat] = [:]
+ private var decelerationDisplayLink: CADisplayLink?
+ private var deceleratingFileId: String?
+ private var horizontalVelocity: CGFloat = 0
+ private var lastDecelerationTimestamp: CFTimeInterval = 0
+ private var lastDrawMetricsTimestamp: CFTimeInterval = 0
+ var isVerticalScrollActive = false
+ var onToggleFile: ((String) -> Void)?
+ var onToggleViewedFile: ((String) -> Void)?
+ var onPressLine: (([String: Any]) -> Void)?
+ var onToggleComment: ((String) -> Void)?
+ var onDrawMetrics: (([String: Any]) -> Void)?
+
+ private var stickyWidth: CGFloat {
+ style.changeBarWidth + style.gutterWidth
+ }
+
+ private var codeStartX: CGFloat {
+ stickyWidth + style.codePadding
+ }
+
+ private func height(for row: ReviewDiffNativeRow) -> CGFloat {
+ if row.kind == "file" {
+ return style.fileHeaderHeight
+ }
+ if collapsedFileIds.contains(resolvedFileId(for: row)) {
+ return 0
+ }
+ if row.kind == "notice" {
+ return max(style.rowHeight * 2, 44)
+ }
+ if row.kind == "comment" {
+ return collapsedCommentIds.contains(row.id) ? 44 : 124
+ }
+ return style.rowHeight
+ }
+
+ private func rebuildRowLayout() {
+ var nextOffsets: [CGFloat] = []
+ var nextFileHeaderRowIndices: [Int] = []
+ nextOffsets.reserveCapacity(rows.count)
+ var maxColumnCountsByFileId: [String: Int] = [:]
+ var offset: CGFloat = 0
+
+ for (index, row) in rows.enumerated() {
+ nextOffsets.append(offset)
+ if row.kind == "file" {
+ nextFileHeaderRowIndices.append(index)
+ }
+ offset += height(for: row)
+
+ let fileId = resolvedFileId(for: row)
+ switch row.kind {
+ case "line":
+ maxColumnCountsByFileId[fileId] = max(
+ maxColumnCountsByFileId[fileId] ?? 0,
+ row.content?.count ?? 0
+ )
+ case "hunk":
+ maxColumnCountsByFileId[fileId] = max(
+ maxColumnCountsByFileId[fileId] ?? 0,
+ row.text?.count ?? 0
+ )
+ default:
+ continue
+ }
+ }
+
+ let characterWidth = monospaceCharacterWidth(font: codeFont)
+ codeCharacterWidth = characterWidth
+ contentWidthsByFileId = maxColumnCountsByFileId.mapValues { maxColumnCount in
+ let measuredWidth = ceil(CGFloat(maxColumnCount) * characterWidth) + style.codePadding * 2
+ return max(0, min(style.contentWidth, measuredWidth))
+ }
+ rowOffsets = nextOffsets
+ fileHeaderRowIndices = nextFileHeaderRowIndices
+ contentHeight = offset
+ }
+
+ private var codeFont: UIFont {
+ UIFont.monospacedSystemFont(ofSize: style.codeFontSize, weight: style.codeFontWeight)
+ }
+
+ private var lineNumberFont: UIFont {
+ UIFont.monospacedSystemFont(ofSize: style.lineNumberFontSize, weight: style.lineNumberFontWeight)
+ }
+
+ private var hunkFont: UIFont {
+ UIFont.monospacedSystemFont(ofSize: style.hunkFontSize, weight: style.hunkFontWeight)
+ }
+
+ private var fileHeaderFont: UIFont {
+ UIFont.systemFont(ofSize: style.fileHeaderFontSize, weight: style.fileHeaderFontWeight)
+ }
+
+ private var fileHeaderMetaFont: UIFont {
+ UIFont.systemFont(ofSize: style.fileHeaderMetaFontSize, weight: style.fileHeaderMetaFontWeight)
+ }
+
+ private var fileHeaderSubtextFont: UIFont {
+ UIFont.systemFont(ofSize: style.fileHeaderSubtextFontSize, weight: style.fileHeaderSubtextFontWeight)
+ }
+
+ private var fileHeaderStatusFont: UIFont {
+ UIFont.systemFont(ofSize: style.fileHeaderStatusFontSize, weight: style.fileHeaderStatusFontWeight)
+ }
+
+ private var emptyStateFont: UIFont {
+ UIFont.systemFont(ofSize: style.emptyStateFontSize, weight: style.emptyStateFontWeight)
+ }
+
+ private lazy var horizontalPanGesture: UIPanGestureRecognizer = {
+ let gesture = UIPanGestureRecognizer(target: self, action: #selector(handleHorizontalPan(_:)))
+ gesture.delegate = self
+ gesture.cancelsTouchesInView = false
+ return gesture
+ }()
+
+ private lazy var tapGesture: UITapGestureRecognizer = {
+ let gesture = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:)))
+ gesture.delegate = self
+ gesture.cancelsTouchesInView = false
+ return gesture
+ }()
+
+ private lazy var longPressGesture: UILongPressGestureRecognizer = {
+ let gesture = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress(_:)))
+ gesture.delegate = self
+ gesture.cancelsTouchesInView = false
+ gesture.minimumPressDuration = 0.28
+ return gesture
+ }()
+
+ override init(frame: CGRect) {
+ super.init(frame: frame)
+ isOpaque = true
+ contentMode = .redraw
+ addGestureRecognizer(horizontalPanGesture)
+ addGestureRecognizer(tapGesture)
+ addGestureRecognizer(longPressGesture)
+ tapGesture.require(toFail: longPressGesture)
+ }
+
+ func invalidateVisibleViewport() {
+ setNeedsDisplayForVisibleBounds()
+ }
+
+ private func setNeedsDisplayForVisibleBounds() {
+ setNeedsDisplay()
+ }
+
+ required init?(coder: NSCoder) {
+ super.init(coder: coder)
+ isOpaque = true
+ contentMode = .redraw
+ addGestureRecognizer(horizontalPanGesture)
+ addGestureRecognizer(tapGesture)
+ addGestureRecognizer(longPressGesture)
+ tapGesture.require(toFail: longPressGesture)
+ }
+
+ override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
+ guard gestureRecognizer === horizontalPanGesture else {
+ return true
+ }
+
+ let velocity = horizontalPanGesture.velocity(in: self)
+ guard abs(velocity.x) > abs(velocity.y) * 1.25 else {
+ return false
+ }
+
+ guard let panTarget = horizontalPanTarget(at: horizontalPanGesture.location(in: self)) else {
+ return false
+ }
+
+ let currentOffset = horizontalOffset(for: panTarget.fileId, kind: panTarget.kind)
+ if velocity.x > 0 && currentOffset <= 0.5 {
+ return false
+ }
+
+ return maxHorizontalOffset(for: panTarget) > 0
+ }
+
+ @objc private func handleTap(_ gesture: UITapGestureRecognizer) {
+ guard gesture.state == .ended else {
+ return
+ }
+
+ let point = gesture.location(in: self)
+ if let stickyHeader = stickyFileHeaderTarget(), stickyHeader.rect.contains(point) {
+ handleFileHeaderTap(row: stickyHeader.row, rect: stickyHeader.rect, point: point)
+ return
+ }
+
+ guard let rowIndex = rowIndex(at: verticalOffset + point.y),
+ rows.indices.contains(rowIndex) else {
+ return
+ }
+
+ let row = rows[rowIndex]
+ if row.kind == "comment" {
+ onToggleComment?(row.id)
+ return
+ }
+
+ if row.kind == "line" {
+ onPressLine?(linePressPayload(for: row, gesture: "tap"))
+ return
+ }
+
+ guard row.kind == "file" else {
+ return
+ }
+
+ let rowY = rowOffsets[rowIndex] - verticalOffset
+ let rect = CGRect(x: 0, y: rowY, width: max(bounds.width, viewportWidth), height: height(for: row))
+ handleFileHeaderTap(row: row, rect: rect, point: point)
+ }
+
+ @objc private func handleLongPress(_ gesture: UILongPressGestureRecognizer) {
+ guard gesture.state == .began else {
+ return
+ }
+
+ let point = gesture.location(in: self)
+ if let stickyHeader = stickyFileHeaderTarget(), stickyHeader.rect.contains(point) {
+ return
+ }
+
+ guard let rowIndex = rowIndex(at: verticalOffset + point.y),
+ rows.indices.contains(rowIndex) else {
+ return
+ }
+
+ let row = rows[rowIndex]
+ guard row.kind == "line" else {
+ return
+ }
+
+ onPressLine?(linePressPayload(for: row, gesture: "longPress"))
+ }
+
+ private func linePressPayload(for row: ReviewDiffNativeRow, gesture: String) -> [String: Any] {
+ var payload: [String: Any] = [
+ "rowId": row.id,
+ "fileId": resolvedFileId(for: row),
+ "gesture": gesture
+ ]
+
+ if let oldLineNumber = row.oldLineNumber {
+ payload["oldLineNumber"] = oldLineNumber
+ }
+ if let newLineNumber = row.newLineNumber {
+ payload["newLineNumber"] = newLineNumber
+ }
+ if let change = row.change {
+ payload["change"] = change
+ }
+
+ return payload
+ }
+
+ private func handleFileHeaderTap(row: ReviewDiffNativeRow, rect: CGRect, point: CGPoint) {
+ let interactiveRects = fileHeaderInteractiveRects(for: row, cardRect: rect)
+ let fileId = resolvedFileId(for: row)
+
+ if interactiveRects.checkbox.contains(point) {
+ onToggleViewedFile?(fileId)
+ return
+ }
+
+ if rect.contains(point) {
+ onToggleFile?(fileId)
+ }
+ }
+
+ func gestureRecognizer(
+ _ gestureRecognizer: UIGestureRecognizer,
+ shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
+ ) -> Bool {
+ false
+ }
+
+ @objc private func handleHorizontalPan(_ gesture: UIPanGestureRecognizer) {
+ switch gesture.state {
+ case .began:
+ stopHorizontalDeceleration()
+ let panTarget = horizontalPanTarget(at: gesture.location(in: self))
+ activePanFileId = panTarget?.fileId
+ activePanKind = panTarget?.kind
+ panStartHorizontalOffset = horizontalOffset(for: panTarget?.fileId, kind: panTarget?.kind)
+ case .changed, .ended, .cancelled:
+ guard let activePanFileId, let activePanKind else {
+ return
+ }
+ let translation = gesture.translation(in: self)
+ setHorizontalOffset(
+ min(
+ max(panStartHorizontalOffset - translation.x, 0),
+ maxHorizontalOffset(for: (fileId: activePanFileId, kind: activePanKind))
+ ),
+ for: activePanFileId,
+ kind: activePanKind
+ )
+ if gesture.state == .ended, activePanKind == .code {
+ let velocity = -gesture.velocity(in: self).x
+ self.activePanFileId = nil
+ self.activePanKind = nil
+ startHorizontalDeceleration(fileId: activePanFileId, velocity: velocity)
+ } else if gesture.state == .cancelled {
+ self.activePanFileId = nil
+ self.activePanKind = nil
+ } else if gesture.state == .ended {
+ self.activePanFileId = nil
+ self.activePanKind = nil
+ }
+ default:
+ break
+ }
+ }
+
+ private func horizontalPanTarget(at point: CGPoint) -> (fileId: String, kind: ReviewDiffHorizontalPanKind)? {
+ if let stickyHeader = stickyFileHeaderTarget(), stickyHeader.rect.contains(point) {
+ return (resolvedFileId(for: stickyHeader.row), .fileHeaderPath)
+ }
+
+ guard let row = row(at: point) else {
+ return nil
+ }
+
+ let fileId = resolvedFileId(for: row)
+ if row.kind == "file" {
+ return (fileId, .fileHeaderPath)
+ }
+
+ return (fileId, .code)
+ }
+
+ private func stickyFileHeaderTarget() -> ReviewDiffStickyFileHeaderTarget? {
+ guard let stickyHeader = stickyFileHeaderRowIndex() else {
+ return nil
+ }
+
+ let rowIndex = stickyHeader.rowIndex
+ let headerTop = rowOffsets[rowIndex]
+ guard headerTop < verticalOffset else {
+ return nil
+ }
+
+ let nextHeaderPosition = stickyHeader.position + 1
+ let nextHeaderRowIndex = fileHeaderRowIndices.indices.contains(nextHeaderPosition)
+ ? fileHeaderRowIndices[nextHeaderPosition]
+ : nil
+ let pushedY: CGFloat
+ if let nextHeaderRowIndex {
+ pushedY = min(0, rowOffsets[nextHeaderRowIndex] - verticalOffset - style.fileHeaderHeight)
+ } else {
+ pushedY = 0
+ }
+
+ guard pushedY > -style.fileHeaderHeight else {
+ return nil
+ }
+
+ let rect = CGRect(
+ x: 0,
+ y: pushedY,
+ width: max(bounds.width, viewportWidth),
+ height: style.fileHeaderHeight
+ )
+ return ReviewDiffStickyFileHeaderTarget(rowIndex: rowIndex, row: rows[rowIndex], rect: rect)
+ }
+
+ private func stickyFileHeaderRowIndex() -> ReviewDiffStickyFileHeaderIndex? {
+ guard !fileHeaderRowIndices.isEmpty else {
+ return nil
+ }
+
+ var lowerBound = 0
+ var upperBound = fileHeaderRowIndices.count
+ while lowerBound < upperBound {
+ let midpoint = (lowerBound + upperBound) / 2
+ let rowIndex = fileHeaderRowIndices[midpoint]
+ if rowOffsets[rowIndex] <= verticalOffset {
+ lowerBound = midpoint + 1
+ } else {
+ upperBound = midpoint
+ }
+ }
+
+ let matchIndex = lowerBound - 1
+ guard matchIndex >= 0 else {
+ return nil
+ }
+ return ReviewDiffStickyFileHeaderIndex(position: matchIndex, rowIndex: fileHeaderRowIndices[matchIndex])
+ }
+
+ func scrollAnchor(forFileId fileId: String) -> ReviewDiffScrollAnchor? {
+ if let stickyHeader = stickyFileHeaderTarget(),
+ resolvedFileId(for: stickyHeader.row) == fileId {
+ return ReviewDiffScrollAnchor(fileId: fileId, screenY: 0)
+ }
+
+ guard let headerOffset = fileHeaderOffset(forFileId: fileId) else {
+ return nil
+ }
+
+ return ReviewDiffScrollAnchor(fileId: fileId, screenY: headerOffset - verticalOffset)
+ }
+
+ func fileHeaderOffset(forFileId fileId: String) -> CGFloat? {
+ guard let rowIndex = fileHeaderRowIndex(forFileId: fileId) else {
+ return nil
+ }
+
+ return rowOffsets[rowIndex]
+ }
+
+ private func fileHeaderRowIndex(forFileId fileId: String) -> Int? {
+ fileHeaderRowIndices.first { rowIndex in
+ rows.indices.contains(rowIndex) && resolvedFileId(for: rows[rowIndex]) == fileId
+ }
+ }
+
+ private func row(at point: CGPoint) -> ReviewDiffNativeRow? {
+ guard let rowIndex = rowIndex(at: verticalOffset + point.y) else {
+ return nil
+ }
+ guard rows.indices.contains(rowIndex) else {
+ return nil
+ }
+ return rows[rowIndex]
+ }
+
+ private func rowIndex(at absoluteY: CGFloat) -> Int? {
+ guard !rows.isEmpty else {
+ return nil
+ }
+
+ var lowerBound = 0
+ var upperBound = rows.count - 1
+ while lowerBound <= upperBound {
+ let midpoint = (lowerBound + upperBound) / 2
+ let rowStart = rowOffsets[midpoint]
+ let rowEnd = rowStart + height(for: rows[midpoint])
+
+ if absoluteY < rowStart {
+ upperBound = midpoint - 1
+ } else if absoluteY >= rowEnd {
+ lowerBound = midpoint + 1
+ } else {
+ return midpoint
+ }
+ }
+
+ return nil
+ }
+
+ private func firstVisibleRowIndex(atOrAfter absoluteY: CGFloat) -> Int? {
+ guard !rows.isEmpty else {
+ return nil
+ }
+
+ var lowerBound = 0
+ var upperBound = rows.count
+ while lowerBound < upperBound {
+ let midpoint = (lowerBound + upperBound) / 2
+ let rowEnd = rowOffsets[midpoint] + height(for: rows[midpoint])
+
+ if rowEnd < absoluteY {
+ lowerBound = midpoint + 1
+ } else {
+ upperBound = midpoint
+ }
+ }
+
+ return lowerBound < rows.count ? lowerBound : nil
+ }
+
+ private func lastVisibleRowIndex(atOrBefore absoluteY: CGFloat) -> Int? {
+ guard !rows.isEmpty else {
+ return nil
+ }
+
+ var lowerBound = 0
+ var upperBound = rows.count
+ while lowerBound < upperBound {
+ let midpoint = (lowerBound + upperBound) / 2
+ let rowStart = rowOffsets[midpoint]
+
+ if rowStart <= absoluteY {
+ lowerBound = midpoint + 1
+ } else {
+ upperBound = midpoint
+ }
+ }
+
+ let index = lowerBound - 1
+ return index >= 0 ? index : nil
+ }
+
+ private func resolvedFileId(for row: ReviewDiffNativeRow) -> String {
+ if let fileId = row.fileId {
+ return fileId
+ }
+ if let range = row.id.range(of: ":header") ?? row.id.range(of: ":hunk:") ?? row.id.range(of: ":line:") {
+ return String(row.id[.. CGFloat {
+ guard let fileId else {
+ return 0
+ }
+ if kind == .fileHeaderPath {
+ return headerPathOffsetsByFileId[fileId] ?? 0
+ }
+ return horizontalOffsetsByFileId[fileId] ?? 0
+ }
+
+ private func setHorizontalOffset(
+ _ offset: CGFloat,
+ for fileId: String,
+ kind: ReviewDiffHorizontalPanKind = .code
+ ) {
+ if kind == .fileHeaderPath {
+ headerPathOffsetsByFileId[fileId] = offset
+ setNeedsDisplayForVisibleBounds()
+ return
+ }
+ horizontalOffsetsByFileId[fileId] = offset
+ setNeedsDisplayForVisibleBounds()
+ }
+
+ private func clampHorizontalOffsets() {
+ for (fileId, offset) in horizontalOffsetsByFileId {
+ horizontalOffsetsByFileId[fileId] = min(offset, maxHorizontalOffset(for: (fileId: fileId, kind: .code)))
+ }
+ for (fileId, offset) in headerPathOffsetsByFileId {
+ headerPathOffsetsByFileId[fileId] = min(
+ offset,
+ maxHorizontalOffset(for: (fileId: fileId, kind: .fileHeaderPath))
+ )
+ }
+ }
+
+ private func maxHorizontalOffset(for target: (fileId: String, kind: ReviewDiffHorizontalPanKind)) -> CGFloat {
+ if target.kind == .fileHeaderPath,
+ let row = rows.first(where: { resolvedFileId(for: $0) == target.fileId && $0.kind == "file" }) {
+ return maxHeaderPathOffset(for: row)
+ }
+
+ return max(0, contentWidth(for: target.fileId) - max(0, viewportWidth - codeStartX))
+ }
+
+ private func contentWidth(for fileId: String) -> CGFloat {
+ contentWidthsByFileId[fileId] ?? min(style.contentWidth, max(viewportWidth, 0))
+ }
+
+ func currentVisibleRowRange() -> (firstRowIndex: Int, lastRowIndex: Int)? {
+ guard !rows.isEmpty else {
+ return nil
+ }
+
+ let visibleMinY = verticalOffset
+ let visibleMaxY = verticalOffset + max(bounds.height, 1)
+ guard let firstRowIndex = firstVisibleRowIndex(atOrAfter: visibleMinY),
+ let lastRowIndex = lastVisibleRowIndex(atOrBefore: visibleMaxY),
+ firstRowIndex <= lastRowIndex else {
+ return nil
+ }
+
+ return (firstRowIndex: firstRowIndex, lastRowIndex: lastRowIndex)
+ }
+
+ private func maxHeaderPathOffset(for row: ReviewDiffNativeRow) -> CGFloat {
+ let fullRect = CGRect(x: 0, y: 0, width: max(bounds.width, viewportWidth), height: height(for: row))
+ let layout = fileHeaderPathLayout(for: row, cardRect: fullRect)
+ let pathWidth = textWidth(layout.displayPath, font: fileHeaderFont)
+ return max(0, pathWidth - layout.rect.width)
+ }
+
+ private func startHorizontalDeceleration(fileId: String, velocity: CGFloat) {
+ guard abs(velocity) > 80 else {
+ return
+ }
+
+ let currentOffset = horizontalOffset(for: fileId)
+ let maxOffset = maxHorizontalOffset(for: (fileId: fileId, kind: .code))
+ if (currentOffset <= 0 && velocity < 0) || (currentOffset >= maxOffset && velocity > 0) {
+ return
+ }
+
+ stopHorizontalDeceleration()
+ deceleratingFileId = fileId
+ horizontalVelocity = velocity
+ lastDecelerationTimestamp = 0
+
+ let displayLink = CADisplayLink(target: self, selector: #selector(stepHorizontalDeceleration(_:)))
+ displayLink.add(to: .main, forMode: .common)
+ decelerationDisplayLink = displayLink
+ }
+
+ @objc private func stepHorizontalDeceleration(_ displayLink: CADisplayLink) {
+ guard let fileId = deceleratingFileId else {
+ stopHorizontalDeceleration()
+ return
+ }
+
+ if lastDecelerationTimestamp == 0 {
+ lastDecelerationTimestamp = displayLink.timestamp
+ return
+ }
+
+ let dt = max(0, displayLink.timestamp - lastDecelerationTimestamp)
+ lastDecelerationTimestamp = displayLink.timestamp
+
+ let maxOffset = maxHorizontalOffset(for: (fileId: fileId, kind: .code))
+ let nextOffset = horizontalOffset(for: fileId) + horizontalVelocity * CGFloat(dt)
+ let clampedOffset = min(max(nextOffset, 0), maxOffset)
+ setHorizontalOffset(clampedOffset, for: fileId)
+
+ // UIScrollView deceleration rates are expressed per millisecond.
+ horizontalVelocity *= CGFloat(pow(Double(UIScrollView.DecelerationRate.normal.rawValue), dt * 1000))
+
+ if abs(horizontalVelocity) < 20 || clampedOffset <= 0 || clampedOffset >= maxOffset {
+ stopHorizontalDeceleration()
+ }
+ }
+
+ private func stopHorizontalDeceleration() {
+ decelerationDisplayLink?.invalidate()
+ decelerationDisplayLink = nil
+ deceleratingFileId = nil
+ horizontalVelocity = 0
+ lastDecelerationTimestamp = 0
+ }
+
+ deinit {
+ stopHorizontalDeceleration()
+ }
+
+ override func draw(_ rect: CGRect) {
+ let drawStartedAt = CACurrentMediaTime()
+ guard let context = UIGraphicsGetCurrentContext() else {
+ return
+ }
+
+ theme.background.setFill()
+ context.fill(rect)
+
+ if rows.isEmpty {
+ drawEmptyState(rect)
+ return
+ }
+
+ let visibleMinY = verticalOffset + rect.minY
+ let visibleMaxY = verticalOffset + rect.maxY
+ let overscan = max(style.rowHeight, style.fileHeaderHeight) * 4
+ let rangeMinY = max(0, visibleMinY - overscan)
+ let rangeMaxY = visibleMaxY + overscan
+ guard let firstRowIndex = firstVisibleRowIndex(atOrAfter: rangeMinY),
+ let lastRowIndex = lastVisibleRowIndex(atOrBefore: rangeMaxY),
+ firstRowIndex <= lastRowIndex else {
+ return
+ }
+
+ var drawnRowCount = 0
+ for rowIndex in firstRowIndex...lastRowIndex {
+ let rowStart = rowOffsets[rowIndex]
+ let rowHeight = height(for: rows[rowIndex])
+ if rowHeight <= 0 {
+ continue
+ }
+ let rowEnd = rowStart + rowHeight
+ if rowEnd < visibleMinY || rowStart > visibleMaxY {
+ continue
+ }
+ drawRow(rows[rowIndex], rowIndex: rowIndex, context: context)
+ drawnRowCount += 1
+ }
+ drawStickyFileHeader(context: context)
+
+ maybeEmitDrawMetrics(
+ drawnRowCount: drawnRowCount,
+ durationMs: (CACurrentMediaTime() - drawStartedAt) * 1000,
+ firstRowIndex: firstRowIndex,
+ lastRowIndex: lastRowIndex
+ )
+ }
+
+ private func maybeEmitDrawMetrics(
+ drawnRowCount: Int,
+ durationMs: Double,
+ firstRowIndex: Int,
+ lastRowIndex: Int
+ ) {
+ guard !isVerticalScrollActive else {
+ return
+ }
+
+ let now = CACurrentMediaTime()
+ guard now - lastDrawMetricsTimestamp >= 1 else {
+ return
+ }
+
+ lastDrawMetricsTimestamp = now
+ onDrawMetrics?([
+ "drawnRows": drawnRowCount,
+ "durationMs": durationMs,
+ "firstRowIndex": firstRowIndex,
+ "lastRowIndex": lastRowIndex,
+ "scannedRows": lastRowIndex - firstRowIndex + 1,
+ "totalRows": rows.count,
+ ])
+ }
+
+ private func drawEmptyState(_ rect: CGRect) {
+ let message = "No native diff rows"
+ let attributes: [NSAttributedString.Key: Any] = [
+ .font: emptyStateFont,
+ .foregroundColor: theme.mutedText,
+ ]
+ message.draw(at: CGPoint(x: 16, y: rect.minY + 16), withAttributes: attributes)
+ }
+
+ private func drawRow(_ row: ReviewDiffNativeRow, rowIndex: Int, context: CGContext) {
+ let rowY = rowOffsets[rowIndex] - verticalOffset
+ let fullRect = CGRect(x: 0, y: rowY, width: max(bounds.width, viewportWidth), height: height(for: row))
+
+ switch row.kind {
+ case "file":
+ drawFileRow(row, rect: fullRect, context: context)
+ case "hunk":
+ drawHunkRow(row, rect: fullRect, context: context)
+ case "notice":
+ drawNoticeRow(row, rect: fullRect, context: context)
+ case "comment":
+ drawCommentRow(row, rect: fullRect, context: context)
+ default:
+ drawCodeRow(row, rect: fullRect, context: context)
+ }
+ }
+
+ private func drawStickyFileHeader(context: CGContext) {
+ guard let stickyHeader = stickyFileHeaderTarget() else {
+ return
+ }
+
+ drawFileRow(stickyHeader.row, rect: stickyHeader.rect, context: context)
+ }
+
+ private func drawFileRow(_ row: ReviewDiffNativeRow, rect: CGRect, context: CGContext) {
+ theme.background.setFill()
+ context.fill(rect)
+
+ let cardRect = rect
+ theme.headerBackground.setFill()
+ context.fill(cardRect)
+
+ let hairline = 1 / UIScreen.main.scale
+ theme.border.setFill()
+ context.fill(CGRect(x: cardRect.minX, y: cardRect.maxY - hairline, width: cardRect.width, height: hairline))
+
+ let centerY = cardRect.midY
+ let interactiveRects = fileHeaderInteractiveRects(for: row, cardRect: cardRect)
+ let fileId = resolvedFileId(for: row)
+ drawDisclosureChevron(
+ rect: interactiveRects.chevron,
+ color: theme.mutedText,
+ collapsed: collapsedFileIds.contains(fileId)
+ )
+
+ drawFileIcon(rect: interactiveRects.icon, changeType: row.changeType)
+
+ drawViewedCheckbox(rect: interactiveRects.checkbox, checked: viewedFileIds.contains(fileId))
+
+ let deletions = row.deletions ?? 0
+ let additions = row.additions ?? 0
+ let deleteText = "-\(deletions)"
+ let addText = "+\(additions)"
+ let deleteWidth = textWidth(deleteText, font: fileHeaderMetaFont)
+ let addWidth = textWidth(addText, font: fileHeaderMetaFont)
+ let countsGap = min(style.fileHeaderCountGap, 4)
+ let countsWidth = deleteWidth + countsGap + addWidth
+ let countsX = interactiveRects.checkbox.minX - 10 - countsWidth
+ drawSingleLineText(
+ deleteText,
+ rect: CGRect(x: countsX, y: centerY - 9, width: deleteWidth, height: 18),
+ color: theme.deleteText,
+ font: fileHeaderMetaFont
+ )
+ drawSingleLineText(
+ addText,
+ rect: CGRect(x: countsX + deleteWidth + countsGap, y: centerY - 9, width: addWidth, height: 18),
+ color: theme.addText,
+ font: fileHeaderMetaFont
+ )
+
+ let pathLayout = fileHeaderPathLayout(for: row, cardRect: cardRect)
+ let pathOffset = horizontalOffset(for: resolvedFileId(for: row), kind: .fileHeaderPath)
+ drawSingleLineText(
+ pathLayout.displayPath,
+ rect: pathLayout.rect,
+ color: theme.text,
+ font: fileHeaderFont,
+ horizontalOffset: pathOffset
+ )
+ drawFileHeaderPathScrollFade(row, pathRect: pathLayout.rect, horizontalOffset: pathOffset, context: context)
+ }
+
+ private func drawNoticeRow(_ row: ReviewDiffNativeRow, rect: CGRect, context: CGContext) {
+ guard !collapsedFileIds.contains(resolvedFileId(for: row)) else {
+ return
+ }
+
+ theme.background.setFill()
+ context.fill(rect)
+
+ let hairline = 1 / UIScreen.main.scale
+ theme.border.withAlphaComponent(0.65).setFill()
+ context.fill(CGRect(x: 0, y: rect.maxY - hairline, width: rect.width, height: hairline))
+
+ let iconSize: CGFloat = 16
+ let iconRect = CGRect(
+ x: style.fileHeaderHorizontalPadding + 2,
+ y: rect.midY - iconSize / 2,
+ width: iconSize,
+ height: iconSize
+ )
+ drawNoticeIcon(rect: iconRect, color: theme.mutedText)
+
+ drawSingleLineText(
+ row.text ?? "",
+ rect: CGRect(
+ x: iconRect.maxX + 10,
+ y: rect.midY - fileHeaderSubtextFont.lineHeight / 2,
+ width: max(24, viewportWidth - iconRect.maxX - 10 - style.fileHeaderHorizontalPadding),
+ height: fileHeaderSubtextFont.lineHeight
+ ),
+ color: theme.mutedText,
+ font: fileHeaderSubtextFont
+ )
+ }
+
+ private func drawCommentRow(_ row: ReviewDiffNativeRow, rect: CGRect, context: CGContext) {
+ guard !collapsedFileIds.contains(resolvedFileId(for: row)) else {
+ return
+ }
+
+ theme.background.setFill()
+ context.fill(rect)
+
+ let isCollapsed = collapsedCommentIds.contains(row.id)
+ let cardInset = CGFloat(8)
+ let cardRect = rect.insetBy(dx: cardInset, dy: 5)
+ let cardPath = UIBezierPath(roundedRect: cardRect, cornerRadius: 10)
+ theme.headerBackground.setFill()
+ cardPath.fill()
+ theme.border.withAlphaComponent(0.85).setStroke()
+ cardPath.lineWidth = 1
+ cardPath.stroke()
+
+ let chevronRect = CGRect(x: cardRect.minX + 10, y: cardRect.minY + 11, width: 16, height: 16)
+ drawDisclosureChevron(rect: chevronRect, color: theme.mutedText, collapsed: isCollapsed)
+
+ let title = "Comment on \(row.commentRangeLabel ?? "line")"
+ drawSingleLineText(
+ title,
+ rect: CGRect(
+ x: chevronRect.maxX + 10,
+ y: cardRect.minY + 8,
+ width: max(24, cardRect.width - chevronRect.maxX - 28),
+ height: fileHeaderSubtextFont.lineHeight + 4
+ ),
+ color: theme.mutedText,
+ font: fileHeaderSubtextFont
+ )
+
+ guard !isCollapsed else {
+ return
+ }
+
+ let body = row.commentText?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+ drawMultilineText(
+ body.isEmpty ? "Comment" : body,
+ rect: CGRect(
+ x: cardRect.minX + 18,
+ y: cardRect.minY + 42,
+ width: max(24, cardRect.width - 36),
+ height: max(20, cardRect.height - 56)
+ ),
+ color: theme.text,
+ font: fileHeaderSubtextFont,
+ maximumLineCount: 3
+ )
+ }
+
+ private func fileHeaderPathLayout(
+ for row: ReviewDiffNativeRow,
+ cardRect: CGRect
+ ) -> ReviewDiffFileHeaderPathLayout {
+ let centerY = cardRect.midY
+ let interactiveRects = fileHeaderInteractiveRects(for: row, cardRect: cardRect)
+
+ let deletions = row.deletions ?? 0
+ let additions = row.additions ?? 0
+ let deleteWidth = textWidth("-\(deletions)", font: fileHeaderMetaFont)
+ let addWidth = textWidth("+\(additions)", font: fileHeaderMetaFont)
+ let countsGap = min(style.fileHeaderCountGap, 4)
+ let countsWidth = deleteWidth + countsGap + addWidth
+ let countsX = interactiveRects.checkbox.minX - 10 - countsWidth
+ let pathX = interactiveRects.icon.maxX + 10
+ let pathWidth = max(CGFloat(24), countsX - pathX - 12)
+ let displayPath: String
+ if let previousPath = row.previousPath, previousPath != row.filePath, let filePath = row.filePath {
+ displayPath = "\(previousPath) -> \(filePath)"
+ } else {
+ displayPath = row.filePath ?? ""
+ }
+
+ return ReviewDiffFileHeaderPathLayout(
+ displayPath: displayPath,
+ rect: CGRect(x: pathX, y: centerY - 10, width: pathWidth, height: 20)
+ )
+ }
+
+ private func fileHeaderInteractiveRects(
+ for row: ReviewDiffNativeRow,
+ cardRect: CGRect
+ ) -> ReviewDiffFileHeaderInteractiveRects {
+ let centerY = cardRect.midY
+ let horizontalPadding = style.fileHeaderHorizontalPadding
+ let chevronRect = CGRect(x: cardRect.minX + horizontalPadding, y: centerY - 10, width: 20, height: 20)
+ let iconRect = CGRect(x: chevronRect.maxX + 8, y: centerY - 10, width: 20, height: 20)
+ let checkboxRect = CGRect(x: cardRect.maxX - horizontalPadding - 20, y: centerY - 10, width: 20, height: 20)
+ return ReviewDiffFileHeaderInteractiveRects(
+ chevron: chevronRect,
+ icon: iconRect,
+ checkbox: checkboxRect
+ )
+ }
+
+ private func fileStatusText(_ changeType: String?) -> String {
+ switch changeType {
+ case "new":
+ return "A"
+ case "deleted":
+ return "D"
+ case "renamed":
+ return "R"
+ default:
+ return ""
+ }
+ }
+
+ private func fileStatusColor(_ changeType: String?) -> UIColor {
+ switch changeType {
+ case "new":
+ return theme.addText
+ case "deleted":
+ return theme.deleteText
+ case "renamed", "rename-pure", "rename-changed":
+ return theme.hunkText
+ default:
+ return theme.hunkText
+ }
+ }
+
+ private func drawStatusPill(_ text: String, rect: CGRect, color: UIColor, font: UIFont) {
+ let path = UIBezierPath(roundedRect: rect, cornerRadius: rect.height / 2)
+ color.withAlphaComponent(0.12).setFill()
+ path.fill()
+ drawCenteredText(text, rect: rect, color: color, font: font)
+ }
+
+ private func drawFileIcon(rect: CGRect, changeType: String?) {
+ let color = fileStatusColor(changeType)
+ let outerPath = UIBezierPath(roundedRect: rect, cornerRadius: 6)
+ color.setStroke()
+ outerPath.lineWidth = 2
+ outerPath.stroke()
+
+ if isRenameChange(changeType) {
+ drawRenameChevronIcon(rect: rect.insetBy(dx: 4.5, dy: 5), color: color)
+ return
+ }
+
+ let dotRect = CGRect(x: rect.midX - 3, y: rect.midY - 3, width: 6, height: 6)
+ color.setFill()
+ UIBezierPath(ovalIn: dotRect).fill()
+ }
+
+ private func isRenameChange(_ changeType: String?) -> Bool {
+ changeType == "renamed" || changeType == "rename-pure" || changeType == "rename-changed"
+ }
+
+ private func drawDisclosureChevron(rect: CGRect, color: UIColor, collapsed: Bool) {
+ guard let context = UIGraphicsGetCurrentContext() else {
+ return
+ }
+
+ context.saveGState()
+ context.setStrokeColor(color.cgColor)
+ context.setLineWidth(2)
+ context.setLineCap(.round)
+ context.setLineJoin(.round)
+ if collapsed {
+ context.move(to: CGPoint(x: rect.minX + rect.width * 0.40, y: rect.minY + rect.height * 0.28))
+ context.addLine(to: CGPoint(x: rect.minX + rect.width * 0.60, y: rect.midY))
+ context.addLine(to: CGPoint(x: rect.minX + rect.width * 0.40, y: rect.maxY - rect.height * 0.28))
+ } else {
+ context.move(to: CGPoint(x: rect.minX + rect.width * 0.28, y: rect.minY + rect.height * 0.42))
+ context.addLine(to: CGPoint(x: rect.midX, y: rect.minY + rect.height * 0.62))
+ context.addLine(to: CGPoint(x: rect.maxX - rect.width * 0.28, y: rect.minY + rect.height * 0.42))
+ }
+ context.strokePath()
+ context.restoreGState()
+ }
+
+ private func drawRenameChevronIcon(rect: CGRect, color: UIColor) {
+ guard let context = UIGraphicsGetCurrentContext() else {
+ return
+ }
+
+ context.saveGState()
+ context.setStrokeColor(color.cgColor)
+ context.setLineWidth(1.8)
+ context.setLineCap(.round)
+ context.setLineJoin(.round)
+
+ let chevronWidth = min(rect.width * 0.28, 3.6)
+ let chevronHeight = min(rect.height, 8)
+ let gap = min(rect.width * 0.18, 2.4)
+ let totalWidth = chevronWidth * 2 + gap
+ let startX = rect.midX - totalWidth / 2
+ let topY = rect.midY - chevronHeight / 2
+ let bottomY = rect.midY + chevronHeight / 2
+
+ for x in [startX, startX + chevronWidth + gap] {
+ context.move(to: CGPoint(x: x, y: topY))
+ context.addLine(to: CGPoint(x: x + chevronWidth, y: rect.midY))
+ context.addLine(to: CGPoint(x: x, y: bottomY))
+ }
+
+ context.strokePath()
+ context.restoreGState()
+ }
+
+ private func drawViewedCheckbox(rect: CGRect, checked: Bool) {
+ let path = UIBezierPath(roundedRect: rect, cornerRadius: 6)
+ if checked {
+ theme.hunkText.setFill()
+ path.fill()
+ }
+ (checked ? theme.hunkText : theme.mutedText).setStroke()
+ path.lineWidth = 1.8
+ path.stroke()
+
+ guard checked, let context = UIGraphicsGetCurrentContext() else {
+ return
+ }
+
+ context.saveGState()
+ context.setStrokeColor(theme.background.cgColor)
+ context.setLineWidth(2)
+ context.setLineCap(.round)
+ context.setLineJoin(.round)
+ context.move(to: CGPoint(x: rect.minX + rect.width * 0.28, y: rect.midY))
+ context.addLine(to: CGPoint(x: rect.minX + rect.width * 0.44, y: rect.maxY - rect.height * 0.30))
+ context.addLine(to: CGPoint(x: rect.maxX - rect.width * 0.25, y: rect.minY + rect.height * 0.30))
+ context.strokePath()
+ context.restoreGState()
+ }
+
+ private func drawNoticeIcon(rect: CGRect, color: UIColor) {
+ guard let context = UIGraphicsGetCurrentContext() else {
+ return
+ }
+
+ context.saveGState()
+ context.setStrokeColor(color.cgColor)
+ context.setLineWidth(1.7)
+ context.setLineCap(.round)
+ context.setLineJoin(.round)
+
+ context.strokeEllipse(in: rect.insetBy(dx: 1, dy: 1))
+ context.move(to: CGPoint(x: rect.midX, y: rect.minY + rect.height * 0.30))
+ context.addLine(to: CGPoint(x: rect.midX, y: rect.minY + rect.height * 0.58))
+ context.strokePath()
+ color.setFill()
+ context.fillEllipse(in: CGRect(x: rect.midX - 1, y: rect.maxY - rect.height * 0.30, width: 2, height: 2))
+ context.restoreGState()
+ }
+
+ private func drawCenteredText(_ text: String, rect: CGRect, color: UIColor, font: UIFont) {
+ let paragraphStyle = NSMutableParagraphStyle()
+ paragraphStyle.alignment = .center
+ let attributes: [NSAttributedString.Key: Any] = [
+ .font: font,
+ .foregroundColor: color,
+ .paragraphStyle: paragraphStyle,
+ ]
+ (text as NSString).draw(in: rect, withAttributes: attributes)
+ }
+
+ private func drawHunkRow(_ row: ReviewDiffNativeRow, rect: CGRect, context: CGContext) {
+ let fileId = resolvedFileId(for: row)
+ let horizontalOffset = horizontalOffset(for: fileId)
+ let contentWidth = contentWidth(for: fileId)
+ theme.hunkBackground.setFill()
+ context.fill(rect)
+
+ context.saveGState()
+ context.clip(to: CGRect(x: stickyWidth, y: rect.minY, width: max(0, viewportWidth - stickyWidth), height: style.rowHeight))
+ drawText(
+ row.text ?? "",
+ rect: CGRect(
+ x: codeStartX - horizontalOffset,
+ y: centeredTextY(in: rect, font: hunkFont),
+ width: contentWidth,
+ height: hunkFont.lineHeight
+ ),
+ color: theme.hunkText,
+ font: hunkFont
+ )
+ context.restoreGState()
+ }
+
+ private func drawCodeRow(_ row: ReviewDiffNativeRow, rect: CGRect, context: CGContext) {
+ let fileId = resolvedFileId(for: row)
+ let horizontalOffset = horizontalOffset(for: fileId)
+ let contentWidth = contentWidth(for: fileId)
+ let change = row.change ?? "context"
+ rowBackground(for: change).setFill()
+ context.fill(rect)
+
+ if change == "add" {
+ theme.addBar.setFill()
+ context.fill(CGRect(x: 0, y: rect.minY, width: style.changeBarWidth, height: style.rowHeight))
+ } else if change == "delete" {
+ drawDeleteStripes(
+ rect: CGRect(x: 0, y: rect.minY, width: style.changeBarWidth, height: style.rowHeight),
+ context: context
+ )
+ }
+
+ drawSelectionOverlay(row, rect: rect, context: context)
+
+ let lineNumber = row.newLineNumber ?? row.oldLineNumber
+ if let lineNumber {
+ drawRightAlignedText(
+ "\(lineNumber)",
+ rect: CGRect(
+ x: style.changeBarWidth,
+ y: centeredTextY(in: rect, font: lineNumberFont),
+ width: style.gutterWidth - style.codePadding,
+ height: lineNumberFont.lineHeight
+ ),
+ color: lineNumberColor(for: change),
+ font: lineNumberFont
+ )
+ }
+
+ context.saveGState()
+ context.clip(to: CGRect(x: stickyWidth, y: rect.minY, width: max(0, viewportWidth - stickyWidth), height: style.rowHeight))
+ let codeTextRect = CGRect(
+ x: codeStartX - horizontalOffset,
+ y: centeredTextY(in: rect, font: codeFont),
+ width: contentWidth,
+ height: codeFont.lineHeight
+ )
+ drawWordDiffRanges(row, rowRect: rect, context: context, horizontalOffset: horizontalOffset)
+ if let tokens = tokensByRowId[row.id], !tokens.isEmpty {
+ drawTokenText(
+ rowId: row.id,
+ tokens,
+ rect: codeTextRect,
+ fallbackColor: theme.text,
+ font: codeFont
+ )
+ } else {
+ drawText(row.content ?? "", rect: codeTextRect, color: theme.text, font: codeFont)
+ }
+ context.restoreGState()
+ }
+
+ private func drawSelectionOverlay(
+ _ row: ReviewDiffNativeRow,
+ rect: CGRect,
+ context: CGContext
+ ) {
+ guard selectedRowIds.contains(row.id) else {
+ return
+ }
+
+ let selectionColor = theme.hunkText.withAlphaComponent(0.22)
+ selectionColor.setFill()
+ context.fill(rect)
+
+ theme.hunkText.withAlphaComponent(0.95).setFill()
+ context.fill(CGRect(x: 0, y: rect.minY, width: style.changeBarWidth, height: rect.height))
+ }
+
+ private func drawWordDiffRanges(
+ _ row: ReviewDiffNativeRow,
+ rowRect: CGRect,
+ context: CGContext,
+ horizontalOffset: CGFloat
+ ) {
+ guard let ranges = row.wordDiffRanges, !ranges.isEmpty else {
+ return
+ }
+
+ let change = row.change ?? "context"
+ guard change == "add" || change == "delete" else {
+ return
+ }
+
+ let fillColor: UIColor
+ if change == "add" {
+ fillColor = theme.addBar.withAlphaComponent(0.28)
+ } else {
+ fillColor = theme.deleteBar.withAlphaComponent(0.28)
+ }
+ let highlightHeight = max(4, min(rowRect.height - 4, codeFont.lineHeight))
+ let highlightY = rowRect.midY - highlightHeight / 2
+
+ fillColor.setFill()
+ for range in ranges {
+ guard range.end > range.start else {
+ continue
+ }
+
+ let startX = codeStartX - horizontalOffset + CGFloat(range.start) * codeCharacterWidth
+ let width = max(2, CGFloat(range.end - range.start) * codeCharacterWidth)
+ let highlightRect = CGRect(
+ x: startX,
+ y: highlightY,
+ width: width,
+ height: highlightHeight
+ )
+ UIBezierPath(roundedRect: highlightRect, cornerRadius: 3).fill()
+ }
+ }
+
+ private func rowBackground(for change: String) -> UIColor {
+ if change == "add" {
+ return theme.addBackground
+ }
+ if change == "delete" {
+ return theme.deleteBackground
+ }
+ return theme.background
+ }
+
+ private func lineNumberColor(for change: String) -> UIColor {
+ if change == "add" {
+ return theme.addText
+ }
+ if change == "delete" {
+ return theme.deleteText
+ }
+ return theme.mutedText
+ }
+
+ private func drawDeleteStripes(rect: CGRect, context: CGContext) {
+ theme.deleteBar.setFill()
+ var y = rect.minY
+ while y < rect.maxY {
+ context.fill(CGRect(x: rect.minX, y: y, width: rect.width, height: 1))
+ y += 2
+ }
+ }
+
+ private func drawText(_ text: String, rect: CGRect, color: UIColor, font: UIFont) {
+ let attributes: [NSAttributedString.Key: Any] = [
+ .font: font,
+ .foregroundColor: color,
+ .ligature: 0,
+ ]
+ (text as NSString).draw(in: rect, withAttributes: attributes)
+ }
+
+ private func drawMultilineText(
+ _ text: String,
+ rect: CGRect,
+ color: UIColor,
+ font: UIFont,
+ maximumLineCount: Int
+ ) {
+ let paragraphStyle = NSMutableParagraphStyle()
+ paragraphStyle.lineBreakMode = .byTruncatingTail
+ paragraphStyle.lineSpacing = 2
+
+ let attributes: [NSAttributedString.Key: Any] = [
+ .font: font,
+ .foregroundColor: color,
+ .paragraphStyle: paragraphStyle,
+ .ligature: 0,
+ ]
+ let maxHeight = CGFloat(maximumLineCount) * (font.lineHeight + paragraphStyle.lineSpacing)
+ (text as NSString).draw(
+ in: CGRect(x: rect.minX, y: rect.minY, width: rect.width, height: min(rect.height, maxHeight)),
+ withAttributes: attributes
+ )
+ }
+
+ private func centeredTextY(in rect: CGRect, font: UIFont) -> CGFloat {
+ rect.midY - font.lineHeight / 2
+ }
+
+ private func drawSingleLineText(
+ _ text: String,
+ rect: CGRect,
+ color: UIColor,
+ font: UIFont,
+ horizontalOffset: CGFloat = 0
+ ) {
+ guard let context = UIGraphicsGetCurrentContext() else {
+ return
+ }
+
+ let attributes: [NSAttributedString.Key: Any] = [
+ .font: font,
+ .foregroundColor: color,
+ .ligature: 0,
+ ]
+ context.saveGState()
+ context.clip(to: rect)
+ let textY = rect.midY - font.lineHeight / 2
+ (text as NSString).draw(
+ at: CGPoint(x: rect.minX - horizontalOffset, y: textY),
+ withAttributes: attributes
+ )
+ context.restoreGState()
+ }
+
+ private func drawFileHeaderPathScrollFade(
+ _ row: ReviewDiffNativeRow,
+ pathRect: CGRect,
+ horizontalOffset: CGFloat,
+ context: CGContext
+ ) {
+ let maxOffset = maxHeaderPathOffset(for: row)
+ guard maxOffset > 0, pathRect.width > 0 else {
+ return
+ }
+
+ let fadeWidth = min(CGFloat(28), pathRect.width / 3)
+ if horizontalOffset > 0.5 {
+ drawHorizontalFade(
+ rect: CGRect(x: pathRect.minX, y: pathRect.minY, width: fadeWidth, height: pathRect.height),
+ color: theme.headerBackground,
+ fadesToRight: false,
+ context: context
+ )
+ }
+
+ if horizontalOffset < maxOffset - 0.5 {
+ drawHorizontalFade(
+ rect: CGRect(x: pathRect.maxX - fadeWidth, y: pathRect.minY, width: fadeWidth, height: pathRect.height),
+ color: theme.headerBackground,
+ fadesToRight: true,
+ context: context
+ )
+ }
+ }
+
+ private func drawHorizontalFade(
+ rect: CGRect,
+ color: UIColor,
+ fadesToRight: Bool,
+ context: CGContext
+ ) {
+ guard rect.width > 0,
+ let gradient = CGGradient(
+ colorsSpace: CGColorSpaceCreateDeviceRGB(),
+ colors: [
+ color.withAlphaComponent(fadesToRight ? 0 : 1).cgColor,
+ color.withAlphaComponent(fadesToRight ? 1 : 0).cgColor,
+ ] as CFArray,
+ locations: [0, 1]
+ ) else {
+ return
+ }
+
+ context.saveGState()
+ context.clip(to: rect)
+ context.drawLinearGradient(
+ gradient,
+ start: CGPoint(x: rect.minX, y: rect.midY),
+ end: CGPoint(x: rect.maxX, y: rect.midY),
+ options: []
+ )
+ context.restoreGState()
+ }
+
+ private func textWidth(_ text: String, font: UIFont) -> CGFloat {
+ let attributes: [NSAttributedString.Key: Any] = [.font: font, .ligature: 0]
+ return ceil((text as NSString).size(withAttributes: attributes).width)
+ }
+
+ private func monospaceCharacterWidth(font: UIFont) -> CGFloat {
+ let sampleLength = 64
+ let sample = String(repeating: "M", count: sampleLength)
+ let attributes: [NSAttributedString.Key: Any] = [.font: font, .ligature: 0]
+ return (sample as NSString).size(withAttributes: attributes).width / CGFloat(sampleLength)
+ }
+
+ private func drawTokenText(
+ rowId: String,
+ _ tokens: [ReviewDiffNativeToken],
+ rect: CGRect,
+ fallbackColor: UIColor,
+ font: UIFont
+ ) {
+ let attributedText = tokenAttributedString(
+ rowId: rowId,
+ tokens: tokens,
+ fallbackColor: fallbackColor,
+ font: font
+ )
+ attributedText.draw(in: rect)
+ }
+
+ private func tokenAttributedString(
+ rowId: String,
+ tokens: [ReviewDiffNativeToken],
+ fallbackColor: UIColor,
+ font: UIFont
+ ) -> NSAttributedString {
+ if let cached = tokenAttributedStringsByRowId[rowId] {
+ return cached
+ }
+
+ let attributedString = NSMutableAttributedString(string: "")
+ for token in tokens where !token.content.isEmpty {
+ attributedString.append(
+ NSAttributedString(
+ string: token.content,
+ attributes: [
+ .font: font,
+ .foregroundColor: tokenColor(for: token.color, fallbackColor: fallbackColor),
+ .ligature: 0,
+ ]
+ )
+ )
+ }
+
+ tokenAttributedStringsByRowId[rowId] = attributedString
+ return attributedString
+ }
+
+ private func tokenColor(for hex: String?, fallbackColor: UIColor) -> UIColor {
+ guard let hex, !hex.isEmpty else {
+ return fallbackColor
+ }
+
+ if let color = tokenColorsByHex[hex] {
+ return color
+ }
+
+ guard let color = UIColor(reviewDiffHex: hex) else {
+ return fallbackColor
+ }
+
+ tokenColorsByHex[hex] = color
+ return color
+ }
+
+ private func drawRightAlignedText(_ text: String, rect: CGRect, color: UIColor, font: UIFont) {
+ let paragraphStyle = NSMutableParagraphStyle()
+ paragraphStyle.alignment = .right
+ let attributes: [NSAttributedString.Key: Any] = [
+ .font: font,
+ .foregroundColor: color,
+ .paragraphStyle: paragraphStyle,
+ ]
+ (text as NSString).draw(in: rect, withAttributes: attributes)
+ }
+}
+
+private extension UIColor {
+ convenience init?(reviewDiffHex hex: String?) {
+ guard var value = hex?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else {
+ return nil
+ }
+
+ if value.hasPrefix("#") {
+ value.removeFirst()
+ }
+
+ guard value.count == 6 || value.count == 8 else {
+ return nil
+ }
+
+ var rawValue: UInt64 = 0
+ guard Scanner(string: value).scanHexInt64(&rawValue) else {
+ return nil
+ }
+
+ let red: CGFloat
+ let green: CGFloat
+ let blue: CGFloat
+ let alpha: CGFloat
+
+ if value.count == 8 {
+ red = CGFloat((rawValue >> 24) & 0xff) / 255
+ green = CGFloat((rawValue >> 16) & 0xff) / 255
+ blue = CGFloat((rawValue >> 8) & 0xff) / 255
+ alpha = CGFloat(rawValue & 0xff) / 255
+ } else {
+ red = CGFloat((rawValue >> 16) & 0xff) / 255
+ green = CGFloat((rawValue >> 8) & 0xff) / 255
+ blue = CGFloat(rawValue & 0xff) / 255
+ alpha = 1
+ }
+
+ self.init(red: red, green: green, blue: blue, alpha: alpha)
+ }
+}
diff --git a/apps/mobile/modules/t3-review-diff/package.json b/apps/mobile/modules/t3-review-diff/package.json
new file mode 100644
index 00000000000..75ac49e5a99
--- /dev/null
+++ b/apps/mobile/modules/t3-review-diff/package.json
@@ -0,0 +1,12 @@
+{
+ "name": "@t3tools/mobile-review-diff-native",
+ "version": "0.0.0",
+ "private": true,
+ "expo-module": {
+ "ios": {
+ "modules": [
+ "T3ReviewDiffModule"
+ ]
+ }
+ }
+}
diff --git a/apps/mobile/modules/t3-terminal/README.md b/apps/mobile/modules/t3-terminal/README.md
new file mode 100644
index 00000000000..09d927f4733
--- /dev/null
+++ b/apps/mobile/modules/t3-terminal/README.md
@@ -0,0 +1,38 @@
+# T3 Mobile Terminal Native Module
+
+This local Expo module owns the native terminal surface for the mobile app.
+
+The JavaScript contract is intentionally small:
+
+- input from the native surface is emitted as `{ data: string }`
+- resize from the native surface is emitted as `{ cols: number, rows: number }`
+- remote PTY output is delivered by the existing `WsRpcClient.terminal` RPC stream
+
+The iOS implementation uses the vendored `GhosttyKit.xcframework` built from the Ghostty custom-I/O
+fork, with T3's iOS 16 compatibility patch applied. `T3TerminalView` owns a `libghostty` surface and
+uses that callback I/O model:
+
+1. initialize libghostty once for the process
+2. create one Ghostty app and surface per native view
+3. feed remote output into the surface with `ghostty_surface_feed_data`
+4. send user input back to JS with the write callback
+5. emit Ghostty's measured terminal size through `onResize`
+
+Android currently implements the same view name (`T3TerminalSurface`) and event payloads so the
+React Native screen and RPC code stay platform-neutral. The renderer backend can be replaced with a
+future Android Ghostty build without changing JS.
+
+Vendored Ghostty revision and license details are in `THIRD_PARTY_NOTICES.md`.
+
+## Rebuilding GhosttyKit
+
+The checked-in `GhosttyKit.xcframework` is built from the Ghostty custom-I/O fork (https://github.com/Yash-Singh1/ghostty/tree/custom-io).
+Set the directory to the cloned repository checked out on the `custom-io` branch to `GHOSTTY_SOURCE_DIR`.
+
+```bash
+apps/mobile/modules/t3-terminal/scripts/build-libghostty-ios16.sh
+```
+
+The script builds Ghostty with Zig 0.15.2, strips the iOS archives, and replaces only the
+`ios-arm64` and `ios-arm64-simulator` slices. Xcode's Metal toolchain must be installed; if `metal`
+fails, run `xcodebuild -downloadComponent MetalToolchain`.
diff --git a/apps/mobile/modules/t3-terminal/T3TerminalNative.podspec b/apps/mobile/modules/t3-terminal/T3TerminalNative.podspec
new file mode 100644
index 00000000000..2d40f977697
--- /dev/null
+++ b/apps/mobile/modules/t3-terminal/T3TerminalNative.podspec
@@ -0,0 +1,21 @@
+require 'json'
+
+package = JSON.parse(File.read(File.join(__dir__, 'package.json')))
+
+Pod::Spec.new do |s|
+ s.name = 'T3TerminalNative'
+ s.version = package['version']
+ s.summary = 'Native terminal surface for T3 Code mobile.'
+ s.description = 'Native terminal surface bridge used by the T3 Code React Native app.'
+ s.homepage = 'https://t3tools.com'
+ s.license = { :type => 'UNLICENSED' }
+ s.author = { 'T3 Tools' => 'hello@t3tools.com' }
+ s.platforms = { :ios => '16.1' }
+ s.source = { :path => '.' }
+ s.source_files = 'ios/**/*.{h,m,mm,swift}'
+ s.vendored_frameworks = 'Vendor/libghostty/GhosttyKit.xcframework'
+ s.frameworks = 'IOSurface', 'Metal', 'MetalKit', 'QuartzCore', 'UIKit'
+ s.libraries = 'c++', 'z'
+ s.swift_version = '5.9'
+ s.dependency 'ExpoModulesCore'
+end
diff --git a/apps/mobile/modules/t3-terminal/THIRD_PARTY_NOTICES.md b/apps/mobile/modules/t3-terminal/THIRD_PARTY_NOTICES.md
new file mode 100644
index 00000000000..0ed6e1d1487
--- /dev/null
+++ b/apps/mobile/modules/t3-terminal/THIRD_PARTY_NOTICES.md
@@ -0,0 +1,16 @@
+# Third-Party Notices
+
+## Ghostty / libghostty
+
+The iOS terminal renderer vendors `GhosttyKit.xcframework`, a libghostty build produced from T3's
+iOS 16 support fork. That fork was created from VVTerm's custom-I/O Ghostty fork.
+
+- Upstream project: https://github.com/ghostty-org/ghostty
+- Custom-I/O base fork: https://github.com/wiedymi/ghostty/tree/custom-io
+- Vendored source fork: https://github.com/Yash-Singh1/ghostty/tree/custom-io
+- Vendored revision: `d36c3b8dffd0d756dd5e5f4933962f774a0e6753`
+- Reference integration: https://github.com/vivy-company/vvterm
+- License: MIT
+
+Ghostty's MIT license applies to the vendored framework. Keep this notice in sync when updating
+`Vendor/libghostty`.
diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/Info.plist b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/Info.plist
new file mode 100644
index 00000000000..a433fb7f04f
--- /dev/null
+++ b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/Info.plist
@@ -0,0 +1,47 @@
+
+
+
+
+ AvailableLibraries
+
+
+ BinaryPath
+ libghostty-fat.a
+ HeadersPath
+ Headers
+ LibraryIdentifier
+ ios-arm64
+ LibraryPath
+ libghostty-fat.a
+ SupportedArchitectures
+
+ arm64
+
+ SupportedPlatform
+ ios
+
+
+ BinaryPath
+ libghostty-fat.a
+ HeadersPath
+ Headers
+ LibraryIdentifier
+ ios-arm64-simulator
+ LibraryPath
+ libghostty-fat.a
+ SupportedArchitectures
+
+ arm64
+
+ SupportedPlatform
+ ios
+ SupportedPlatformVariant
+ simulator
+
+
+ CFBundlePackageType
+ XFWK
+ XCFrameworkFormatVersion
+ 1.0
+
+
diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty.h
new file mode 100644
index 00000000000..232e094ceef
--- /dev/null
+++ b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty.h
@@ -0,0 +1,1218 @@
+// Ghostty embedding API. The documentation for the embedding API is
+// only within the Zig source files that define the implementations. This
+// isn't meant to be a general purpose embedding API (yet) so there hasn't
+// been documentation or example work beyond that.
+//
+// The only consumer of this API is the macOS app, but the API is built to
+// be more general purpose.
+#ifndef GHOSTTY_H
+#define GHOSTTY_H
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+#include
+#include
+#include
+
+#ifdef _MSC_VER
+#include
+typedef SSIZE_T ssize_t;
+#else
+#include
+#endif
+
+//-------------------------------------------------------------------
+// Macros
+
+#define GHOSTTY_SUCCESS 0
+
+// Symbol visibility for shared library builds. On Windows, functions
+// are exported from the DLL when building and imported when consuming.
+// On other platforms with GCC/Clang, functions are marked with default
+// visibility so they remain accessible when the library is built with
+// -fvisibility=hidden. For static library builds, define GHOSTTY_STATIC
+// before including this header to make this a no-op.
+#ifndef GHOSTTY_API
+#if defined(GHOSTTY_STATIC)
+ #define GHOSTTY_API
+#elif defined(_WIN32) || defined(_WIN64)
+ #ifdef GHOSTTY_BUILD_SHARED
+ #define GHOSTTY_API __declspec(dllexport)
+ #else
+ #define GHOSTTY_API __declspec(dllimport)
+ #endif
+#elif defined(__GNUC__) && __GNUC__ >= 4
+ #define GHOSTTY_API __attribute__((visibility("default")))
+#else
+ #define GHOSTTY_API
+#endif
+#endif
+
+//-------------------------------------------------------------------
+// Types
+
+// Opaque types
+typedef void* ghostty_app_t;
+typedef void* ghostty_config_t;
+typedef void* ghostty_surface_t;
+typedef void* ghostty_inspector_t;
+
+// All the types below are fully defined and must be kept in sync with
+// their Zig counterparts. Any changes to these types MUST have an associated
+// Zig change.
+typedef enum {
+ GHOSTTY_PLATFORM_INVALID,
+ GHOSTTY_PLATFORM_MACOS,
+ GHOSTTY_PLATFORM_IOS,
+} ghostty_platform_e;
+
+// Callback for custom I/O write handler.
+typedef void (*ghostty_surface_write_fn)(void* userdata,
+ const uint8_t* data,
+ size_t len);
+
+typedef enum {
+ GHOSTTY_CLIPBOARD_STANDARD,
+ GHOSTTY_CLIPBOARD_SELECTION,
+} ghostty_clipboard_e;
+
+typedef struct {
+ const char *mime;
+ const char *data;
+} ghostty_clipboard_content_s;
+
+typedef enum {
+ GHOSTTY_CLIPBOARD_REQUEST_PASTE,
+ GHOSTTY_CLIPBOARD_REQUEST_OSC_52_READ,
+ GHOSTTY_CLIPBOARD_REQUEST_OSC_52_WRITE,
+} ghostty_clipboard_request_e;
+
+typedef enum {
+ GHOSTTY_MOUSE_RELEASE,
+ GHOSTTY_MOUSE_PRESS,
+} ghostty_input_mouse_state_e;
+
+typedef enum {
+ GHOSTTY_MOUSE_UNKNOWN,
+ GHOSTTY_MOUSE_LEFT,
+ GHOSTTY_MOUSE_RIGHT,
+ GHOSTTY_MOUSE_MIDDLE,
+ GHOSTTY_MOUSE_FOUR,
+ GHOSTTY_MOUSE_FIVE,
+ GHOSTTY_MOUSE_SIX,
+ GHOSTTY_MOUSE_SEVEN,
+ GHOSTTY_MOUSE_EIGHT,
+ GHOSTTY_MOUSE_NINE,
+ GHOSTTY_MOUSE_TEN,
+ GHOSTTY_MOUSE_ELEVEN,
+} ghostty_input_mouse_button_e;
+
+typedef enum {
+ GHOSTTY_MOUSE_MOMENTUM_NONE,
+ GHOSTTY_MOUSE_MOMENTUM_BEGAN,
+ GHOSTTY_MOUSE_MOMENTUM_STATIONARY,
+ GHOSTTY_MOUSE_MOMENTUM_CHANGED,
+ GHOSTTY_MOUSE_MOMENTUM_ENDED,
+ GHOSTTY_MOUSE_MOMENTUM_CANCELLED,
+ GHOSTTY_MOUSE_MOMENTUM_MAY_BEGIN,
+} ghostty_input_mouse_momentum_e;
+
+typedef enum {
+ GHOSTTY_COLOR_SCHEME_LIGHT = 0,
+ GHOSTTY_COLOR_SCHEME_DARK = 1,
+} ghostty_color_scheme_e;
+
+// This is a packed struct (see src/input/mouse.zig) but the C standard
+// afaik doesn't let us reliably define packed structs so we build it up
+// from scratch.
+typedef int ghostty_input_scroll_mods_t;
+
+typedef enum {
+ GHOSTTY_MODS_NONE = 0,
+ GHOSTTY_MODS_SHIFT = 1 << 0,
+ GHOSTTY_MODS_CTRL = 1 << 1,
+ GHOSTTY_MODS_ALT = 1 << 2,
+ GHOSTTY_MODS_SUPER = 1 << 3,
+ GHOSTTY_MODS_CAPS = 1 << 4,
+ GHOSTTY_MODS_NUM = 1 << 5,
+ GHOSTTY_MODS_SHIFT_RIGHT = 1 << 6,
+ GHOSTTY_MODS_CTRL_RIGHT = 1 << 7,
+ GHOSTTY_MODS_ALT_RIGHT = 1 << 8,
+ GHOSTTY_MODS_SUPER_RIGHT = 1 << 9,
+} ghostty_input_mods_e;
+
+typedef enum {
+ GHOSTTY_BINDING_FLAGS_CONSUMED = 1 << 0,
+ GHOSTTY_BINDING_FLAGS_ALL = 1 << 1,
+ GHOSTTY_BINDING_FLAGS_GLOBAL = 1 << 2,
+ GHOSTTY_BINDING_FLAGS_PERFORMABLE = 1 << 3,
+} ghostty_binding_flags_e;
+
+typedef enum {
+ GHOSTTY_ACTION_RELEASE,
+ GHOSTTY_ACTION_PRESS,
+ GHOSTTY_ACTION_REPEAT,
+} ghostty_input_action_e;
+
+// Based on: https://www.w3.org/TR/uievents-code/
+typedef enum {
+ GHOSTTY_KEY_UNIDENTIFIED,
+
+ // "Writing System Keys" § 3.1.1
+ GHOSTTY_KEY_BACKQUOTE,
+ GHOSTTY_KEY_BACKSLASH,
+ GHOSTTY_KEY_BRACKET_LEFT,
+ GHOSTTY_KEY_BRACKET_RIGHT,
+ GHOSTTY_KEY_COMMA,
+ GHOSTTY_KEY_DIGIT_0,
+ GHOSTTY_KEY_DIGIT_1,
+ GHOSTTY_KEY_DIGIT_2,
+ GHOSTTY_KEY_DIGIT_3,
+ GHOSTTY_KEY_DIGIT_4,
+ GHOSTTY_KEY_DIGIT_5,
+ GHOSTTY_KEY_DIGIT_6,
+ GHOSTTY_KEY_DIGIT_7,
+ GHOSTTY_KEY_DIGIT_8,
+ GHOSTTY_KEY_DIGIT_9,
+ GHOSTTY_KEY_EQUAL,
+ GHOSTTY_KEY_INTL_BACKSLASH,
+ GHOSTTY_KEY_INTL_RO,
+ GHOSTTY_KEY_INTL_YEN,
+ GHOSTTY_KEY_A,
+ GHOSTTY_KEY_B,
+ GHOSTTY_KEY_C,
+ GHOSTTY_KEY_D,
+ GHOSTTY_KEY_E,
+ GHOSTTY_KEY_F,
+ GHOSTTY_KEY_G,
+ GHOSTTY_KEY_H,
+ GHOSTTY_KEY_I,
+ GHOSTTY_KEY_J,
+ GHOSTTY_KEY_K,
+ GHOSTTY_KEY_L,
+ GHOSTTY_KEY_M,
+ GHOSTTY_KEY_N,
+ GHOSTTY_KEY_O,
+ GHOSTTY_KEY_P,
+ GHOSTTY_KEY_Q,
+ GHOSTTY_KEY_R,
+ GHOSTTY_KEY_S,
+ GHOSTTY_KEY_T,
+ GHOSTTY_KEY_U,
+ GHOSTTY_KEY_V,
+ GHOSTTY_KEY_W,
+ GHOSTTY_KEY_X,
+ GHOSTTY_KEY_Y,
+ GHOSTTY_KEY_Z,
+ GHOSTTY_KEY_MINUS,
+ GHOSTTY_KEY_PERIOD,
+ GHOSTTY_KEY_QUOTE,
+ GHOSTTY_KEY_SEMICOLON,
+ GHOSTTY_KEY_SLASH,
+
+ // "Functional Keys" § 3.1.2
+ GHOSTTY_KEY_ALT_LEFT,
+ GHOSTTY_KEY_ALT_RIGHT,
+ GHOSTTY_KEY_BACKSPACE,
+ GHOSTTY_KEY_CAPS_LOCK,
+ GHOSTTY_KEY_CONTEXT_MENU,
+ GHOSTTY_KEY_CONTROL_LEFT,
+ GHOSTTY_KEY_CONTROL_RIGHT,
+ GHOSTTY_KEY_ENTER,
+ GHOSTTY_KEY_META_LEFT,
+ GHOSTTY_KEY_META_RIGHT,
+ GHOSTTY_KEY_SHIFT_LEFT,
+ GHOSTTY_KEY_SHIFT_RIGHT,
+ GHOSTTY_KEY_SPACE,
+ GHOSTTY_KEY_TAB,
+ GHOSTTY_KEY_CONVERT,
+ GHOSTTY_KEY_KANA_MODE,
+ GHOSTTY_KEY_NON_CONVERT,
+
+ // "Control Pad Section" § 3.2
+ GHOSTTY_KEY_DELETE,
+ GHOSTTY_KEY_END,
+ GHOSTTY_KEY_HELP,
+ GHOSTTY_KEY_HOME,
+ GHOSTTY_KEY_INSERT,
+ GHOSTTY_KEY_PAGE_DOWN,
+ GHOSTTY_KEY_PAGE_UP,
+
+ // "Arrow Pad Section" § 3.3
+ GHOSTTY_KEY_ARROW_DOWN,
+ GHOSTTY_KEY_ARROW_LEFT,
+ GHOSTTY_KEY_ARROW_RIGHT,
+ GHOSTTY_KEY_ARROW_UP,
+
+ // "Numpad Section" § 3.4
+ GHOSTTY_KEY_NUM_LOCK,
+ GHOSTTY_KEY_NUMPAD_0,
+ GHOSTTY_KEY_NUMPAD_1,
+ GHOSTTY_KEY_NUMPAD_2,
+ GHOSTTY_KEY_NUMPAD_3,
+ GHOSTTY_KEY_NUMPAD_4,
+ GHOSTTY_KEY_NUMPAD_5,
+ GHOSTTY_KEY_NUMPAD_6,
+ GHOSTTY_KEY_NUMPAD_7,
+ GHOSTTY_KEY_NUMPAD_8,
+ GHOSTTY_KEY_NUMPAD_9,
+ GHOSTTY_KEY_NUMPAD_ADD,
+ GHOSTTY_KEY_NUMPAD_BACKSPACE,
+ GHOSTTY_KEY_NUMPAD_CLEAR,
+ GHOSTTY_KEY_NUMPAD_CLEAR_ENTRY,
+ GHOSTTY_KEY_NUMPAD_COMMA,
+ GHOSTTY_KEY_NUMPAD_DECIMAL,
+ GHOSTTY_KEY_NUMPAD_DIVIDE,
+ GHOSTTY_KEY_NUMPAD_ENTER,
+ GHOSTTY_KEY_NUMPAD_EQUAL,
+ GHOSTTY_KEY_NUMPAD_MEMORY_ADD,
+ GHOSTTY_KEY_NUMPAD_MEMORY_CLEAR,
+ GHOSTTY_KEY_NUMPAD_MEMORY_RECALL,
+ GHOSTTY_KEY_NUMPAD_MEMORY_STORE,
+ GHOSTTY_KEY_NUMPAD_MEMORY_SUBTRACT,
+ GHOSTTY_KEY_NUMPAD_MULTIPLY,
+ GHOSTTY_KEY_NUMPAD_PAREN_LEFT,
+ GHOSTTY_KEY_NUMPAD_PAREN_RIGHT,
+ GHOSTTY_KEY_NUMPAD_SUBTRACT,
+ GHOSTTY_KEY_NUMPAD_SEPARATOR,
+ GHOSTTY_KEY_NUMPAD_UP,
+ GHOSTTY_KEY_NUMPAD_DOWN,
+ GHOSTTY_KEY_NUMPAD_RIGHT,
+ GHOSTTY_KEY_NUMPAD_LEFT,
+ GHOSTTY_KEY_NUMPAD_BEGIN,
+ GHOSTTY_KEY_NUMPAD_HOME,
+ GHOSTTY_KEY_NUMPAD_END,
+ GHOSTTY_KEY_NUMPAD_INSERT,
+ GHOSTTY_KEY_NUMPAD_DELETE,
+ GHOSTTY_KEY_NUMPAD_PAGE_UP,
+ GHOSTTY_KEY_NUMPAD_PAGE_DOWN,
+
+ // "Function Section" § 3.5
+ GHOSTTY_KEY_ESCAPE,
+ GHOSTTY_KEY_F1,
+ GHOSTTY_KEY_F2,
+ GHOSTTY_KEY_F3,
+ GHOSTTY_KEY_F4,
+ GHOSTTY_KEY_F5,
+ GHOSTTY_KEY_F6,
+ GHOSTTY_KEY_F7,
+ GHOSTTY_KEY_F8,
+ GHOSTTY_KEY_F9,
+ GHOSTTY_KEY_F10,
+ GHOSTTY_KEY_F11,
+ GHOSTTY_KEY_F12,
+ GHOSTTY_KEY_F13,
+ GHOSTTY_KEY_F14,
+ GHOSTTY_KEY_F15,
+ GHOSTTY_KEY_F16,
+ GHOSTTY_KEY_F17,
+ GHOSTTY_KEY_F18,
+ GHOSTTY_KEY_F19,
+ GHOSTTY_KEY_F20,
+ GHOSTTY_KEY_F21,
+ GHOSTTY_KEY_F22,
+ GHOSTTY_KEY_F23,
+ GHOSTTY_KEY_F24,
+ GHOSTTY_KEY_F25,
+ GHOSTTY_KEY_FN,
+ GHOSTTY_KEY_FN_LOCK,
+ GHOSTTY_KEY_PRINT_SCREEN,
+ GHOSTTY_KEY_SCROLL_LOCK,
+ GHOSTTY_KEY_PAUSE,
+
+ // "Media Keys" § 3.6
+ GHOSTTY_KEY_BROWSER_BACK,
+ GHOSTTY_KEY_BROWSER_FAVORITES,
+ GHOSTTY_KEY_BROWSER_FORWARD,
+ GHOSTTY_KEY_BROWSER_HOME,
+ GHOSTTY_KEY_BROWSER_REFRESH,
+ GHOSTTY_KEY_BROWSER_SEARCH,
+ GHOSTTY_KEY_BROWSER_STOP,
+ GHOSTTY_KEY_EJECT,
+ GHOSTTY_KEY_LAUNCH_APP_1,
+ GHOSTTY_KEY_LAUNCH_APP_2,
+ GHOSTTY_KEY_LAUNCH_MAIL,
+ GHOSTTY_KEY_MEDIA_PLAY_PAUSE,
+ GHOSTTY_KEY_MEDIA_SELECT,
+ GHOSTTY_KEY_MEDIA_STOP,
+ GHOSTTY_KEY_MEDIA_TRACK_NEXT,
+ GHOSTTY_KEY_MEDIA_TRACK_PREVIOUS,
+ GHOSTTY_KEY_POWER,
+ GHOSTTY_KEY_SLEEP,
+ GHOSTTY_KEY_AUDIO_VOLUME_DOWN,
+ GHOSTTY_KEY_AUDIO_VOLUME_MUTE,
+ GHOSTTY_KEY_AUDIO_VOLUME_UP,
+ GHOSTTY_KEY_WAKE_UP,
+
+ // "Legacy, Non-standard, and Special Keys" § 3.7
+ GHOSTTY_KEY_COPY,
+ GHOSTTY_KEY_CUT,
+ GHOSTTY_KEY_PASTE,
+} ghostty_input_key_e;
+
+typedef struct {
+ ghostty_input_action_e action;
+ ghostty_input_mods_e mods;
+ ghostty_input_mods_e consumed_mods;
+ uint32_t keycode;
+ const char* text;
+ uint32_t unshifted_codepoint;
+ bool composing;
+} ghostty_input_key_s;
+
+typedef enum {
+ GHOSTTY_TRIGGER_PHYSICAL,
+ GHOSTTY_TRIGGER_UNICODE,
+ GHOSTTY_TRIGGER_CATCH_ALL,
+} ghostty_input_trigger_tag_e;
+
+typedef union {
+ ghostty_input_key_e translated;
+ ghostty_input_key_e physical;
+ uint32_t unicode;
+ // catch_all has no payload
+} ghostty_input_trigger_key_u;
+
+typedef struct {
+ ghostty_input_trigger_tag_e tag;
+ ghostty_input_trigger_key_u key;
+ ghostty_input_mods_e mods;
+} ghostty_input_trigger_s;
+
+typedef struct {
+ const char* action_key;
+ const char* action;
+ const char* title;
+ const char* description;
+} ghostty_command_s;
+
+typedef enum {
+ GHOSTTY_BUILD_MODE_DEBUG,
+ GHOSTTY_BUILD_MODE_RELEASE_SAFE,
+ GHOSTTY_BUILD_MODE_RELEASE_FAST,
+ GHOSTTY_BUILD_MODE_RELEASE_SMALL,
+} ghostty_build_mode_e;
+
+typedef struct {
+ ghostty_build_mode_e build_mode;
+ const char* version;
+ uintptr_t version_len;
+} ghostty_info_s;
+
+typedef struct {
+ const char* message;
+} ghostty_diagnostic_s;
+
+typedef struct {
+ const char* ptr;
+ uintptr_t len;
+ bool sentinel;
+} ghostty_string_s;
+
+typedef struct {
+ double tl_px_x;
+ double tl_px_y;
+ uint32_t offset_start;
+ uint32_t offset_len;
+ const char* text;
+ uintptr_t text_len;
+} ghostty_text_s;
+
+typedef enum {
+ GHOSTTY_POINT_ACTIVE,
+ GHOSTTY_POINT_VIEWPORT,
+ GHOSTTY_POINT_SCREEN,
+ GHOSTTY_POINT_SURFACE,
+} ghostty_point_tag_e;
+
+typedef enum {
+ GHOSTTY_POINT_COORD_EXACT,
+ GHOSTTY_POINT_COORD_TOP_LEFT,
+ GHOSTTY_POINT_COORD_BOTTOM_RIGHT,
+} ghostty_point_coord_e;
+
+typedef struct {
+ ghostty_point_tag_e tag;
+ ghostty_point_coord_e coord;
+ uint32_t x;
+ uint32_t y;
+} ghostty_point_s;
+
+typedef struct {
+ ghostty_point_s top_left;
+ ghostty_point_s bottom_right;
+ bool rectangle;
+} ghostty_selection_s;
+
+typedef struct {
+ const char* key;
+ const char* value;
+} ghostty_env_var_s;
+
+typedef struct {
+ void* nsview;
+} ghostty_platform_macos_s;
+
+typedef struct {
+ void* uiview;
+} ghostty_platform_ios_s;
+
+typedef union {
+ ghostty_platform_macos_s macos;
+ ghostty_platform_ios_s ios;
+} ghostty_platform_u;
+
+typedef enum {
+ GHOSTTY_SURFACE_CONTEXT_WINDOW = 0,
+ GHOSTTY_SURFACE_CONTEXT_TAB = 1,
+ GHOSTTY_SURFACE_CONTEXT_SPLIT = 2,
+} ghostty_surface_context_e;
+
+typedef struct {
+ ghostty_platform_e platform_tag;
+ ghostty_platform_u platform;
+ void* userdata;
+ double scale_factor;
+ float font_size;
+ const char* working_directory;
+ const char* command;
+ ghostty_env_var_s* env_vars;
+ size_t env_var_count;
+ const char* initial_input;
+ bool wait_after_command;
+ ghostty_surface_context_e context;
+ bool use_custom_io;
+} ghostty_surface_config_s;
+
+typedef struct {
+ uint16_t columns;
+ uint16_t rows;
+ uint32_t width_px;
+ uint32_t height_px;
+ uint32_t cell_width_px;
+ uint32_t cell_height_px;
+} ghostty_surface_size_s;
+
+// Config types
+
+// config.Path
+typedef struct {
+ const char* path;
+ bool optional;
+} ghostty_config_path_s;
+
+// config.Color
+typedef struct {
+ uint8_t r;
+ uint8_t g;
+ uint8_t b;
+} ghostty_config_color_s;
+
+// config.ColorList
+typedef struct {
+ const ghostty_config_color_s* colors;
+ size_t len;
+} ghostty_config_color_list_s;
+
+// config.RepeatableCommand
+typedef struct {
+ const ghostty_command_s* commands;
+ size_t len;
+} ghostty_config_command_list_s;
+
+// config.Palette
+typedef struct {
+ ghostty_config_color_s colors[256];
+} ghostty_config_palette_s;
+
+// config.QuickTerminalSize
+typedef enum {
+ GHOSTTY_QUICK_TERMINAL_SIZE_NONE,
+ GHOSTTY_QUICK_TERMINAL_SIZE_PERCENTAGE,
+ GHOSTTY_QUICK_TERMINAL_SIZE_PIXELS,
+} ghostty_quick_terminal_size_tag_e;
+
+typedef union {
+ float percentage;
+ uint32_t pixels;
+} ghostty_quick_terminal_size_value_u;
+
+typedef struct {
+ ghostty_quick_terminal_size_tag_e tag;
+ ghostty_quick_terminal_size_value_u value;
+} ghostty_quick_terminal_size_s;
+
+typedef struct {
+ ghostty_quick_terminal_size_s primary;
+ ghostty_quick_terminal_size_s secondary;
+} ghostty_config_quick_terminal_size_s;
+
+// config.Fullscreen
+typedef enum {
+ GHOSTTY_CONFIG_FULLSCREEN_FALSE,
+ GHOSTTY_CONFIG_FULLSCREEN_TRUE,
+ GHOSTTY_CONFIG_FULLSCREEN_NON_NATIVE,
+ GHOSTTY_CONFIG_FULLSCREEN_NON_NATIVE_VISIBLE_MENU,
+ GHOSTTY_CONFIG_FULLSCREEN_NON_NATIVE_PADDED_NOTCH,
+} ghostty_config_fullscreen_e;
+
+// apprt.Target.Key
+typedef enum {
+ GHOSTTY_TARGET_APP,
+ GHOSTTY_TARGET_SURFACE,
+} ghostty_target_tag_e;
+
+typedef union {
+ ghostty_surface_t surface;
+} ghostty_target_u;
+
+typedef struct {
+ ghostty_target_tag_e tag;
+ ghostty_target_u target;
+} ghostty_target_s;
+
+// apprt.action.SplitDirection
+typedef enum {
+ GHOSTTY_SPLIT_DIRECTION_RIGHT,
+ GHOSTTY_SPLIT_DIRECTION_DOWN,
+ GHOSTTY_SPLIT_DIRECTION_LEFT,
+ GHOSTTY_SPLIT_DIRECTION_UP,
+} ghostty_action_split_direction_e;
+
+// apprt.action.GotoSplit
+typedef enum {
+ GHOSTTY_GOTO_SPLIT_PREVIOUS,
+ GHOSTTY_GOTO_SPLIT_NEXT,
+ GHOSTTY_GOTO_SPLIT_UP,
+ GHOSTTY_GOTO_SPLIT_LEFT,
+ GHOSTTY_GOTO_SPLIT_DOWN,
+ GHOSTTY_GOTO_SPLIT_RIGHT,
+} ghostty_action_goto_split_e;
+
+// apprt.action.GotoWindow
+typedef enum {
+ GHOSTTY_GOTO_WINDOW_PREVIOUS,
+ GHOSTTY_GOTO_WINDOW_NEXT,
+} ghostty_action_goto_window_e;
+
+// apprt.action.ResizeSplit.Direction
+typedef enum {
+ GHOSTTY_RESIZE_SPLIT_UP,
+ GHOSTTY_RESIZE_SPLIT_DOWN,
+ GHOSTTY_RESIZE_SPLIT_LEFT,
+ GHOSTTY_RESIZE_SPLIT_RIGHT,
+} ghostty_action_resize_split_direction_e;
+
+// apprt.action.ResizeSplit
+typedef struct {
+ uint16_t amount;
+ ghostty_action_resize_split_direction_e direction;
+} ghostty_action_resize_split_s;
+
+// apprt.action.MoveTab
+typedef struct {
+ ssize_t amount;
+} ghostty_action_move_tab_s;
+
+// apprt.action.GotoTab
+typedef enum {
+ GHOSTTY_GOTO_TAB_PREVIOUS = -1,
+ GHOSTTY_GOTO_TAB_NEXT = -2,
+ GHOSTTY_GOTO_TAB_LAST = -3,
+} ghostty_action_goto_tab_e;
+
+// apprt.action.Fullscreen
+typedef enum {
+ GHOSTTY_FULLSCREEN_NATIVE,
+ GHOSTTY_FULLSCREEN_MACOS_NON_NATIVE,
+ GHOSTTY_FULLSCREEN_MACOS_NON_NATIVE_VISIBLE_MENU,
+ GHOSTTY_FULLSCREEN_MACOS_NON_NATIVE_PADDED_NOTCH,
+} ghostty_action_fullscreen_e;
+
+// apprt.action.FloatWindow
+typedef enum {
+ GHOSTTY_FLOAT_WINDOW_ON,
+ GHOSTTY_FLOAT_WINDOW_OFF,
+ GHOSTTY_FLOAT_WINDOW_TOGGLE,
+} ghostty_action_float_window_e;
+
+// apprt.action.SecureInput
+typedef enum {
+ GHOSTTY_SECURE_INPUT_ON,
+ GHOSTTY_SECURE_INPUT_OFF,
+ GHOSTTY_SECURE_INPUT_TOGGLE,
+} ghostty_action_secure_input_e;
+
+// apprt.action.Inspector
+typedef enum {
+ GHOSTTY_INSPECTOR_TOGGLE,
+ GHOSTTY_INSPECTOR_SHOW,
+ GHOSTTY_INSPECTOR_HIDE,
+} ghostty_action_inspector_e;
+
+// apprt.action.QuitTimer
+typedef enum {
+ GHOSTTY_QUIT_TIMER_START,
+ GHOSTTY_QUIT_TIMER_STOP,
+} ghostty_action_quit_timer_e;
+
+// apprt.action.Readonly
+typedef enum {
+ GHOSTTY_READONLY_OFF,
+ GHOSTTY_READONLY_ON,
+} ghostty_action_readonly_e;
+
+// apprt.action.DesktopNotification.C
+typedef struct {
+ const char* title;
+ const char* body;
+} ghostty_action_desktop_notification_s;
+
+// apprt.action.SetTitle.C
+typedef struct {
+ const char* title;
+} ghostty_action_set_title_s;
+
+// apprt.action.PromptTitle
+typedef enum {
+ GHOSTTY_PROMPT_TITLE_SURFACE,
+ GHOSTTY_PROMPT_TITLE_TAB,
+} ghostty_action_prompt_title_e;
+
+// apprt.action.Pwd.C
+typedef struct {
+ const char* pwd;
+} ghostty_action_pwd_s;
+
+// terminal.MouseShape
+typedef enum {
+ GHOSTTY_MOUSE_SHAPE_DEFAULT,
+ GHOSTTY_MOUSE_SHAPE_CONTEXT_MENU,
+ GHOSTTY_MOUSE_SHAPE_HELP,
+ GHOSTTY_MOUSE_SHAPE_POINTER,
+ GHOSTTY_MOUSE_SHAPE_PROGRESS,
+ GHOSTTY_MOUSE_SHAPE_WAIT,
+ GHOSTTY_MOUSE_SHAPE_CELL,
+ GHOSTTY_MOUSE_SHAPE_CROSSHAIR,
+ GHOSTTY_MOUSE_SHAPE_TEXT,
+ GHOSTTY_MOUSE_SHAPE_VERTICAL_TEXT,
+ GHOSTTY_MOUSE_SHAPE_ALIAS,
+ GHOSTTY_MOUSE_SHAPE_COPY,
+ GHOSTTY_MOUSE_SHAPE_MOVE,
+ GHOSTTY_MOUSE_SHAPE_NO_DROP,
+ GHOSTTY_MOUSE_SHAPE_NOT_ALLOWED,
+ GHOSTTY_MOUSE_SHAPE_GRAB,
+ GHOSTTY_MOUSE_SHAPE_GRABBING,
+ GHOSTTY_MOUSE_SHAPE_ALL_SCROLL,
+ GHOSTTY_MOUSE_SHAPE_COL_RESIZE,
+ GHOSTTY_MOUSE_SHAPE_ROW_RESIZE,
+ GHOSTTY_MOUSE_SHAPE_N_RESIZE,
+ GHOSTTY_MOUSE_SHAPE_E_RESIZE,
+ GHOSTTY_MOUSE_SHAPE_S_RESIZE,
+ GHOSTTY_MOUSE_SHAPE_W_RESIZE,
+ GHOSTTY_MOUSE_SHAPE_NE_RESIZE,
+ GHOSTTY_MOUSE_SHAPE_NW_RESIZE,
+ GHOSTTY_MOUSE_SHAPE_SE_RESIZE,
+ GHOSTTY_MOUSE_SHAPE_SW_RESIZE,
+ GHOSTTY_MOUSE_SHAPE_EW_RESIZE,
+ GHOSTTY_MOUSE_SHAPE_NS_RESIZE,
+ GHOSTTY_MOUSE_SHAPE_NESW_RESIZE,
+ GHOSTTY_MOUSE_SHAPE_NWSE_RESIZE,
+ GHOSTTY_MOUSE_SHAPE_ZOOM_IN,
+ GHOSTTY_MOUSE_SHAPE_ZOOM_OUT,
+} ghostty_action_mouse_shape_e;
+
+// apprt.action.MouseVisibility
+typedef enum {
+ GHOSTTY_MOUSE_VISIBLE,
+ GHOSTTY_MOUSE_HIDDEN,
+} ghostty_action_mouse_visibility_e;
+
+// apprt.action.MouseOverLink
+typedef struct {
+ const char* url;
+ size_t len;
+} ghostty_action_mouse_over_link_s;
+
+// apprt.action.SizeLimit
+typedef struct {
+ uint32_t min_width;
+ uint32_t min_height;
+ uint32_t max_width;
+ uint32_t max_height;
+} ghostty_action_size_limit_s;
+
+// apprt.action.InitialSize
+typedef struct {
+ uint32_t width;
+ uint32_t height;
+} ghostty_action_initial_size_s;
+
+// apprt.action.CellSize
+typedef struct {
+ uint32_t width;
+ uint32_t height;
+} ghostty_action_cell_size_s;
+
+// renderer.Health
+typedef enum {
+ GHOSTTY_RENDERER_HEALTH_HEALTHY,
+ GHOSTTY_RENDERER_HEALTH_UNHEALTHY,
+} ghostty_action_renderer_health_e;
+
+// apprt.action.KeySequence
+typedef struct {
+ bool active;
+ ghostty_input_trigger_s trigger;
+} ghostty_action_key_sequence_s;
+
+// apprt.action.KeyTable.Tag
+typedef enum {
+ GHOSTTY_KEY_TABLE_ACTIVATE,
+ GHOSTTY_KEY_TABLE_DEACTIVATE,
+ GHOSTTY_KEY_TABLE_DEACTIVATE_ALL,
+} ghostty_action_key_table_tag_e;
+
+// apprt.action.KeyTable.CValue
+typedef union {
+ struct {
+ const char *name;
+ size_t len;
+ } activate;
+} ghostty_action_key_table_u;
+
+// apprt.action.KeyTable.C
+typedef struct {
+ ghostty_action_key_table_tag_e tag;
+ ghostty_action_key_table_u value;
+} ghostty_action_key_table_s;
+
+// apprt.action.ColorKind
+typedef enum {
+ GHOSTTY_ACTION_COLOR_KIND_FOREGROUND = -1,
+ GHOSTTY_ACTION_COLOR_KIND_BACKGROUND = -2,
+ GHOSTTY_ACTION_COLOR_KIND_CURSOR = -3,
+} ghostty_action_color_kind_e;
+
+// apprt.action.ColorChange
+typedef struct {
+ ghostty_action_color_kind_e kind;
+ uint8_t r;
+ uint8_t g;
+ uint8_t b;
+} ghostty_action_color_change_s;
+
+// apprt.action.ConfigChange
+typedef struct {
+ ghostty_config_t config;
+} ghostty_action_config_change_s;
+
+// apprt.action.ReloadConfig
+typedef struct {
+ bool soft;
+} ghostty_action_reload_config_s;
+
+// apprt.action.OpenUrlKind
+typedef enum {
+ GHOSTTY_ACTION_OPEN_URL_KIND_UNKNOWN,
+ GHOSTTY_ACTION_OPEN_URL_KIND_TEXT,
+ GHOSTTY_ACTION_OPEN_URL_KIND_HTML,
+} ghostty_action_open_url_kind_e;
+
+// apprt.action.OpenUrl.C
+typedef struct {
+ ghostty_action_open_url_kind_e kind;
+ const char* url;
+ uintptr_t len;
+} ghostty_action_open_url_s;
+
+// apprt.action.CloseTabMode
+typedef enum {
+ GHOSTTY_ACTION_CLOSE_TAB_MODE_THIS,
+ GHOSTTY_ACTION_CLOSE_TAB_MODE_OTHER,
+ GHOSTTY_ACTION_CLOSE_TAB_MODE_RIGHT,
+} ghostty_action_close_tab_mode_e;
+
+// apprt.surface.Message.ChildExited
+typedef struct {
+ uint32_t exit_code;
+ uint64_t timetime_ms;
+} ghostty_surface_message_childexited_s;
+
+// terminal.osc.Command.ProgressReport.State
+typedef enum {
+ GHOSTTY_PROGRESS_STATE_REMOVE,
+ GHOSTTY_PROGRESS_STATE_SET,
+ GHOSTTY_PROGRESS_STATE_ERROR,
+ GHOSTTY_PROGRESS_STATE_INDETERMINATE,
+ GHOSTTY_PROGRESS_STATE_PAUSE,
+} ghostty_action_progress_report_state_e;
+
+// terminal.osc.Command.ProgressReport.C
+typedef struct {
+ ghostty_action_progress_report_state_e state;
+ // -1 if no progress was reported, otherwise 0-100 indicating percent
+ // completeness.
+ int8_t progress;
+} ghostty_action_progress_report_s;
+
+// apprt.action.CommandFinished.C
+typedef struct {
+ // -1 if no exit code was reported, otherwise 0-255
+ int16_t exit_code;
+ // number of nanoseconds that command was running for
+ uint64_t duration;
+} ghostty_action_command_finished_s;
+
+// apprt.action.StartSearch.C
+typedef struct {
+ const char* needle;
+} ghostty_action_start_search_s;
+
+// apprt.action.SearchTotal
+typedef struct {
+ ssize_t total;
+} ghostty_action_search_total_s;
+
+// apprt.action.SearchSelected
+typedef struct {
+ ssize_t selected;
+} ghostty_action_search_selected_s;
+
+// terminal.Scrollbar
+typedef struct {
+ uint64_t total;
+ uint64_t offset;
+ uint64_t len;
+} ghostty_action_scrollbar_s;
+
+// apprt.Action.Key
+typedef enum {
+ GHOSTTY_ACTION_QUIT,
+ GHOSTTY_ACTION_NEW_WINDOW,
+ GHOSTTY_ACTION_NEW_TAB,
+ GHOSTTY_ACTION_CLOSE_TAB,
+ GHOSTTY_ACTION_NEW_SPLIT,
+ GHOSTTY_ACTION_CLOSE_ALL_WINDOWS,
+ GHOSTTY_ACTION_TOGGLE_MAXIMIZE,
+ GHOSTTY_ACTION_TOGGLE_FULLSCREEN,
+ GHOSTTY_ACTION_TOGGLE_TAB_OVERVIEW,
+ GHOSTTY_ACTION_TOGGLE_WINDOW_DECORATIONS,
+ GHOSTTY_ACTION_TOGGLE_QUICK_TERMINAL,
+ GHOSTTY_ACTION_TOGGLE_COMMAND_PALETTE,
+ GHOSTTY_ACTION_TOGGLE_VISIBILITY,
+ GHOSTTY_ACTION_TOGGLE_BACKGROUND_OPACITY,
+ GHOSTTY_ACTION_MOVE_TAB,
+ GHOSTTY_ACTION_GOTO_TAB,
+ GHOSTTY_ACTION_GOTO_SPLIT,
+ GHOSTTY_ACTION_GOTO_WINDOW,
+ GHOSTTY_ACTION_RESIZE_SPLIT,
+ GHOSTTY_ACTION_EQUALIZE_SPLITS,
+ GHOSTTY_ACTION_TOGGLE_SPLIT_ZOOM,
+ GHOSTTY_ACTION_PRESENT_TERMINAL,
+ GHOSTTY_ACTION_SIZE_LIMIT,
+ GHOSTTY_ACTION_RESET_WINDOW_SIZE,
+ GHOSTTY_ACTION_INITIAL_SIZE,
+ GHOSTTY_ACTION_CELL_SIZE,
+ GHOSTTY_ACTION_SCROLLBAR,
+ GHOSTTY_ACTION_RENDER,
+ GHOSTTY_ACTION_INSPECTOR,
+ GHOSTTY_ACTION_SHOW_GTK_INSPECTOR,
+ GHOSTTY_ACTION_RENDER_INSPECTOR,
+ GHOSTTY_ACTION_DESKTOP_NOTIFICATION,
+ GHOSTTY_ACTION_SET_TITLE,
+ GHOSTTY_ACTION_SET_TAB_TITLE,
+ GHOSTTY_ACTION_PROMPT_TITLE,
+ GHOSTTY_ACTION_PWD,
+ GHOSTTY_ACTION_MOUSE_SHAPE,
+ GHOSTTY_ACTION_MOUSE_VISIBILITY,
+ GHOSTTY_ACTION_MOUSE_OVER_LINK,
+ GHOSTTY_ACTION_RENDERER_HEALTH,
+ GHOSTTY_ACTION_OPEN_CONFIG,
+ GHOSTTY_ACTION_QUIT_TIMER,
+ GHOSTTY_ACTION_FLOAT_WINDOW,
+ GHOSTTY_ACTION_SECURE_INPUT,
+ GHOSTTY_ACTION_KEY_SEQUENCE,
+ GHOSTTY_ACTION_KEY_TABLE,
+ GHOSTTY_ACTION_COLOR_CHANGE,
+ GHOSTTY_ACTION_RELOAD_CONFIG,
+ GHOSTTY_ACTION_CONFIG_CHANGE,
+ GHOSTTY_ACTION_CLOSE_WINDOW,
+ GHOSTTY_ACTION_RING_BELL,
+ GHOSTTY_ACTION_UNDO,
+ GHOSTTY_ACTION_REDO,
+ GHOSTTY_ACTION_CHECK_FOR_UPDATES,
+ GHOSTTY_ACTION_OPEN_URL,
+ GHOSTTY_ACTION_SHOW_CHILD_EXITED,
+ GHOSTTY_ACTION_PROGRESS_REPORT,
+ GHOSTTY_ACTION_SHOW_ON_SCREEN_KEYBOARD,
+ GHOSTTY_ACTION_COMMAND_FINISHED,
+ GHOSTTY_ACTION_START_SEARCH,
+ GHOSTTY_ACTION_END_SEARCH,
+ GHOSTTY_ACTION_SEARCH_TOTAL,
+ GHOSTTY_ACTION_SEARCH_SELECTED,
+ GHOSTTY_ACTION_READONLY,
+ GHOSTTY_ACTION_COPY_TITLE_TO_CLIPBOARD,
+} ghostty_action_tag_e;
+
+typedef union {
+ ghostty_action_split_direction_e new_split;
+ ghostty_action_fullscreen_e toggle_fullscreen;
+ ghostty_action_move_tab_s move_tab;
+ ghostty_action_goto_tab_e goto_tab;
+ ghostty_action_goto_split_e goto_split;
+ ghostty_action_goto_window_e goto_window;
+ ghostty_action_resize_split_s resize_split;
+ ghostty_action_size_limit_s size_limit;
+ ghostty_action_initial_size_s initial_size;
+ ghostty_action_cell_size_s cell_size;
+ ghostty_action_scrollbar_s scrollbar;
+ ghostty_action_inspector_e inspector;
+ ghostty_action_desktop_notification_s desktop_notification;
+ ghostty_action_set_title_s set_title;
+ ghostty_action_set_title_s set_tab_title;
+ ghostty_action_prompt_title_e prompt_title;
+ ghostty_action_pwd_s pwd;
+ ghostty_action_mouse_shape_e mouse_shape;
+ ghostty_action_mouse_visibility_e mouse_visibility;
+ ghostty_action_mouse_over_link_s mouse_over_link;
+ ghostty_action_renderer_health_e renderer_health;
+ ghostty_action_quit_timer_e quit_timer;
+ ghostty_action_float_window_e float_window;
+ ghostty_action_secure_input_e secure_input;
+ ghostty_action_key_sequence_s key_sequence;
+ ghostty_action_key_table_s key_table;
+ ghostty_action_color_change_s color_change;
+ ghostty_action_reload_config_s reload_config;
+ ghostty_action_config_change_s config_change;
+ ghostty_action_open_url_s open_url;
+ ghostty_action_close_tab_mode_e close_tab_mode;
+ ghostty_surface_message_childexited_s child_exited;
+ ghostty_action_progress_report_s progress_report;
+ ghostty_action_command_finished_s command_finished;
+ ghostty_action_start_search_s start_search;
+ ghostty_action_search_total_s search_total;
+ ghostty_action_search_selected_s search_selected;
+ ghostty_action_readonly_e readonly;
+} ghostty_action_u;
+
+typedef struct {
+ ghostty_action_tag_e tag;
+ ghostty_action_u action;
+} ghostty_action_s;
+
+typedef void (*ghostty_runtime_wakeup_cb)(void*);
+typedef bool (*ghostty_runtime_read_clipboard_cb)(void*,
+ ghostty_clipboard_e,
+ void*);
+typedef void (*ghostty_runtime_confirm_read_clipboard_cb)(
+ void*,
+ const char*,
+ void*,
+ ghostty_clipboard_request_e);
+typedef void (*ghostty_runtime_write_clipboard_cb)(void*,
+ ghostty_clipboard_e,
+ const ghostty_clipboard_content_s*,
+ size_t,
+ bool);
+typedef void (*ghostty_runtime_close_surface_cb)(void*, bool);
+typedef bool (*ghostty_runtime_action_cb)(ghostty_app_t,
+ ghostty_target_s,
+ ghostty_action_s);
+
+typedef struct {
+ void* userdata;
+ bool supports_selection_clipboard;
+ ghostty_runtime_wakeup_cb wakeup_cb;
+ ghostty_runtime_action_cb action_cb;
+ ghostty_runtime_read_clipboard_cb read_clipboard_cb;
+ ghostty_runtime_confirm_read_clipboard_cb confirm_read_clipboard_cb;
+ ghostty_runtime_write_clipboard_cb write_clipboard_cb;
+ ghostty_runtime_close_surface_cb close_surface_cb;
+} ghostty_runtime_config_s;
+
+// apprt.ipc.Target.Key
+typedef enum {
+ GHOSTTY_IPC_TARGET_CLASS,
+ GHOSTTY_IPC_TARGET_DETECT,
+} ghostty_ipc_target_tag_e;
+
+typedef union {
+ char *klass;
+} ghostty_ipc_target_u;
+
+typedef struct {
+ ghostty_ipc_target_tag_e tag;
+ ghostty_ipc_target_u target;
+} chostty_ipc_target_s;
+
+// apprt.ipc.Action.NewWindow
+typedef struct {
+ // This should be a null terminated list of strings.
+ const char **arguments;
+} ghostty_ipc_action_new_window_s;
+
+typedef union {
+ ghostty_ipc_action_new_window_s new_window;
+} ghostty_ipc_action_u;
+
+// apprt.ipc.Action.Key
+typedef enum {
+ GHOSTTY_IPC_ACTION_NEW_WINDOW,
+} ghostty_ipc_action_tag_e;
+
+//-------------------------------------------------------------------
+// Published API
+
+GHOSTTY_API int ghostty_init(uintptr_t, char**);
+GHOSTTY_API void ghostty_cli_try_action(void);
+GHOSTTY_API ghostty_info_s ghostty_info(void);
+GHOSTTY_API const char* ghostty_translate(const char*);
+GHOSTTY_API void ghostty_string_free(ghostty_string_s);
+
+GHOSTTY_API ghostty_config_t ghostty_config_new();
+GHOSTTY_API void ghostty_config_free(ghostty_config_t);
+GHOSTTY_API ghostty_config_t ghostty_config_clone(ghostty_config_t);
+GHOSTTY_API void ghostty_config_load_cli_args(ghostty_config_t);
+GHOSTTY_API void ghostty_config_load_file(ghostty_config_t, const char*);
+GHOSTTY_API void ghostty_config_load_default_files(ghostty_config_t);
+GHOSTTY_API void ghostty_config_load_recursive_files(ghostty_config_t);
+GHOSTTY_API void ghostty_config_finalize(ghostty_config_t);
+GHOSTTY_API bool ghostty_config_get(ghostty_config_t, void*, const char*, uintptr_t);
+GHOSTTY_API ghostty_input_trigger_s ghostty_config_trigger(ghostty_config_t,
+ const char*,
+ uintptr_t);
+GHOSTTY_API uint32_t ghostty_config_diagnostics_count(ghostty_config_t);
+GHOSTTY_API ghostty_diagnostic_s ghostty_config_get_diagnostic(ghostty_config_t, uint32_t);
+GHOSTTY_API ghostty_string_s ghostty_config_open_path(void);
+
+GHOSTTY_API ghostty_app_t ghostty_app_new(const ghostty_runtime_config_s*,
+ ghostty_config_t);
+GHOSTTY_API void ghostty_app_free(ghostty_app_t);
+GHOSTTY_API void ghostty_app_tick(ghostty_app_t);
+GHOSTTY_API void* ghostty_app_userdata(ghostty_app_t);
+GHOSTTY_API void ghostty_app_set_focus(ghostty_app_t, bool);
+GHOSTTY_API bool ghostty_app_key(ghostty_app_t, ghostty_input_key_s);
+GHOSTTY_API bool ghostty_app_key_is_binding(ghostty_app_t, ghostty_input_key_s);
+GHOSTTY_API void ghostty_app_keyboard_changed(ghostty_app_t);
+GHOSTTY_API void ghostty_app_open_config(ghostty_app_t);
+GHOSTTY_API void ghostty_app_update_config(ghostty_app_t, ghostty_config_t);
+GHOSTTY_API bool ghostty_app_needs_confirm_quit(ghostty_app_t);
+GHOSTTY_API bool ghostty_app_has_global_keybinds(ghostty_app_t);
+GHOSTTY_API void ghostty_app_set_color_scheme(ghostty_app_t, ghostty_color_scheme_e);
+
+GHOSTTY_API ghostty_surface_config_s ghostty_surface_config_new();
+
+GHOSTTY_API ghostty_surface_t ghostty_surface_new(ghostty_app_t,
+ const ghostty_surface_config_s*);
+GHOSTTY_API void ghostty_surface_free(ghostty_surface_t);
+GHOSTTY_API void* ghostty_surface_userdata(ghostty_surface_t);
+GHOSTTY_API ghostty_app_t ghostty_surface_app(ghostty_surface_t);
+GHOSTTY_API ghostty_surface_config_s ghostty_surface_inherited_config(ghostty_surface_t, ghostty_surface_context_e);
+GHOSTTY_API void ghostty_surface_update_config(ghostty_surface_t, ghostty_config_t);
+GHOSTTY_API bool ghostty_surface_needs_confirm_quit(ghostty_surface_t);
+GHOSTTY_API bool ghostty_surface_process_exited(ghostty_surface_t);
+GHOSTTY_API void ghostty_surface_refresh(ghostty_surface_t);
+GHOSTTY_API void ghostty_surface_draw(ghostty_surface_t);
+GHOSTTY_API void ghostty_surface_feed_data(ghostty_surface_t, const uint8_t*, size_t);
+GHOSTTY_API void ghostty_surface_set_write_callback(ghostty_surface_t,
+ ghostty_surface_write_fn,
+ void*);
+GHOSTTY_API void ghostty_surface_set_content_scale(ghostty_surface_t, double, double);
+GHOSTTY_API void ghostty_surface_set_focus(ghostty_surface_t, bool);
+GHOSTTY_API void ghostty_surface_set_occlusion(ghostty_surface_t, bool);
+GHOSTTY_API void ghostty_surface_set_size(ghostty_surface_t, uint32_t, uint32_t);
+GHOSTTY_API ghostty_surface_size_s ghostty_surface_size(ghostty_surface_t);
+GHOSTTY_API uint64_t ghostty_surface_foreground_pid(ghostty_surface_t);
+GHOSTTY_API ghostty_string_s ghostty_surface_tty_name(ghostty_surface_t);
+GHOSTTY_API void ghostty_surface_set_color_scheme(ghostty_surface_t,
+ ghostty_color_scheme_e);
+GHOSTTY_API ghostty_input_mods_e ghostty_surface_key_translation_mods(ghostty_surface_t,
+ ghostty_input_mods_e);
+GHOSTTY_API bool ghostty_surface_key(ghostty_surface_t, ghostty_input_key_s);
+GHOSTTY_API bool ghostty_surface_key_is_binding(ghostty_surface_t,
+ ghostty_input_key_s,
+ ghostty_binding_flags_e*);
+GHOSTTY_API void ghostty_surface_text(ghostty_surface_t, const char*, uintptr_t);
+GHOSTTY_API void ghostty_surface_preedit(ghostty_surface_t, const char*, uintptr_t);
+GHOSTTY_API bool ghostty_surface_mouse_captured(ghostty_surface_t);
+GHOSTTY_API bool ghostty_surface_mouse_button(ghostty_surface_t,
+ ghostty_input_mouse_state_e,
+ ghostty_input_mouse_button_e,
+ ghostty_input_mods_e);
+GHOSTTY_API void ghostty_surface_mouse_pos(ghostty_surface_t,
+ double,
+ double,
+ ghostty_input_mods_e);
+GHOSTTY_API void ghostty_surface_mouse_scroll(ghostty_surface_t,
+ double,
+ double,
+ ghostty_input_scroll_mods_t);
+GHOSTTY_API void ghostty_surface_mouse_pressure(ghostty_surface_t, uint32_t, double);
+GHOSTTY_API void ghostty_surface_ime_point(ghostty_surface_t, double*, double*, double*, double*);
+GHOSTTY_API void ghostty_surface_request_close(ghostty_surface_t);
+GHOSTTY_API void ghostty_surface_split(ghostty_surface_t, ghostty_action_split_direction_e);
+GHOSTTY_API void ghostty_surface_split_focus(ghostty_surface_t,
+ ghostty_action_goto_split_e);
+GHOSTTY_API void ghostty_surface_split_resize(ghostty_surface_t,
+ ghostty_action_resize_split_direction_e,
+ uint16_t);
+GHOSTTY_API void ghostty_surface_split_equalize(ghostty_surface_t);
+GHOSTTY_API bool ghostty_surface_binding_action(ghostty_surface_t, const char*, uintptr_t);
+GHOSTTY_API void ghostty_surface_complete_clipboard_request(ghostty_surface_t,
+ const char*,
+ void*,
+ bool);
+GHOSTTY_API bool ghostty_surface_has_selection(ghostty_surface_t);
+GHOSTTY_API bool ghostty_surface_read_selection(ghostty_surface_t, ghostty_text_s*);
+GHOSTTY_API bool ghostty_surface_read_text(ghostty_surface_t,
+ ghostty_selection_s,
+ ghostty_text_s*);
+GHOSTTY_API void ghostty_surface_free_text(ghostty_surface_t, ghostty_text_s*);
+
+#ifdef __APPLE__
+GHOSTTY_API void ghostty_surface_set_display_id(ghostty_surface_t, uint32_t);
+GHOSTTY_API void* ghostty_surface_quicklook_font(ghostty_surface_t);
+GHOSTTY_API bool ghostty_surface_quicklook_word(ghostty_surface_t, ghostty_text_s*);
+#endif
+
+GHOSTTY_API ghostty_inspector_t ghostty_surface_inspector(ghostty_surface_t);
+GHOSTTY_API void ghostty_inspector_free(ghostty_surface_t);
+GHOSTTY_API void ghostty_inspector_set_focus(ghostty_inspector_t, bool);
+GHOSTTY_API void ghostty_inspector_set_content_scale(ghostty_inspector_t, double, double);
+GHOSTTY_API void ghostty_inspector_set_size(ghostty_inspector_t, uint32_t, uint32_t);
+GHOSTTY_API void ghostty_inspector_mouse_button(ghostty_inspector_t,
+ ghostty_input_mouse_state_e,
+ ghostty_input_mouse_button_e,
+ ghostty_input_mods_e);
+GHOSTTY_API void ghostty_inspector_mouse_pos(ghostty_inspector_t, double, double);
+GHOSTTY_API void ghostty_inspector_mouse_scroll(ghostty_inspector_t,
+ double,
+ double,
+ ghostty_input_scroll_mods_t);
+GHOSTTY_API void ghostty_inspector_key(ghostty_inspector_t,
+ ghostty_input_action_e,
+ ghostty_input_key_e,
+ ghostty_input_mods_e);
+GHOSTTY_API void ghostty_inspector_text(ghostty_inspector_t, const char*);
+
+#ifdef __APPLE__
+GHOSTTY_API bool ghostty_inspector_metal_init(ghostty_inspector_t, void*);
+GHOSTTY_API void ghostty_inspector_metal_render(ghostty_inspector_t, void*, void*);
+GHOSTTY_API bool ghostty_inspector_metal_shutdown(ghostty_inspector_t);
+#endif
+
+// APIs I'd like to get rid of eventually but are still needed for now.
+// Don't use these unless you know what you're doing.
+GHOSTTY_API void ghostty_set_window_background_blur(ghostty_app_t, void*);
+
+// Benchmark API, if available.
+GHOSTTY_API bool ghostty_benchmark_cli(const char*, const char*);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* GHOSTTY_H */
diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt.h
new file mode 100644
index 00000000000..4f8fef88ecc
--- /dev/null
+++ b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt.h
@@ -0,0 +1,87 @@
+/**
+ * @file vt.h
+ *
+ * libghostty-vt - Virtual terminal emulator library
+ *
+ * This library provides functionality for parsing and handling terminal
+ * escape sequences as well as maintaining terminal state such as styles,
+ * cursor position, screen, scrollback, and more.
+ *
+ * WARNING: This is an incomplete, work-in-progress API. It is not yet
+ * stable and is definitely going to change.
+ */
+
+/**
+ * @mainpage libghostty-vt - Virtual Terminal Emulator Library
+ *
+ * libghostty-vt is a C library which implements a modern terminal emulator,
+ * extracted from the [Ghostty](https://ghostty.org) terminal emulator.
+ *
+ * libghostty-vt contains the logic for handling the core parts of a terminal
+ * emulator: parsing terminal escape sequences, maintaining terminal state,
+ * encoding input events, etc. It can handle scrollback, line wrapping,
+ * reflow on resize, and more.
+ *
+ * @warning This library is currently in development and the API is not yet stable.
+ * Breaking changes are expected in future versions. Use with caution in production code.
+ *
+ * @section groups_sec API Reference
+ *
+ * The API is organized into the following groups:
+ * - @ref key "Key Encoding" - Encode key events into terminal sequences
+ * - @ref osc "OSC Parser" - Parse OSC (Operating System Command) sequences
+ * - @ref sgr "SGR Parser" - Parse SGR (Select Graphic Rendition) sequences
+ * - @ref paste "Paste Utilities" - Validate paste data safety
+ * - @ref allocator "Memory Management" - Memory management and custom allocators
+ * - @ref wasm "WebAssembly Utilities" - WebAssembly convenience functions
+ *
+ * @section examples_sec Examples
+ *
+ * Complete working examples:
+ * - @ref c-vt/src/main.c - OSC parser example
+ * - @ref c-vt-key-encode/src/main.c - Key encoding example
+ * - @ref c-vt-paste/src/main.c - Paste safety check example
+ * - @ref c-vt-sgr/src/main.c - SGR parser example
+ *
+ */
+
+/** @example c-vt/src/main.c
+ * This example demonstrates how to use the OSC parser to parse an OSC sequence,
+ * extract command information, and retrieve command-specific data like window titles.
+ */
+
+/** @example c-vt-key-encode/src/main.c
+ * This example demonstrates how to use the key encoder to convert key events
+ * into terminal escape sequences using the Kitty keyboard protocol.
+ */
+
+/** @example c-vt-paste/src/main.c
+ * This example demonstrates how to use the paste utilities to check if
+ * paste data is safe before sending it to the terminal.
+ */
+
+/** @example c-vt-sgr/src/main.c
+ * This example demonstrates how to use the SGR parser to parse terminal
+ * styling sequences and extract text attributes like colors and underline styles.
+ */
+
+#ifndef GHOSTTY_VT_H
+#define GHOSTTY_VT_H
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* GHOSTTY_VT_H */
diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/allocator.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/allocator.h
new file mode 100644
index 00000000000..4cebe91bb10
--- /dev/null
+++ b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/allocator.h
@@ -0,0 +1,196 @@
+/**
+ * @file allocator.h
+ *
+ * Memory management interface for libghostty-vt.
+ */
+
+#ifndef GHOSTTY_VT_ALLOCATOR_H
+#define GHOSTTY_VT_ALLOCATOR_H
+
+#include
+#include
+#include
+
+/** @defgroup allocator Memory Management
+ *
+ * libghostty-vt does require memory allocation for various operations,
+ * but is resilient to allocation failures and will gracefully handle
+ * out-of-memory situations by returning error codes.
+ *
+ * The exact memory management semantics are documented in the relevant
+ * functions and data structures.
+ *
+ * libghostty-vt uses explicit memory allocation via an allocator
+ * interface provided by GhosttyAllocator. The interface is based on the
+ * [Zig](https://ziglang.org) allocator interface, since this has been
+ * shown to be a flexible and powerful interface in practice and enables
+ * a wide variety of allocation strategies.
+ *
+ * **For the common case, you can pass NULL as the allocator for any
+ * function that accepts one,** and libghostty will use a default allocator.
+ * The default allocator will be libc malloc/free if libc is linked.
+ * Otherwise, a custom allocator is used (currently Zig's SMP allocator)
+ * that doesn't require any external dependencies.
+ *
+ * ## Basic Usage
+ *
+ * For simple use cases, you can ignore this interface entirely by passing NULL
+ * as the allocator parameter to functions that accept one. This will use the
+ * default allocator (typically libc malloc/free, if libc is linked, but
+ * we provide our own default allocator if libc isn't linked).
+ *
+ * To use a custom allocator:
+ * 1. Implement the GhosttyAllocatorVtable function pointers
+ * 2. Create a GhosttyAllocator struct with your vtable and context
+ * 3. Pass the allocator to functions that accept one
+ *
+ * @{
+ */
+
+/**
+ * Function table for custom memory allocator operations.
+ *
+ * This vtable defines the interface for a custom memory allocator. All
+ * function pointers must be valid and non-NULL.
+ *
+ * @ingroup allocator
+ *
+ * If you're not going to use a custom allocator, you can ignore all of
+ * this. All functions that take an allocator pointer allow NULL to use a
+ * default allocator.
+ *
+ * The interface is based on the Zig allocator interface. I'll say up front
+ * that it is easy to look at this interface and think "wow, this is really
+ * overcomplicated". The reason for this complexity is well thought out by
+ * the Zig folks, and it enables a diverse set of allocation strategies
+ * as shown by the Zig ecosystem. As a consolation, please note that many
+ * of the arguments are only needed for advanced use cases and can be
+ * safely ignored in simple implementations. For example, if you look at
+ * the Zig implementation of the libc allocator in `lib/std/heap.zig`
+ * (search for CAllocator), you'll see it is very simple.
+ *
+ * We chose to align with the Zig allocator interface because:
+ *
+ * 1. It is a proven interface that serves a wide variety of use cases
+ * in the real world via the Zig ecosystem. It's shown to work.
+ *
+ * 2. Our core implementation itself is Zig, and this lets us very
+ * cheaply and easily convert between C and Zig allocators.
+ *
+ * NOTE(mitchellh): In the future, we can have default implementations of
+ * resize/remap and allow those to be null.
+ */
+typedef struct {
+ /**
+ * Return a pointer to `len` bytes with specified `alignment`, or return
+ * `NULL` indicating the allocation failed.
+ *
+ * @param ctx The allocator context
+ * @param len Number of bytes to allocate
+ * @param alignment Required alignment for the allocation. Guaranteed to
+ * be a power of two between 1 and 16 inclusive.
+ * @param ret_addr First return address of the allocation call stack (0 if not provided)
+ * @return Pointer to allocated memory, or NULL if allocation failed
+ */
+ void* (*alloc)(void *ctx, size_t len, uint8_t alignment, uintptr_t ret_addr);
+
+ /**
+ * Attempt to expand or shrink memory in place.
+ *
+ * `memory_len` must equal the length requested from the most recent
+ * successful call to `alloc`, `resize`, or `remap`. `alignment` must
+ * equal the same value that was passed as the `alignment` parameter to
+ * the original `alloc` call.
+ *
+ * `new_len` must be greater than zero.
+ *
+ * @param ctx The allocator context
+ * @param memory Pointer to the memory block to resize
+ * @param memory_len Current size of the memory block
+ * @param alignment Alignment (must match original allocation)
+ * @param new_len New requested size
+ * @param ret_addr First return address of the allocation call stack (0 if not provided)
+ * @return true if resize was successful in-place, false if relocation would be required
+ */
+ bool (*resize)(void *ctx, void *memory, size_t memory_len, uint8_t alignment, size_t new_len, uintptr_t ret_addr);
+
+ /**
+ * Attempt to expand or shrink memory, allowing relocation.
+ *
+ * `memory_len` must equal the length requested from the most recent
+ * successful call to `alloc`, `resize`, or `remap`. `alignment` must
+ * equal the same value that was passed as the `alignment` parameter to
+ * the original `alloc` call.
+ *
+ * A non-`NULL` return value indicates the resize was successful. The
+ * allocation may have same address, or may have been relocated. In either
+ * case, the allocation now has size of `new_len`. A `NULL` return value
+ * indicates that the resize would be equivalent to allocating new memory,
+ * copying the bytes from the old memory, and then freeing the old memory.
+ * In such case, it is more efficient for the caller to perform the copy.
+ *
+ * `new_len` must be greater than zero.
+ *
+ * @param ctx The allocator context
+ * @param memory Pointer to the memory block to remap
+ * @param memory_len Current size of the memory block
+ * @param alignment Alignment (must match original allocation)
+ * @param new_len New requested size
+ * @param ret_addr First return address of the allocation call stack (0 if not provided)
+ * @return Pointer to resized memory (may be relocated), or NULL if manual copy is needed
+ */
+ void* (*remap)(void *ctx, void *memory, size_t memory_len, uint8_t alignment, size_t new_len, uintptr_t ret_addr);
+
+ /**
+ * Free and invalidate a region of memory.
+ *
+ * `memory_len` must equal the length requested from the most recent
+ * successful call to `alloc`, `resize`, or `remap`. `alignment` must
+ * equal the same value that was passed as the `alignment` parameter to
+ * the original `alloc` call.
+ *
+ * @param ctx The allocator context
+ * @param memory Pointer to the memory block to free
+ * @param memory_len Size of the memory block
+ * @param alignment Alignment (must match original allocation)
+ * @param ret_addr First return address of the allocation call stack (0 if not provided)
+ */
+ void (*free)(void *ctx, void *memory, size_t memory_len, uint8_t alignment, uintptr_t ret_addr);
+} GhosttyAllocatorVtable;
+
+/**
+ * Custom memory allocator.
+ *
+ * For functions that take an allocator pointer, a NULL pointer indicates
+ * that the default allocator should be used. The default allocator will
+ * be libc malloc/free if we're linking to libc. If libc isn't linked,
+ * a custom allocator is used (currently Zig's SMP allocator).
+ *
+ * @ingroup allocator
+ *
+ * Usage example:
+ * @code
+ * GhosttyAllocator allocator = {
+ * .vtable = &my_allocator_vtable,
+ * .ctx = my_allocator_state
+ * };
+ * @endcode
+ */
+typedef struct GhosttyAllocator {
+ /**
+ * Opaque context pointer passed to all vtable functions.
+ * This allows the allocator implementation to maintain state
+ * or reference external resources needed for memory management.
+ */
+ void *ctx;
+
+ /**
+ * Pointer to the allocator's vtable containing function pointers
+ * for memory operations (alloc, resize, remap, free).
+ */
+ const GhosttyAllocatorVtable *vtable;
+} GhosttyAllocator;
+
+/** @} */
+
+#endif /* GHOSTTY_VT_ALLOCATOR_H */
diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/color.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/color.h
new file mode 100644
index 00000000000..0d57b8db4ab
--- /dev/null
+++ b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/color.h
@@ -0,0 +1,96 @@
+/**
+ * @file color.h
+ *
+ * Color types and utilities.
+ */
+
+#ifndef GHOSTTY_VT_COLOR_H
+#define GHOSTTY_VT_COLOR_H
+
+#include
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/**
+ * RGB color value.
+ *
+ * @ingroup sgr
+ */
+typedef struct {
+ uint8_t r; /**< Red component (0-255) */
+ uint8_t g; /**< Green component (0-255) */
+ uint8_t b; /**< Blue component (0-255) */
+} GhosttyColorRgb;
+
+/**
+ * Palette color index (0-255).
+ *
+ * @ingroup sgr
+ */
+typedef uint8_t GhosttyColorPaletteIndex;
+
+/** @addtogroup sgr
+ * @{
+ */
+
+/** Black color (0) @ingroup sgr */
+#define GHOSTTY_COLOR_NAMED_BLACK 0
+/** Red color (1) @ingroup sgr */
+#define GHOSTTY_COLOR_NAMED_RED 1
+/** Green color (2) @ingroup sgr */
+#define GHOSTTY_COLOR_NAMED_GREEN 2
+/** Yellow color (3) @ingroup sgr */
+#define GHOSTTY_COLOR_NAMED_YELLOW 3
+/** Blue color (4) @ingroup sgr */
+#define GHOSTTY_COLOR_NAMED_BLUE 4
+/** Magenta color (5) @ingroup sgr */
+#define GHOSTTY_COLOR_NAMED_MAGENTA 5
+/** Cyan color (6) @ingroup sgr */
+#define GHOSTTY_COLOR_NAMED_CYAN 6
+/** White color (7) @ingroup sgr */
+#define GHOSTTY_COLOR_NAMED_WHITE 7
+/** Bright black color (8) @ingroup sgr */
+#define GHOSTTY_COLOR_NAMED_BRIGHT_BLACK 8
+/** Bright red color (9) @ingroup sgr */
+#define GHOSTTY_COLOR_NAMED_BRIGHT_RED 9
+/** Bright green color (10) @ingroup sgr */
+#define GHOSTTY_COLOR_NAMED_BRIGHT_GREEN 10
+/** Bright yellow color (11) @ingroup sgr */
+#define GHOSTTY_COLOR_NAMED_BRIGHT_YELLOW 11
+/** Bright blue color (12) @ingroup sgr */
+#define GHOSTTY_COLOR_NAMED_BRIGHT_BLUE 12
+/** Bright magenta color (13) @ingroup sgr */
+#define GHOSTTY_COLOR_NAMED_BRIGHT_MAGENTA 13
+/** Bright cyan color (14) @ingroup sgr */
+#define GHOSTTY_COLOR_NAMED_BRIGHT_CYAN 14
+/** Bright white color (15) @ingroup sgr */
+#define GHOSTTY_COLOR_NAMED_BRIGHT_WHITE 15
+
+/** @} */
+
+/**
+ * Get the RGB color components.
+ *
+ * This function extracts the individual red, green, and blue components
+ * from a GhosttyColorRgb value. Primarily useful in WebAssembly environments
+ * where accessing struct fields directly is difficult.
+ *
+ * @param color The RGB color value
+ * @param r Pointer to store the red component (0-255)
+ * @param g Pointer to store the green component (0-255)
+ * @param b Pointer to store the blue component (0-255)
+ *
+ * @ingroup sgr
+ */
+void ghostty_color_rgb_get(GhosttyColorRgb color,
+ uint8_t* r,
+ uint8_t* g,
+ uint8_t* b);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* GHOSTTY_VT_COLOR_H */
diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/key.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/key.h
new file mode 100644
index 00000000000..772b5d43bcf
--- /dev/null
+++ b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/key.h
@@ -0,0 +1,80 @@
+/**
+ * @file key.h
+ *
+ * Key encoding module - encode key events into terminal escape sequences.
+ */
+
+#ifndef GHOSTTY_VT_KEY_H
+#define GHOSTTY_VT_KEY_H
+
+/** @defgroup key Key Encoding
+ *
+ * Utilities for encoding key events into terminal escape sequences,
+ * supporting both legacy encoding as well as Kitty Keyboard Protocol.
+ *
+ * ## Basic Usage
+ *
+ * 1. Create an encoder instance with ghostty_key_encoder_new()
+ * 2. Configure encoder options with ghostty_key_encoder_setopt().
+ * 3. For each key event:
+ * - Create a key event with ghostty_key_event_new()
+ * - Set event properties (action, key, modifiers, etc.)
+ * - Encode with ghostty_key_encoder_encode()
+ * - Free the event with ghostty_key_event_free()
+ * - Note: You can also reuse the same key event multiple times by
+ * changing its properties.
+ * 4. Free the encoder with ghostty_key_encoder_free() when done
+ *
+ * ## Example
+ *
+ * @code{.c}
+ * #include
+ * #include
+ * #include
+ *
+ * int main() {
+ * // Create encoder
+ * GhosttyKeyEncoder encoder;
+ * GhosttyResult result = ghostty_key_encoder_new(NULL, &encoder);
+ * assert(result == GHOSTTY_SUCCESS);
+ *
+ * // Enable Kitty keyboard protocol with all features
+ * ghostty_key_encoder_setopt(encoder, GHOSTTY_KEY_ENCODER_OPT_KITTY_FLAGS,
+ * &(uint8_t){GHOSTTY_KITTY_KEY_ALL});
+ *
+ * // Create and configure key event for Ctrl+C press
+ * GhosttyKeyEvent event;
+ * result = ghostty_key_event_new(NULL, &event);
+ * assert(result == GHOSTTY_SUCCESS);
+ * ghostty_key_event_set_action(event, GHOSTTY_KEY_ACTION_PRESS);
+ * ghostty_key_event_set_key(event, GHOSTTY_KEY_C);
+ * ghostty_key_event_set_mods(event, GHOSTTY_MODS_CTRL);
+ *
+ * // Encode the key event
+ * char buf[128];
+ * size_t written = 0;
+ * result = ghostty_key_encoder_encode(encoder, event, buf, sizeof(buf), &written);
+ * assert(result == GHOSTTY_SUCCESS);
+ *
+ * // Use the encoded sequence (e.g., write to terminal)
+ * fwrite(buf, 1, written, stdout);
+ *
+ * // Cleanup
+ * ghostty_key_event_free(event);
+ * ghostty_key_encoder_free(encoder);
+ * return 0;
+ * }
+ * @endcode
+ *
+ * For a complete working example, see example/c-vt-key-encode in the
+ * repository.
+ *
+ * @{
+ */
+
+#include
+#include
+
+/** @} */
+
+#endif /* GHOSTTY_VT_KEY_H */
diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/key/encoder.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/key/encoder.h
new file mode 100644
index 00000000000..766a2942796
--- /dev/null
+++ b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/key/encoder.h
@@ -0,0 +1,221 @@
+/**
+ * @file encoder.h
+ *
+ * Key event encoding to terminal escape sequences.
+ */
+
+#ifndef GHOSTTY_VT_KEY_ENCODER_H
+#define GHOSTTY_VT_KEY_ENCODER_H
+
+#include
+#include
+#include
+#include
+#include
+
+/**
+ * Opaque handle to a key encoder instance.
+ *
+ * This handle represents a key encoder that converts key events into terminal
+ * escape sequences.
+ *
+ * @ingroup key
+ */
+typedef struct GhosttyKeyEncoder *GhosttyKeyEncoder;
+
+/**
+ * Kitty keyboard protocol flags.
+ *
+ * Bitflags representing the various modes of the Kitty keyboard protocol.
+ * These can be combined using bitwise OR operations. Valid values all
+ * start with `GHOSTTY_KITTY_KEY_`.
+ *
+ * @ingroup key
+ */
+typedef uint8_t GhosttyKittyKeyFlags;
+
+/** Kitty keyboard protocol disabled (all flags off) */
+#define GHOSTTY_KITTY_KEY_DISABLED 0
+
+/** Disambiguate escape codes */
+#define GHOSTTY_KITTY_KEY_DISAMBIGUATE (1 << 0)
+
+/** Report key press and release events */
+#define GHOSTTY_KITTY_KEY_REPORT_EVENTS (1 << 1)
+
+/** Report alternate key codes */
+#define GHOSTTY_KITTY_KEY_REPORT_ALTERNATES (1 << 2)
+
+/** Report all key events including those normally handled by the terminal */
+#define GHOSTTY_KITTY_KEY_REPORT_ALL (1 << 3)
+
+/** Report associated text with key events */
+#define GHOSTTY_KITTY_KEY_REPORT_ASSOCIATED (1 << 4)
+
+/** All Kitty keyboard protocol flags enabled */
+#define GHOSTTY_KITTY_KEY_ALL (GHOSTTY_KITTY_KEY_DISAMBIGUATE | GHOSTTY_KITTY_KEY_REPORT_EVENTS | GHOSTTY_KITTY_KEY_REPORT_ALTERNATES | GHOSTTY_KITTY_KEY_REPORT_ALL | GHOSTTY_KITTY_KEY_REPORT_ASSOCIATED)
+
+/**
+ * macOS option key behavior.
+ *
+ * Determines whether the "option" key on macOS is treated as "alt" or not.
+ * See the Ghostty `macos-option-as-alt` configuration option for more details.
+ *
+ * @ingroup key
+ */
+typedef enum {
+ /** Option key is not treated as alt */
+ GHOSTTY_OPTION_AS_ALT_FALSE = 0,
+ /** Option key is treated as alt */
+ GHOSTTY_OPTION_AS_ALT_TRUE = 1,
+ /** Only left option key is treated as alt */
+ GHOSTTY_OPTION_AS_ALT_LEFT = 2,
+ /** Only right option key is treated as alt */
+ GHOSTTY_OPTION_AS_ALT_RIGHT = 3,
+} GhosttyOptionAsAlt;
+
+/**
+ * Key encoder option identifiers.
+ *
+ * These values are used with ghostty_key_encoder_setopt() to configure
+ * the behavior of the key encoder.
+ *
+ * @ingroup key
+ */
+typedef enum {
+ /** Terminal DEC mode 1: cursor key application mode (value: bool) */
+ GHOSTTY_KEY_ENCODER_OPT_CURSOR_KEY_APPLICATION = 0,
+
+ /** Terminal DEC mode 66: keypad key application mode (value: bool) */
+ GHOSTTY_KEY_ENCODER_OPT_KEYPAD_KEY_APPLICATION = 1,
+
+ /** Terminal DEC mode 1035: ignore keypad with numlock (value: bool) */
+ GHOSTTY_KEY_ENCODER_OPT_IGNORE_KEYPAD_WITH_NUMLOCK = 2,
+
+ /** Terminal DEC mode 1036: alt sends escape prefix (value: bool) */
+ GHOSTTY_KEY_ENCODER_OPT_ALT_ESC_PREFIX = 3,
+
+ /** xterm modifyOtherKeys mode 2 (value: bool) */
+ GHOSTTY_KEY_ENCODER_OPT_MODIFY_OTHER_KEYS_STATE_2 = 4,
+
+ /** Kitty keyboard protocol flags (value: GhosttyKittyKeyFlags bitmask) */
+ GHOSTTY_KEY_ENCODER_OPT_KITTY_FLAGS = 5,
+
+ /** macOS option-as-alt setting (value: GhosttyOptionAsAlt) */
+ GHOSTTY_KEY_ENCODER_OPT_MACOS_OPTION_AS_ALT = 6,
+} GhosttyKeyEncoderOption;
+
+/**
+ * Create a new key encoder instance.
+ *
+ * Creates a new key encoder with default options. The encoder can be configured
+ * using ghostty_key_encoder_setopt() and must be freed using
+ * ghostty_key_encoder_free() when no longer needed.
+ *
+ * @param allocator Pointer to the allocator to use for memory management, or NULL to use the default allocator
+ * @param encoder Pointer to store the created encoder handle
+ * @return GHOSTTY_SUCCESS on success, or an error code on failure
+ *
+ * @ingroup key
+ */
+GhosttyResult ghostty_key_encoder_new(const GhosttyAllocator *allocator, GhosttyKeyEncoder *encoder);
+
+/**
+ * Free a key encoder instance.
+ *
+ * Releases all resources associated with the key encoder. After this call,
+ * the encoder handle becomes invalid and must not be used.
+ *
+ * @param encoder The encoder handle to free (may be NULL)
+ *
+ * @ingroup key
+ */
+void ghostty_key_encoder_free(GhosttyKeyEncoder encoder);
+
+/**
+ * Set an option on the key encoder.
+ *
+ * Configures the behavior of the key encoder. Options control various aspects
+ * of encoding such as terminal modes (cursor key application mode, keypad mode),
+ * protocol selection (Kitty keyboard protocol flags), and platform-specific
+ * behaviors (macOS option-as-alt).
+ *
+ * A null pointer value does nothing. It does not reset the value to the
+ * default. The setopt call will do nothing.
+ *
+ * @param encoder The encoder handle, must not be NULL
+ * @param option The option to set
+ * @param value Pointer to the value to set (type depends on the option)
+ *
+ * @ingroup key
+ */
+void ghostty_key_encoder_setopt(GhosttyKeyEncoder encoder, GhosttyKeyEncoderOption option, const void *value);
+
+/**
+ * Encode a key event into a terminal escape sequence.
+ *
+ * Converts a key event into the appropriate terminal escape sequence based on
+ * the encoder's current options. The sequence is written to the provided buffer.
+ *
+ * Not all key events produce output. For example, unmodified modifier keys
+ * typically don't generate escape sequences. Check the out_len parameter to
+ * determine if any data was written.
+ *
+ * If the output buffer is too small, this function returns GHOSTTY_OUT_OF_MEMORY
+ * and out_len will contain the required buffer size. The caller can then
+ * allocate a larger buffer and call the function again.
+ *
+ * @param encoder The encoder handle, must not be NULL
+ * @param event The key event to encode, must not be NULL
+ * @param out_buf Buffer to write the encoded sequence to
+ * @param out_buf_size Size of the output buffer in bytes
+ * @param out_len Pointer to store the number of bytes written (may be NULL)
+ * @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_MEMORY if buffer too small, or other error code
+ *
+ * ## Example: Calculate required buffer size
+ *
+ * @code{.c}
+ * // Query the required size with a NULL buffer (always returns OUT_OF_MEMORY)
+ * size_t required = 0;
+ * GhosttyResult result = ghostty_key_encoder_encode(encoder, event, NULL, 0, &required);
+ * assert(result == GHOSTTY_OUT_OF_MEMORY);
+ *
+ * // Allocate buffer of required size
+ * char *buf = malloc(required);
+ *
+ * // Encode with properly sized buffer
+ * size_t written = 0;
+ * result = ghostty_key_encoder_encode(encoder, event, buf, required, &written);
+ * assert(result == GHOSTTY_SUCCESS);
+ *
+ * // Use the encoded sequence...
+ *
+ * free(buf);
+ * @endcode
+ *
+ * ## Example: Direct encoding with static buffer
+ *
+ * @code{.c}
+ * // Most escape sequences are short, so a static buffer often suffices
+ * char buf[128];
+ * size_t written = 0;
+ * GhosttyResult result = ghostty_key_encoder_encode(encoder, event, buf, sizeof(buf), &written);
+ *
+ * if (result == GHOSTTY_SUCCESS) {
+ * // Write the encoded sequence to the terminal
+ * write(pty_fd, buf, written);
+ * } else if (result == GHOSTTY_OUT_OF_MEMORY) {
+ * // Buffer too small, written contains required size
+ * char *dynamic_buf = malloc(written);
+ * result = ghostty_key_encoder_encode(encoder, event, dynamic_buf, written, &written);
+ * assert(result == GHOSTTY_SUCCESS);
+ * write(pty_fd, dynamic_buf, written);
+ * free(dynamic_buf);
+ * }
+ * @endcode
+ *
+ * @ingroup key
+ */
+GhosttyResult ghostty_key_encoder_encode(GhosttyKeyEncoder encoder, GhosttyKeyEvent event, char *out_buf, size_t out_buf_size, size_t *out_len);
+
+#endif /* GHOSTTY_VT_KEY_ENCODER_H */
diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/key/event.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/key/event.h
new file mode 100644
index 00000000000..dbd2e9f841a
--- /dev/null
+++ b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/key/event.h
@@ -0,0 +1,474 @@
+/**
+ * @file event.h
+ *
+ * Key event representation and manipulation.
+ */
+
+#ifndef GHOSTTY_VT_KEY_EVENT_H
+#define GHOSTTY_VT_KEY_EVENT_H
+
+#include
+#include
+#include
+#include
+#include
+
+/**
+ * Opaque handle to a key event.
+ *
+ * This handle represents a keyboard input event containing information about
+ * the physical key pressed, modifiers, and generated text.
+ *
+ * @ingroup key
+ */
+typedef struct GhosttyKeyEvent *GhosttyKeyEvent;
+
+/**
+ * Keyboard input event types.
+ *
+ * @ingroup key
+ */
+typedef enum {
+ /** Key was released */
+ GHOSTTY_KEY_ACTION_RELEASE = 0,
+ /** Key was pressed */
+ GHOSTTY_KEY_ACTION_PRESS = 1,
+ /** Key is being repeated (held down) */
+ GHOSTTY_KEY_ACTION_REPEAT = 2,
+} GhosttyKeyAction;
+
+/**
+ * Keyboard modifier keys bitmask.
+ *
+ * A bitmask representing all keyboard modifiers. This tracks which modifier keys
+ * are pressed and, where supported by the platform, which side (left or right)
+ * of each modifier is active.
+ *
+ * Use the GHOSTTY_MODS_* constants to test and set individual modifiers.
+ *
+ * Modifier side bits are only meaningful when the corresponding modifier bit is set.
+ * Not all platforms support distinguishing between left and right modifier
+ * keys and Ghostty is built to expect that some platforms may not provide this
+ * information.
+ *
+ * @ingroup key
+ */
+typedef uint16_t GhosttyMods;
+
+/** Shift key is pressed */
+#define GHOSTTY_MODS_SHIFT (1 << 0)
+/** Control key is pressed */
+#define GHOSTTY_MODS_CTRL (1 << 1)
+/** Alt/Option key is pressed */
+#define GHOSTTY_MODS_ALT (1 << 2)
+/** Super/Command/Windows key is pressed */
+#define GHOSTTY_MODS_SUPER (1 << 3)
+/** Caps Lock is active */
+#define GHOSTTY_MODS_CAPS_LOCK (1 << 4)
+/** Num Lock is active */
+#define GHOSTTY_MODS_NUM_LOCK (1 << 5)
+
+/**
+ * Right shift is pressed (0 = left, 1 = right).
+ * Only meaningful when GHOSTTY_MODS_SHIFT is set.
+ */
+#define GHOSTTY_MODS_SHIFT_SIDE (1 << 6)
+/**
+ * Right ctrl is pressed (0 = left, 1 = right).
+ * Only meaningful when GHOSTTY_MODS_CTRL is set.
+ */
+#define GHOSTTY_MODS_CTRL_SIDE (1 << 7)
+/**
+ * Right alt is pressed (0 = left, 1 = right).
+ * Only meaningful when GHOSTTY_MODS_ALT is set.
+ */
+#define GHOSTTY_MODS_ALT_SIDE (1 << 8)
+/**
+ * Right super is pressed (0 = left, 1 = right).
+ * Only meaningful when GHOSTTY_MODS_SUPER is set.
+ */
+#define GHOSTTY_MODS_SUPER_SIDE (1 << 9)
+
+/**
+ * Physical key codes.
+ *
+ * The set of key codes that Ghostty is aware of. These represent physical keys
+ * on the keyboard and are layout-independent. For example, the "a" key on a US
+ * keyboard is the same as the "ф" key on a Russian keyboard, but both will
+ * report the same key_a value.
+ *
+ * Layout-dependent strings are provided separately as UTF-8 text and are produced
+ * by the platform. These values are based on the W3C UI Events KeyboardEvent code
+ * standard. See: https://www.w3.org/TR/uievents-code
+ *
+ * @ingroup key
+ */
+typedef enum {
+ GHOSTTY_KEY_UNIDENTIFIED = 0,
+
+ // Writing System Keys (W3C § 3.1.1)
+ GHOSTTY_KEY_BACKQUOTE,
+ GHOSTTY_KEY_BACKSLASH,
+ GHOSTTY_KEY_BRACKET_LEFT,
+ GHOSTTY_KEY_BRACKET_RIGHT,
+ GHOSTTY_KEY_COMMA,
+ GHOSTTY_KEY_DIGIT_0,
+ GHOSTTY_KEY_DIGIT_1,
+ GHOSTTY_KEY_DIGIT_2,
+ GHOSTTY_KEY_DIGIT_3,
+ GHOSTTY_KEY_DIGIT_4,
+ GHOSTTY_KEY_DIGIT_5,
+ GHOSTTY_KEY_DIGIT_6,
+ GHOSTTY_KEY_DIGIT_7,
+ GHOSTTY_KEY_DIGIT_8,
+ GHOSTTY_KEY_DIGIT_9,
+ GHOSTTY_KEY_EQUAL,
+ GHOSTTY_KEY_INTL_BACKSLASH,
+ GHOSTTY_KEY_INTL_RO,
+ GHOSTTY_KEY_INTL_YEN,
+ GHOSTTY_KEY_A,
+ GHOSTTY_KEY_B,
+ GHOSTTY_KEY_C,
+ GHOSTTY_KEY_D,
+ GHOSTTY_KEY_E,
+ GHOSTTY_KEY_F,
+ GHOSTTY_KEY_G,
+ GHOSTTY_KEY_H,
+ GHOSTTY_KEY_I,
+ GHOSTTY_KEY_J,
+ GHOSTTY_KEY_K,
+ GHOSTTY_KEY_L,
+ GHOSTTY_KEY_M,
+ GHOSTTY_KEY_N,
+ GHOSTTY_KEY_O,
+ GHOSTTY_KEY_P,
+ GHOSTTY_KEY_Q,
+ GHOSTTY_KEY_R,
+ GHOSTTY_KEY_S,
+ GHOSTTY_KEY_T,
+ GHOSTTY_KEY_U,
+ GHOSTTY_KEY_V,
+ GHOSTTY_KEY_W,
+ GHOSTTY_KEY_X,
+ GHOSTTY_KEY_Y,
+ GHOSTTY_KEY_Z,
+ GHOSTTY_KEY_MINUS,
+ GHOSTTY_KEY_PERIOD,
+ GHOSTTY_KEY_QUOTE,
+ GHOSTTY_KEY_SEMICOLON,
+ GHOSTTY_KEY_SLASH,
+
+ // Functional Keys (W3C § 3.1.2)
+ GHOSTTY_KEY_ALT_LEFT,
+ GHOSTTY_KEY_ALT_RIGHT,
+ GHOSTTY_KEY_BACKSPACE,
+ GHOSTTY_KEY_CAPS_LOCK,
+ GHOSTTY_KEY_CONTEXT_MENU,
+ GHOSTTY_KEY_CONTROL_LEFT,
+ GHOSTTY_KEY_CONTROL_RIGHT,
+ GHOSTTY_KEY_ENTER,
+ GHOSTTY_KEY_META_LEFT,
+ GHOSTTY_KEY_META_RIGHT,
+ GHOSTTY_KEY_SHIFT_LEFT,
+ GHOSTTY_KEY_SHIFT_RIGHT,
+ GHOSTTY_KEY_SPACE,
+ GHOSTTY_KEY_TAB,
+ GHOSTTY_KEY_CONVERT,
+ GHOSTTY_KEY_KANA_MODE,
+ GHOSTTY_KEY_NON_CONVERT,
+
+ // Control Pad Section (W3C § 3.2)
+ GHOSTTY_KEY_DELETE,
+ GHOSTTY_KEY_END,
+ GHOSTTY_KEY_HELP,
+ GHOSTTY_KEY_HOME,
+ GHOSTTY_KEY_INSERT,
+ GHOSTTY_KEY_PAGE_DOWN,
+ GHOSTTY_KEY_PAGE_UP,
+
+ // Arrow Pad Section (W3C § 3.3)
+ GHOSTTY_KEY_ARROW_DOWN,
+ GHOSTTY_KEY_ARROW_LEFT,
+ GHOSTTY_KEY_ARROW_RIGHT,
+ GHOSTTY_KEY_ARROW_UP,
+
+ // Numpad Section (W3C § 3.4)
+ GHOSTTY_KEY_NUM_LOCK,
+ GHOSTTY_KEY_NUMPAD_0,
+ GHOSTTY_KEY_NUMPAD_1,
+ GHOSTTY_KEY_NUMPAD_2,
+ GHOSTTY_KEY_NUMPAD_3,
+ GHOSTTY_KEY_NUMPAD_4,
+ GHOSTTY_KEY_NUMPAD_5,
+ GHOSTTY_KEY_NUMPAD_6,
+ GHOSTTY_KEY_NUMPAD_7,
+ GHOSTTY_KEY_NUMPAD_8,
+ GHOSTTY_KEY_NUMPAD_9,
+ GHOSTTY_KEY_NUMPAD_ADD,
+ GHOSTTY_KEY_NUMPAD_BACKSPACE,
+ GHOSTTY_KEY_NUMPAD_CLEAR,
+ GHOSTTY_KEY_NUMPAD_CLEAR_ENTRY,
+ GHOSTTY_KEY_NUMPAD_COMMA,
+ GHOSTTY_KEY_NUMPAD_DECIMAL,
+ GHOSTTY_KEY_NUMPAD_DIVIDE,
+ GHOSTTY_KEY_NUMPAD_ENTER,
+ GHOSTTY_KEY_NUMPAD_EQUAL,
+ GHOSTTY_KEY_NUMPAD_MEMORY_ADD,
+ GHOSTTY_KEY_NUMPAD_MEMORY_CLEAR,
+ GHOSTTY_KEY_NUMPAD_MEMORY_RECALL,
+ GHOSTTY_KEY_NUMPAD_MEMORY_STORE,
+ GHOSTTY_KEY_NUMPAD_MEMORY_SUBTRACT,
+ GHOSTTY_KEY_NUMPAD_MULTIPLY,
+ GHOSTTY_KEY_NUMPAD_PAREN_LEFT,
+ GHOSTTY_KEY_NUMPAD_PAREN_RIGHT,
+ GHOSTTY_KEY_NUMPAD_SUBTRACT,
+ GHOSTTY_KEY_NUMPAD_SEPARATOR,
+ GHOSTTY_KEY_NUMPAD_UP,
+ GHOSTTY_KEY_NUMPAD_DOWN,
+ GHOSTTY_KEY_NUMPAD_RIGHT,
+ GHOSTTY_KEY_NUMPAD_LEFT,
+ GHOSTTY_KEY_NUMPAD_BEGIN,
+ GHOSTTY_KEY_NUMPAD_HOME,
+ GHOSTTY_KEY_NUMPAD_END,
+ GHOSTTY_KEY_NUMPAD_INSERT,
+ GHOSTTY_KEY_NUMPAD_DELETE,
+ GHOSTTY_KEY_NUMPAD_PAGE_UP,
+ GHOSTTY_KEY_NUMPAD_PAGE_DOWN,
+
+ // Function Section (W3C § 3.5)
+ GHOSTTY_KEY_ESCAPE,
+ GHOSTTY_KEY_F1,
+ GHOSTTY_KEY_F2,
+ GHOSTTY_KEY_F3,
+ GHOSTTY_KEY_F4,
+ GHOSTTY_KEY_F5,
+ GHOSTTY_KEY_F6,
+ GHOSTTY_KEY_F7,
+ GHOSTTY_KEY_F8,
+ GHOSTTY_KEY_F9,
+ GHOSTTY_KEY_F10,
+ GHOSTTY_KEY_F11,
+ GHOSTTY_KEY_F12,
+ GHOSTTY_KEY_F13,
+ GHOSTTY_KEY_F14,
+ GHOSTTY_KEY_F15,
+ GHOSTTY_KEY_F16,
+ GHOSTTY_KEY_F17,
+ GHOSTTY_KEY_F18,
+ GHOSTTY_KEY_F19,
+ GHOSTTY_KEY_F20,
+ GHOSTTY_KEY_F21,
+ GHOSTTY_KEY_F22,
+ GHOSTTY_KEY_F23,
+ GHOSTTY_KEY_F24,
+ GHOSTTY_KEY_F25,
+ GHOSTTY_KEY_FN,
+ GHOSTTY_KEY_FN_LOCK,
+ GHOSTTY_KEY_PRINT_SCREEN,
+ GHOSTTY_KEY_SCROLL_LOCK,
+ GHOSTTY_KEY_PAUSE,
+
+ // Media Keys (W3C § 3.6)
+ GHOSTTY_KEY_BROWSER_BACK,
+ GHOSTTY_KEY_BROWSER_FAVORITES,
+ GHOSTTY_KEY_BROWSER_FORWARD,
+ GHOSTTY_KEY_BROWSER_HOME,
+ GHOSTTY_KEY_BROWSER_REFRESH,
+ GHOSTTY_KEY_BROWSER_SEARCH,
+ GHOSTTY_KEY_BROWSER_STOP,
+ GHOSTTY_KEY_EJECT,
+ GHOSTTY_KEY_LAUNCH_APP_1,
+ GHOSTTY_KEY_LAUNCH_APP_2,
+ GHOSTTY_KEY_LAUNCH_MAIL,
+ GHOSTTY_KEY_MEDIA_PLAY_PAUSE,
+ GHOSTTY_KEY_MEDIA_SELECT,
+ GHOSTTY_KEY_MEDIA_STOP,
+ GHOSTTY_KEY_MEDIA_TRACK_NEXT,
+ GHOSTTY_KEY_MEDIA_TRACK_PREVIOUS,
+ GHOSTTY_KEY_POWER,
+ GHOSTTY_KEY_SLEEP,
+ GHOSTTY_KEY_AUDIO_VOLUME_DOWN,
+ GHOSTTY_KEY_AUDIO_VOLUME_MUTE,
+ GHOSTTY_KEY_AUDIO_VOLUME_UP,
+ GHOSTTY_KEY_WAKE_UP,
+
+ // Legacy, Non-standard, and Special Keys (W3C § 3.7)
+ GHOSTTY_KEY_COPY,
+ GHOSTTY_KEY_CUT,
+ GHOSTTY_KEY_PASTE,
+} GhosttyKey;
+
+/**
+ * Create a new key event instance.
+ *
+ * Creates a new key event with default values. The event must be freed using
+ * ghostty_key_event_free() when no longer needed.
+ *
+ * @param allocator Pointer to the allocator to use for memory management, or NULL to use the default allocator
+ * @param event Pointer to store the created key event handle
+ * @return GHOSTTY_SUCCESS on success, or an error code on failure
+ *
+ * @ingroup key
+ */
+GhosttyResult ghostty_key_event_new(const GhosttyAllocator *allocator, GhosttyKeyEvent *event);
+
+/**
+ * Free a key event instance.
+ *
+ * Releases all resources associated with the key event. After this call,
+ * the event handle becomes invalid and must not be used.
+ *
+ * @param event The key event handle to free (may be NULL)
+ *
+ * @ingroup key
+ */
+void ghostty_key_event_free(GhosttyKeyEvent event);
+
+/**
+ * Set the key action (press, release, repeat).
+ *
+ * @param event The key event handle, must not be NULL
+ * @param action The action to set
+ *
+ * @ingroup key
+ */
+void ghostty_key_event_set_action(GhosttyKeyEvent event, GhosttyKeyAction action);
+
+/**
+ * Get the key action (press, release, repeat).
+ *
+ * @param event The key event handle, must not be NULL
+ * @return The key action
+ *
+ * @ingroup key
+ */
+GhosttyKeyAction ghostty_key_event_get_action(GhosttyKeyEvent event);
+
+/**
+ * Set the physical key code.
+ *
+ * @param event The key event handle, must not be NULL
+ * @param key The physical key code to set
+ *
+ * @ingroup key
+ */
+void ghostty_key_event_set_key(GhosttyKeyEvent event, GhosttyKey key);
+
+/**
+ * Get the physical key code.
+ *
+ * @param event The key event handle, must not be NULL
+ * @return The physical key code
+ *
+ * @ingroup key
+ */
+GhosttyKey ghostty_key_event_get_key(GhosttyKeyEvent event);
+
+/**
+ * Set the modifier keys bitmask.
+ *
+ * @param event The key event handle, must not be NULL
+ * @param mods The modifier keys bitmask to set
+ *
+ * @ingroup key
+ */
+void ghostty_key_event_set_mods(GhosttyKeyEvent event, GhosttyMods mods);
+
+/**
+ * Get the modifier keys bitmask.
+ *
+ * @param event The key event handle, must not be NULL
+ * @return The modifier keys bitmask
+ *
+ * @ingroup key
+ */
+GhosttyMods ghostty_key_event_get_mods(GhosttyKeyEvent event);
+
+/**
+ * Set the consumed modifiers bitmask.
+ *
+ * @param event The key event handle, must not be NULL
+ * @param consumed_mods The consumed modifiers bitmask to set
+ *
+ * @ingroup key
+ */
+void ghostty_key_event_set_consumed_mods(GhosttyKeyEvent event, GhosttyMods consumed_mods);
+
+/**
+ * Get the consumed modifiers bitmask.
+ *
+ * @param event The key event handle, must not be NULL
+ * @return The consumed modifiers bitmask
+ *
+ * @ingroup key
+ */
+GhosttyMods ghostty_key_event_get_consumed_mods(GhosttyKeyEvent event);
+
+/**
+ * Set whether the key event is part of a composition sequence.
+ *
+ * @param event The key event handle, must not be NULL
+ * @param composing Whether the key event is part of a composition sequence
+ *
+ * @ingroup key
+ */
+void ghostty_key_event_set_composing(GhosttyKeyEvent event, bool composing);
+
+/**
+ * Get whether the key event is part of a composition sequence.
+ *
+ * @param event The key event handle, must not be NULL
+ * @return Whether the key event is part of a composition sequence
+ *
+ * @ingroup key
+ */
+bool ghostty_key_event_get_composing(GhosttyKeyEvent event);
+
+/**
+ * Set the UTF-8 text generated by the key event.
+ *
+ * The key event does NOT take ownership of the text pointer. The caller
+ * must ensure the string remains valid for the lifetime needed by the event.
+ *
+ * @param event The key event handle, must not be NULL
+ * @param utf8 The UTF-8 text to set (or NULL for empty)
+ * @param len Length of the UTF-8 text in bytes
+ *
+ * @ingroup key
+ */
+void ghostty_key_event_set_utf8(GhosttyKeyEvent event, const char *utf8, size_t len);
+
+/**
+ * Get the UTF-8 text generated by the key event.
+ *
+ * The returned pointer is valid until the event is freed or the UTF-8 text is modified.
+ *
+ * @param event The key event handle, must not be NULL
+ * @param len Pointer to store the length of the UTF-8 text in bytes (may be NULL)
+ * @return The UTF-8 text (or NULL for empty)
+ *
+ * @ingroup key
+ */
+const char *ghostty_key_event_get_utf8(GhosttyKeyEvent event, size_t *len);
+
+/**
+ * Set the unshifted Unicode codepoint.
+ *
+ * @param event The key event handle, must not be NULL
+ * @param codepoint The unshifted Unicode codepoint to set
+ *
+ * @ingroup key
+ */
+void ghostty_key_event_set_unshifted_codepoint(GhosttyKeyEvent event, uint32_t codepoint);
+
+/**
+ * Get the unshifted Unicode codepoint.
+ *
+ * @param event The key event handle, must not be NULL
+ * @return The unshifted Unicode codepoint
+ *
+ * @ingroup key
+ */
+uint32_t ghostty_key_event_get_unshifted_codepoint(GhosttyKeyEvent event);
+
+#endif /* GHOSTTY_VT_KEY_EVENT_H */
diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/osc.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/osc.h
new file mode 100644
index 00000000000..f53077ab326
--- /dev/null
+++ b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/osc.h
@@ -0,0 +1,233 @@
+/**
+ * @file osc.h
+ *
+ * OSC (Operating System Command) sequence parser and command handling.
+ */
+
+#ifndef GHOSTTY_VT_OSC_H
+#define GHOSTTY_VT_OSC_H
+
+#include
+#include
+#include
+#include
+#include
+
+/**
+ * Opaque handle to an OSC parser instance.
+ *
+ * This handle represents an OSC (Operating System Command) parser that can
+ * be used to parse the contents of OSC sequences.
+ *
+ * @ingroup osc
+ */
+typedef struct GhosttyOscParser *GhosttyOscParser;
+
+/**
+ * Opaque handle to a single OSC command.
+ *
+ * This handle represents a parsed OSC (Operating System Command) command.
+ * The command can be queried for its type and associated data.
+ *
+ * @ingroup osc
+ */
+typedef struct GhosttyOscCommand *GhosttyOscCommand;
+
+/** @defgroup osc OSC Parser
+ *
+ * OSC (Operating System Command) sequence parser and command handling.
+ *
+ * The parser operates in a streaming fashion, processing input byte-by-byte
+ * to handle OSC sequences that may arrive in fragments across multiple reads.
+ * This interface makes it easy to integrate into most environments and avoids
+ * over-allocating buffers.
+ *
+ * ## Basic Usage
+ *
+ * 1. Create a parser instance with ghostty_osc_new()
+ * 2. Feed bytes to the parser using ghostty_osc_next()
+ * 3. Finalize parsing with ghostty_osc_end() to get the command
+ * 4. Query command type and extract data using ghostty_osc_command_type()
+ * and ghostty_osc_command_data()
+ * 5. Free the parser with ghostty_osc_free() when done
+ *
+ * @{
+ */
+
+/**
+ * OSC command types.
+ *
+ * @ingroup osc
+ */
+typedef enum {
+ GHOSTTY_OSC_COMMAND_INVALID = 0,
+ GHOSTTY_OSC_COMMAND_CHANGE_WINDOW_TITLE = 1,
+ GHOSTTY_OSC_COMMAND_CHANGE_WINDOW_ICON = 2,
+ GHOSTTY_OSC_COMMAND_SEMANTIC_PROMPT = 3,
+ GHOSTTY_OSC_COMMAND_CLIPBOARD_CONTENTS = 4,
+ GHOSTTY_OSC_COMMAND_REPORT_PWD = 5,
+ GHOSTTY_OSC_COMMAND_MOUSE_SHAPE = 6,
+ GHOSTTY_OSC_COMMAND_COLOR_OPERATION = 7,
+ GHOSTTY_OSC_COMMAND_KITTY_COLOR_PROTOCOL = 8,
+ GHOSTTY_OSC_COMMAND_SHOW_DESKTOP_NOTIFICATION = 9,
+ GHOSTTY_OSC_COMMAND_HYPERLINK_START = 10,
+ GHOSTTY_OSC_COMMAND_HYPERLINK_END = 11,
+ GHOSTTY_OSC_COMMAND_CONEMU_SLEEP = 12,
+ GHOSTTY_OSC_COMMAND_CONEMU_SHOW_MESSAGE_BOX = 13,
+ GHOSTTY_OSC_COMMAND_CONEMU_CHANGE_TAB_TITLE = 14,
+ GHOSTTY_OSC_COMMAND_CONEMU_PROGRESS_REPORT = 15,
+ GHOSTTY_OSC_COMMAND_CONEMU_WAIT_INPUT = 16,
+ GHOSTTY_OSC_COMMAND_CONEMU_GUIMACRO = 17,
+ GHOSTTY_OSC_COMMAND_CONEMU_RUN_PROCESS = 18,
+ GHOSTTY_OSC_COMMAND_CONEMU_OUTPUT_ENVIRONMENT_VARIABLE = 19,
+ GHOSTTY_OSC_COMMAND_CONEMU_XTERM_EMULATION = 20,
+ GHOSTTY_OSC_COMMAND_CONEMU_COMMENT = 21,
+ GHOSTTY_OSC_COMMAND_KITTY_TEXT_SIZING = 22,
+} GhosttyOscCommandType;
+
+/**
+ * OSC command data types.
+ *
+ * These values specify what type of data to extract from an OSC command
+ * using `ghostty_osc_command_data`.
+ *
+ * @ingroup osc
+ */
+typedef enum {
+ /** Invalid data type. Never results in any data extraction. */
+ GHOSTTY_OSC_DATA_INVALID = 0,
+
+ /**
+ * Window title string data.
+ *
+ * Valid for: GHOSTTY_OSC_COMMAND_CHANGE_WINDOW_TITLE
+ *
+ * Output type: const char ** (pointer to null-terminated string)
+ *
+ * Lifetime: Valid until the next call to any ghostty_osc_* function with
+ * the same parser instance. Memory is owned by the parser.
+ */
+ GHOSTTY_OSC_DATA_CHANGE_WINDOW_TITLE_STR = 1,
+} GhosttyOscCommandData;
+
+/**
+ * Create a new OSC parser instance.
+ *
+ * Creates a new OSC (Operating System Command) parser using the provided
+ * allocator. The parser must be freed using ghostty_vt_osc_free() when
+ * no longer needed.
+ *
+ * @param allocator Pointer to the allocator to use for memory management, or NULL to use the default allocator
+ * @param parser Pointer to store the created parser handle
+ * @return GHOSTTY_SUCCESS on success, or an error code on failure
+ *
+ * @ingroup osc
+ */
+GhosttyResult ghostty_osc_new(const GhosttyAllocator *allocator, GhosttyOscParser *parser);
+
+/**
+ * Free an OSC parser instance.
+ *
+ * Releases all resources associated with the OSC parser. After this call,
+ * the parser handle becomes invalid and must not be used.
+ *
+ * @param parser The parser handle to free (may be NULL)
+ *
+ * @ingroup osc
+ */
+void ghostty_osc_free(GhosttyOscParser parser);
+
+/**
+ * Reset an OSC parser instance to its initial state.
+ *
+ * Resets the parser state, clearing any partially parsed OSC sequences
+ * and returning the parser to its initial state. This is useful for
+ * reusing a parser instance or recovering from parse errors.
+ *
+ * @param parser The parser handle to reset, must not be null.
+ *
+ * @ingroup osc
+ */
+void ghostty_osc_reset(GhosttyOscParser parser);
+
+/**
+ * Parse the next byte in an OSC sequence.
+ *
+ * Processes a single byte as part of an OSC sequence. The parser maintains
+ * internal state to track the progress through the sequence. Call this
+ * function for each byte in the sequence data.
+ *
+ * When finished pumping the parser with bytes, call ghostty_osc_end
+ * to get the final result.
+ *
+ * @param parser The parser handle, must not be null.
+ * @param byte The next byte to parse
+ *
+ * @ingroup osc
+ */
+void ghostty_osc_next(GhosttyOscParser parser, uint8_t byte);
+
+/**
+ * Finalize OSC parsing and retrieve the parsed command.
+ *
+ * Call this function after feeding all bytes of an OSC sequence to the parser
+ * using ghostty_osc_next() with the exception of the terminating character
+ * (ESC or ST). This function finalizes the parsing process and returns the
+ * parsed OSC command.
+ *
+ * The return value is never NULL. Invalid commands will return a command
+ * with type GHOSTTY_OSC_COMMAND_INVALID.
+ *
+ * The terminator parameter specifies the byte that terminated the OSC sequence
+ * (typically 0x07 for BEL or 0x5C for ST after ESC). This information is
+ * preserved in the parsed command so that responses can use the same terminator
+ * format for better compatibility with the calling program. For commands that
+ * do not require a response, this parameter is ignored and the resulting
+ * command will not retain the terminator information.
+ *
+ * The returned command handle is valid until the next call to any
+ * `ghostty_osc_*` function with the same parser instance with the exception
+ * of command introspection functions such as `ghostty_osc_command_type`.
+ *
+ * @param parser The parser handle, must not be null.
+ * @param terminator The terminating byte of the OSC sequence (0x07 for BEL, 0x5C for ST)
+ * @return Handle to the parsed OSC command
+ *
+ * @ingroup osc
+ */
+GhosttyOscCommand ghostty_osc_end(GhosttyOscParser parser, uint8_t terminator);
+
+/**
+ * Get the type of an OSC command.
+ *
+ * Returns the type identifier for the given OSC command. This can be used
+ * to determine what kind of command was parsed and what data might be
+ * available from it.
+ *
+ * @param command The OSC command handle to query (may be NULL)
+ * @return The command type, or GHOSTTY_OSC_COMMAND_INVALID if command is NULL
+ *
+ * @ingroup osc
+ */
+GhosttyOscCommandType ghostty_osc_command_type(GhosttyOscCommand command);
+
+/**
+ * Extract data from an OSC command.
+ *
+ * Extracts typed data from the given OSC command based on the specified
+ * data type. The output pointer must be of the appropriate type for the
+ * requested data kind. Valid command types, output types, and memory
+ * safety information are documented in the `GhosttyOscCommandData` enum.
+ *
+ * @param command The OSC command handle to query (may be NULL)
+ * @param data The type of data to extract
+ * @param out Pointer to store the extracted data (type depends on data parameter)
+ * @return true if data extraction was successful, false otherwise
+ *
+ * @ingroup osc
+ */
+bool ghostty_osc_command_data(GhosttyOscCommand command, GhosttyOscCommandData data, void *out);
+
+/** @} */
+
+#endif /* GHOSTTY_VT_OSC_H */
diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/paste.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/paste.h
new file mode 100644
index 00000000000..d90f303d43e
--- /dev/null
+++ b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/paste.h
@@ -0,0 +1,75 @@
+/**
+ * @file paste.h
+ *
+ * Paste utilities - validate and encode paste data for terminal input.
+ */
+
+#ifndef GHOSTTY_VT_PASTE_H
+#define GHOSTTY_VT_PASTE_H
+
+/** @defgroup paste Paste Utilities
+ *
+ * Utilities for validating paste data safety.
+ *
+ * ## Basic Usage
+ *
+ * Use ghostty_paste_is_safe() to check if paste data contains potentially
+ * dangerous sequences before sending it to the terminal.
+ *
+ * ## Example
+ *
+ * @code{.c}
+ * #include
+ * #include
+ * #include
+ *
+ * int main() {
+ * const char* safe_data = "hello world";
+ * const char* unsafe_data = "rm -rf /\n";
+ *
+ * if (ghostty_paste_is_safe(safe_data, strlen(safe_data))) {
+ * printf("Safe to paste\n");
+ * }
+ *
+ * if (!ghostty_paste_is_safe(unsafe_data, strlen(unsafe_data))) {
+ * printf("Unsafe! Contains newline\n");
+ * }
+ *
+ * return 0;
+ * }
+ * @endcode
+ *
+ * @{
+ */
+
+#include
+#include
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/**
+ * Check if paste data is safe to paste into the terminal.
+ *
+ * Data is considered unsafe if it contains:
+ * - Newlines (`\n`) which can inject commands
+ * - The bracketed paste end sequence (`\x1b[201~`) which can be used
+ * to exit bracketed paste mode and inject commands
+ *
+ * This check is conservative and considers data unsafe regardless of
+ * current terminal state.
+ *
+ * @param data The paste data to check (must not be NULL)
+ * @param len The length of the data in bytes
+ * @return true if the data is safe to paste, false otherwise
+ */
+bool ghostty_paste_is_safe(const char* data, size_t len);
+
+#ifdef __cplusplus
+}
+#endif
+
+/** @} */
+
+#endif /* GHOSTTY_VT_PASTE_H */
diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/result.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/result.h
new file mode 100644
index 00000000000..65938ee766f
--- /dev/null
+++ b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/result.h
@@ -0,0 +1,22 @@
+/**
+ * @file result.h
+ *
+ * Result codes for libghostty-vt operations.
+ */
+
+#ifndef GHOSTTY_VT_RESULT_H
+#define GHOSTTY_VT_RESULT_H
+
+/**
+ * Result codes for libghostty-vt operations.
+ */
+typedef enum {
+ /** Operation completed successfully */
+ GHOSTTY_SUCCESS = 0,
+ /** Operation failed due to failed allocation */
+ GHOSTTY_OUT_OF_MEMORY = -1,
+ /** Operation failed due to invalid value */
+ GHOSTTY_INVALID_VALUE = -2,
+} GhosttyResult;
+
+#endif /* GHOSTTY_VT_RESULT_H */
diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/sgr.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/sgr.h
new file mode 100644
index 00000000000..0c1afc309bd
--- /dev/null
+++ b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/sgr.h
@@ -0,0 +1,394 @@
+/**
+ * @file sgr.h
+ *
+ * SGR (Select Graphic Rendition) attribute parsing and handling.
+ */
+
+#ifndef GHOSTTY_VT_SGR_H
+#define GHOSTTY_VT_SGR_H
+
+/** @defgroup sgr SGR Parser
+ *
+ * SGR (Select Graphic Rendition) attribute parser.
+ *
+ * SGR sequences are the syntax used to set styling attributes such as
+ * bold, italic, underline, and colors for text in terminal emulators.
+ * For example, you may be familiar with sequences like `ESC[1;31m`. The
+ * `1;31` is the SGR attribute list.
+ *
+ * The parser processes SGR parameters from CSI sequences (e.g., `ESC[1;31m`)
+ * and returns individual text attributes like bold, italic, colors, etc.
+ * It supports both semicolon (`;`) and colon (`:`) separators, possibly mixed,
+ * and handles various color formats including 8-color, 16-color, 256-color,
+ * X11 named colors, and RGB in multiple formats.
+ *
+ * ## Basic Usage
+ *
+ * 1. Create a parser instance with ghostty_sgr_new()
+ * 2. Set SGR parameters with ghostty_sgr_set_params()
+ * 3. Iterate through attributes using ghostty_sgr_next()
+ * 4. Free the parser with ghostty_sgr_free() when done
+ *
+ * ## Example
+ *
+ * @code{.c}
+ * #include
+ * #include
+ * #include
+ *
+ * int main() {
+ * // Create parser
+ * GhosttySgrParser parser;
+ * GhosttyResult result = ghostty_sgr_new(NULL, &parser);
+ * assert(result == GHOSTTY_SUCCESS);
+ *
+ * // Parse "bold, red foreground" sequence: ESC[1;31m
+ * uint16_t params[] = {1, 31};
+ * result = ghostty_sgr_set_params(parser, params, NULL, 2);
+ * assert(result == GHOSTTY_SUCCESS);
+ *
+ * // Iterate through attributes
+ * GhosttySgrAttribute attr;
+ * while (ghostty_sgr_next(parser, &attr)) {
+ * switch (attr.tag) {
+ * case GHOSTTY_SGR_ATTR_BOLD:
+ * printf("Bold enabled\n");
+ * break;
+ * case GHOSTTY_SGR_ATTR_FG_8:
+ * printf("Foreground color: %d\n", attr.value.fg_8);
+ * break;
+ * default:
+ * break;
+ * }
+ * }
+ *
+ * // Cleanup
+ * ghostty_sgr_free(parser);
+ * return 0;
+ * }
+ * @endcode
+ *
+ * @{
+ */
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/**
+ * Opaque handle to an SGR parser instance.
+ *
+ * This handle represents an SGR (Select Graphic Rendition) parser that can
+ * be used to parse SGR sequences and extract individual text attributes.
+ *
+ * @ingroup sgr
+ */
+typedef struct GhosttySgrParser* GhosttySgrParser;
+
+/**
+ * SGR attribute tags.
+ *
+ * These values identify the type of an SGR attribute in a tagged union.
+ * Use the tag to determine which field in the attribute value union to access.
+ *
+ * @ingroup sgr
+ */
+typedef enum {
+ GHOSTTY_SGR_ATTR_UNSET = 0,
+ GHOSTTY_SGR_ATTR_UNKNOWN = 1,
+ GHOSTTY_SGR_ATTR_BOLD = 2,
+ GHOSTTY_SGR_ATTR_RESET_BOLD = 3,
+ GHOSTTY_SGR_ATTR_ITALIC = 4,
+ GHOSTTY_SGR_ATTR_RESET_ITALIC = 5,
+ GHOSTTY_SGR_ATTR_FAINT = 6,
+ GHOSTTY_SGR_ATTR_UNDERLINE = 7,
+ GHOSTTY_SGR_ATTR_RESET_UNDERLINE = 8,
+ GHOSTTY_SGR_ATTR_UNDERLINE_COLOR = 9,
+ GHOSTTY_SGR_ATTR_UNDERLINE_COLOR_256 = 10,
+ GHOSTTY_SGR_ATTR_RESET_UNDERLINE_COLOR = 11,
+ GHOSTTY_SGR_ATTR_OVERLINE = 12,
+ GHOSTTY_SGR_ATTR_RESET_OVERLINE = 13,
+ GHOSTTY_SGR_ATTR_BLINK = 14,
+ GHOSTTY_SGR_ATTR_RESET_BLINK = 15,
+ GHOSTTY_SGR_ATTR_INVERSE = 16,
+ GHOSTTY_SGR_ATTR_RESET_INVERSE = 17,
+ GHOSTTY_SGR_ATTR_INVISIBLE = 18,
+ GHOSTTY_SGR_ATTR_RESET_INVISIBLE = 19,
+ GHOSTTY_SGR_ATTR_STRIKETHROUGH = 20,
+ GHOSTTY_SGR_ATTR_RESET_STRIKETHROUGH = 21,
+ GHOSTTY_SGR_ATTR_DIRECT_COLOR_FG = 22,
+ GHOSTTY_SGR_ATTR_DIRECT_COLOR_BG = 23,
+ GHOSTTY_SGR_ATTR_BG_8 = 24,
+ GHOSTTY_SGR_ATTR_FG_8 = 25,
+ GHOSTTY_SGR_ATTR_RESET_FG = 26,
+ GHOSTTY_SGR_ATTR_RESET_BG = 27,
+ GHOSTTY_SGR_ATTR_BRIGHT_BG_8 = 28,
+ GHOSTTY_SGR_ATTR_BRIGHT_FG_8 = 29,
+ GHOSTTY_SGR_ATTR_BG_256 = 30,
+ GHOSTTY_SGR_ATTR_FG_256 = 31,
+} GhosttySgrAttributeTag;
+
+/**
+ * Underline style types.
+ *
+ * @ingroup sgr
+ */
+typedef enum {
+ GHOSTTY_SGR_UNDERLINE_NONE = 0,
+ GHOSTTY_SGR_UNDERLINE_SINGLE = 1,
+ GHOSTTY_SGR_UNDERLINE_DOUBLE = 2,
+ GHOSTTY_SGR_UNDERLINE_CURLY = 3,
+ GHOSTTY_SGR_UNDERLINE_DOTTED = 4,
+ GHOSTTY_SGR_UNDERLINE_DASHED = 5,
+} GhosttySgrUnderline;
+
+/**
+ * Unknown SGR attribute data.
+ *
+ * Contains the full parameter list and the partial list where parsing
+ * encountered an unknown or invalid sequence.
+ *
+ * @ingroup sgr
+ */
+typedef struct {
+ const uint16_t* full_ptr;
+ size_t full_len;
+ const uint16_t* partial_ptr;
+ size_t partial_len;
+} GhosttySgrUnknown;
+
+/**
+ * SGR attribute value union.
+ *
+ * This union contains all possible attribute values. Use the tag field
+ * to determine which union member is active. Attributes without associated
+ * data (like bold, italic) don't use the union value.
+ *
+ * @ingroup sgr
+ */
+typedef union {
+ GhosttySgrUnknown unknown;
+ GhosttySgrUnderline underline;
+ GhosttyColorRgb underline_color;
+ GhosttyColorPaletteIndex underline_color_256;
+ GhosttyColorRgb direct_color_fg;
+ GhosttyColorRgb direct_color_bg;
+ GhosttyColorPaletteIndex bg_8;
+ GhosttyColorPaletteIndex fg_8;
+ GhosttyColorPaletteIndex bright_bg_8;
+ GhosttyColorPaletteIndex bright_fg_8;
+ GhosttyColorPaletteIndex bg_256;
+ GhosttyColorPaletteIndex fg_256;
+ uint64_t _padding[8];
+} GhosttySgrAttributeValue;
+
+/**
+ * SGR attribute (tagged union).
+ *
+ * A complete SGR attribute with both its type tag and associated value.
+ * Always check the tag field to determine which value union member is valid.
+ *
+ * Attributes without associated data (e.g., GHOSTTY_SGR_ATTR_BOLD) can be
+ * identified by tag alone; the value union is not used for these and
+ * the memory in the value field is undefined.
+ *
+ * @ingroup sgr
+ */
+typedef struct {
+ GhosttySgrAttributeTag tag;
+ GhosttySgrAttributeValue value;
+} GhosttySgrAttribute;
+
+/**
+ * Create a new SGR parser instance.
+ *
+ * Creates a new SGR (Select Graphic Rendition) parser using the provided
+ * allocator. The parser must be freed using ghostty_sgr_free() when
+ * no longer needed.
+ *
+ * @param allocator Pointer to the allocator to use for memory management, or
+ * NULL to use the default allocator
+ * @param parser Pointer to store the created parser handle
+ * @return GHOSTTY_SUCCESS on success, or an error code on failure
+ *
+ * @ingroup sgr
+ */
+GhosttyResult ghostty_sgr_new(const GhosttyAllocator* allocator,
+ GhosttySgrParser* parser);
+
+/**
+ * Free an SGR parser instance.
+ *
+ * Releases all resources associated with the SGR parser. After this call,
+ * the parser handle becomes invalid and must not be used. This includes
+ * any attributes previously returned by ghostty_sgr_next().
+ *
+ * @param parser The parser handle to free (may be NULL)
+ *
+ * @ingroup sgr
+ */
+void ghostty_sgr_free(GhosttySgrParser parser);
+
+/**
+ * Reset an SGR parser instance to the beginning of the parameter list.
+ *
+ * Resets the parser's iteration state without clearing the parameters.
+ * After calling this, ghostty_sgr_next() will start from the beginning
+ * of the parameter list again.
+ *
+ * @param parser The parser handle to reset, must not be NULL
+ *
+ * @ingroup sgr
+ */
+void ghostty_sgr_reset(GhosttySgrParser parser);
+
+/**
+ * Set SGR parameters for parsing.
+ *
+ * Sets the SGR parameter list to parse. Parameters are the numeric values
+ * from a CSI SGR sequence (e.g., for `ESC[1;31m`, params would be {1, 31}).
+ *
+ * The separators array optionally specifies the separator type for each
+ * parameter position. Each byte should be either ';' for semicolon or ':'
+ * for colon. This is needed for certain color formats that use colon
+ * separators (e.g., `ESC[4:3m` for curly underline). Any invalid separator
+ * values are treated as semicolons. The separators array must have the same
+ * length as the params array, if it is not NULL.
+ *
+ * If separators is NULL, all parameters are assumed to be semicolon-separated.
+ *
+ * This function makes an internal copy of the parameter and separator data,
+ * so the caller can safely free or modify the input arrays after this call.
+ *
+ * After calling this function, the parser is automatically reset and ready
+ * to iterate from the beginning.
+ *
+ * @param parser The parser handle, must not be NULL
+ * @param params Array of SGR parameter values
+ * @param separators Optional array of separator characters (';' or ':'), or
+ * NULL
+ * @param len Number of parameters (and separators if provided)
+ * @return GHOSTTY_SUCCESS on success, or an error code on failure
+ *
+ * @ingroup sgr
+ */
+GhosttyResult ghostty_sgr_set_params(GhosttySgrParser parser,
+ const uint16_t* params,
+ const char* separators,
+ size_t len);
+
+/**
+ * Get the next SGR attribute.
+ *
+ * Parses and returns the next attribute from the parameter list.
+ * Call this function repeatedly until it returns false to process
+ * all attributes in the sequence.
+ *
+ * @param parser The parser handle, must not be NULL
+ * @param attr Pointer to store the next attribute
+ * @return true if an attribute was returned, false if no more attributes
+ *
+ * @ingroup sgr
+ */
+bool ghostty_sgr_next(GhosttySgrParser parser, GhosttySgrAttribute* attr);
+
+/**
+ * Get the full parameter list from an unknown SGR attribute.
+ *
+ * This function retrieves the full parameter list that was provided to the
+ * parser when an unknown attribute was encountered. Primarily useful in
+ * WebAssembly environments where accessing struct fields directly is difficult.
+ *
+ * @param unknown The unknown attribute data
+ * @param ptr Pointer to store the pointer to the parameter array (may be NULL)
+ * @return The length of the full parameter array
+ *
+ * @ingroup sgr
+ */
+size_t ghostty_sgr_unknown_full(GhosttySgrUnknown unknown,
+ const uint16_t** ptr);
+
+/**
+ * Get the partial parameter list from an unknown SGR attribute.
+ *
+ * This function retrieves the partial parameter list where parsing stopped
+ * when an unknown attribute was encountered. Primarily useful in WebAssembly
+ * environments where accessing struct fields directly is difficult.
+ *
+ * @param unknown The unknown attribute data
+ * @param ptr Pointer to store the pointer to the parameter array (may be NULL)
+ * @return The length of the partial parameter array
+ *
+ * @ingroup sgr
+ */
+size_t ghostty_sgr_unknown_partial(GhosttySgrUnknown unknown,
+ const uint16_t** ptr);
+
+/**
+ * Get the tag from an SGR attribute.
+ *
+ * This function extracts the tag that identifies which type of attribute
+ * this is. Primarily useful in WebAssembly environments where accessing
+ * struct fields directly is difficult.
+ *
+ * @param attr The SGR attribute
+ * @return The attribute tag
+ *
+ * @ingroup sgr
+ */
+GhosttySgrAttributeTag ghostty_sgr_attribute_tag(GhosttySgrAttribute attr);
+
+/**
+ * Get the value from an SGR attribute.
+ *
+ * This function returns a pointer to the value union from an SGR attribute. Use
+ * the tag to determine which field of the union is valid. Primarily useful in
+ * WebAssembly environments where accessing struct fields directly is difficult.
+ *
+ * @param attr Pointer to the SGR attribute
+ * @return Pointer to the attribute value union
+ *
+ * @ingroup sgr
+ */
+GhosttySgrAttributeValue* ghostty_sgr_attribute_value(
+ GhosttySgrAttribute* attr);
+
+#ifdef __wasm__
+/**
+ * Allocate memory for an SGR attribute (WebAssembly only).
+ *
+ * This is a convenience function for WebAssembly environments to allocate
+ * memory for an SGR attribute structure that can be passed to ghostty_sgr_next.
+ *
+ * @return Pointer to the allocated attribute structure
+ *
+ * @ingroup wasm
+ */
+GhosttySgrAttribute* ghostty_wasm_alloc_sgr_attribute(void);
+
+/**
+ * Free memory for an SGR attribute (WebAssembly only).
+ *
+ * Frees memory allocated by ghostty_wasm_alloc_sgr_attribute.
+ *
+ * @param attr Pointer to the attribute structure to free
+ *
+ * @ingroup wasm
+ */
+void ghostty_wasm_free_sgr_attribute(GhosttySgrAttribute* attr);
+#endif
+
+#ifdef __cplusplus
+}
+#endif
+
+/** @} */
+
+#endif /* GHOSTTY_VT_SGR_H */
diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/wasm.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/wasm.h
new file mode 100644
index 00000000000..37a8263265d
--- /dev/null
+++ b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/ghostty/vt/wasm.h
@@ -0,0 +1,159 @@
+/**
+ * @file wasm.h
+ *
+ * WebAssembly utility functions for libghostty-vt.
+ */
+
+#ifndef GHOSTTY_VT_WASM_H
+#define GHOSTTY_VT_WASM_H
+
+#ifdef __wasm__
+
+#include
+#include
+
+/** @defgroup wasm WebAssembly Utilities
+ *
+ * Convenience functions for allocating various types in WebAssembly builds.
+ * **These are only available the libghostty-vt wasm module.**
+ *
+ * Ghostty relies on pointers to various types for ABI compatibility, and
+ * creating those pointers in Wasm can be tedious. These functions provide
+ * a purely additive set of utilities that simplify memory management in
+ * Wasm environments without changing the core C library API.
+ *
+ * @note These functions always use the default allocator. If you need
+ * custom allocation strategies, you should allocate types manually using
+ * your custom allocator. This is a very rare use case in the WebAssembly
+ * world so these are optimized for simplicity.
+ *
+ * ## Example Usage
+ *
+ * Here's a simple example of using the Wasm utilities with the key encoder:
+ *
+ * @code
+ * const { exports } = wasmInstance;
+ * const view = new DataView(wasmMemory.buffer);
+ *
+ * // Create key encoder
+ * const encoderPtr = exports.ghostty_wasm_alloc_opaque();
+ * exports.ghostty_key_encoder_new(null, encoderPtr);
+ * const encoder = view.getUint32(encoder, true);
+ *
+ * // Configure encoder with Kitty protocol flags
+ * const flagsPtr = exports.ghostty_wasm_alloc_u8();
+ * view.setUint8(flagsPtr, 0x1F);
+ * exports.ghostty_key_encoder_setopt(encoder, 5, flagsPtr);
+ *
+ * // Allocate output buffer and size pointer
+ * const bufferSize = 32;
+ * const bufPtr = exports.ghostty_wasm_alloc_u8_array(bufferSize);
+ * const writtenPtr = exports.ghostty_wasm_alloc_usize();
+ *
+ * // Encode the key event
+ * exports.ghostty_key_encoder_encode(
+ * encoder, eventPtr, bufPtr, bufferSize, writtenPtr
+ * );
+ *
+ * // Read encoded output
+ * const bytesWritten = view.getUint32(writtenPtr, true);
+ * const encoded = new Uint8Array(wasmMemory.buffer, bufPtr, bytesWritten);
+ * @endcode
+ *
+ * @remark The code above is pretty ugly! This is the lowest level interface
+ * to the libghostty-vt Wasm module. In practice, this should be wrapped
+ * in a higher-level API that abstracts away all this.
+ *
+ * @{
+ */
+
+/**
+ * Allocate an opaque pointer. This can be used for any opaque pointer
+ * types such as GhosttyKeyEncoder, GhosttyKeyEvent, etc.
+ *
+ * @return Pointer to allocated opaque pointer, or NULL if allocation failed
+ * @ingroup wasm
+ */
+void** ghostty_wasm_alloc_opaque(void);
+
+/**
+ * Free an opaque pointer allocated by ghostty_wasm_alloc_opaque().
+ *
+ * @param ptr Pointer to free, or NULL (NULL is safely ignored)
+ * @ingroup wasm
+ */
+void ghostty_wasm_free_opaque(void **ptr);
+
+/**
+ * Allocate an array of uint8_t values.
+ *
+ * @param len Number of uint8_t elements to allocate
+ * @return Pointer to allocated array, or NULL if allocation failed
+ * @ingroup wasm
+ */
+uint8_t* ghostty_wasm_alloc_u8_array(size_t len);
+
+/**
+ * Free an array allocated by ghostty_wasm_alloc_u8_array().
+ *
+ * @param ptr Pointer to the array to free, or NULL (NULL is safely ignored)
+ * @param len Length of the array (must match the length passed to alloc)
+ * @ingroup wasm
+ */
+void ghostty_wasm_free_u8_array(uint8_t *ptr, size_t len);
+
+/**
+ * Allocate an array of uint16_t values.
+ *
+ * @param len Number of uint16_t elements to allocate
+ * @return Pointer to allocated array, or NULL if allocation failed
+ * @ingroup wasm
+ */
+uint16_t* ghostty_wasm_alloc_u16_array(size_t len);
+
+/**
+ * Free an array allocated by ghostty_wasm_alloc_u16_array().
+ *
+ * @param ptr Pointer to the array to free, or NULL (NULL is safely ignored)
+ * @param len Length of the array (must match the length passed to alloc)
+ * @ingroup wasm
+ */
+void ghostty_wasm_free_u16_array(uint16_t *ptr, size_t len);
+
+/**
+ * Allocate a single uint8_t value.
+ *
+ * @return Pointer to allocated uint8_t, or NULL if allocation failed
+ * @ingroup wasm
+ */
+uint8_t* ghostty_wasm_alloc_u8(void);
+
+/**
+ * Free a uint8_t allocated by ghostty_wasm_alloc_u8().
+ *
+ * @param ptr Pointer to free, or NULL (NULL is safely ignored)
+ * @ingroup wasm
+ */
+void ghostty_wasm_free_u8(uint8_t *ptr);
+
+/**
+ * Allocate a single size_t value.
+ *
+ * @return Pointer to allocated size_t, or NULL if allocation failed
+ * @ingroup wasm
+ */
+size_t* ghostty_wasm_alloc_usize(void);
+
+/**
+ * Free a size_t allocated by ghostty_wasm_alloc_usize().
+ *
+ * @param ptr Pointer to free, or NULL (NULL is safely ignored)
+ * @ingroup wasm
+ */
+void ghostty_wasm_free_usize(size_t *ptr);
+
+/** @} */
+
+#endif /* __wasm__ */
+
+#endif /* GHOSTTY_VT_WASM_H */
diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/module.modulemap b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/module.modulemap
new file mode 100644
index 00000000000..8961f5c04ba
--- /dev/null
+++ b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/Headers/module.modulemap
@@ -0,0 +1,7 @@
+// This makes Ghostty available to the XCode build for the macOS app.
+// We append "Kit" to it not to be cute, but because targets have to have
+// unique names and we use Ghostty for other things.
+module GhosttyKit {
+ umbrella header "ghostty.h"
+ export *
+}
diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/libghostty-fat.a b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/libghostty-fat.a
new file mode 100644
index 00000000000..788a46839d1
Binary files /dev/null and b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64-simulator/libghostty-fat.a differ
diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty.h
new file mode 100644
index 00000000000..232e094ceef
--- /dev/null
+++ b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty.h
@@ -0,0 +1,1218 @@
+// Ghostty embedding API. The documentation for the embedding API is
+// only within the Zig source files that define the implementations. This
+// isn't meant to be a general purpose embedding API (yet) so there hasn't
+// been documentation or example work beyond that.
+//
+// The only consumer of this API is the macOS app, but the API is built to
+// be more general purpose.
+#ifndef GHOSTTY_H
+#define GHOSTTY_H
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+#include
+#include
+#include
+
+#ifdef _MSC_VER
+#include
+typedef SSIZE_T ssize_t;
+#else
+#include
+#endif
+
+//-------------------------------------------------------------------
+// Macros
+
+#define GHOSTTY_SUCCESS 0
+
+// Symbol visibility for shared library builds. On Windows, functions
+// are exported from the DLL when building and imported when consuming.
+// On other platforms with GCC/Clang, functions are marked with default
+// visibility so they remain accessible when the library is built with
+// -fvisibility=hidden. For static library builds, define GHOSTTY_STATIC
+// before including this header to make this a no-op.
+#ifndef GHOSTTY_API
+#if defined(GHOSTTY_STATIC)
+ #define GHOSTTY_API
+#elif defined(_WIN32) || defined(_WIN64)
+ #ifdef GHOSTTY_BUILD_SHARED
+ #define GHOSTTY_API __declspec(dllexport)
+ #else
+ #define GHOSTTY_API __declspec(dllimport)
+ #endif
+#elif defined(__GNUC__) && __GNUC__ >= 4
+ #define GHOSTTY_API __attribute__((visibility("default")))
+#else
+ #define GHOSTTY_API
+#endif
+#endif
+
+//-------------------------------------------------------------------
+// Types
+
+// Opaque types
+typedef void* ghostty_app_t;
+typedef void* ghostty_config_t;
+typedef void* ghostty_surface_t;
+typedef void* ghostty_inspector_t;
+
+// All the types below are fully defined and must be kept in sync with
+// their Zig counterparts. Any changes to these types MUST have an associated
+// Zig change.
+typedef enum {
+ GHOSTTY_PLATFORM_INVALID,
+ GHOSTTY_PLATFORM_MACOS,
+ GHOSTTY_PLATFORM_IOS,
+} ghostty_platform_e;
+
+// Callback for custom I/O write handler.
+typedef void (*ghostty_surface_write_fn)(void* userdata,
+ const uint8_t* data,
+ size_t len);
+
+typedef enum {
+ GHOSTTY_CLIPBOARD_STANDARD,
+ GHOSTTY_CLIPBOARD_SELECTION,
+} ghostty_clipboard_e;
+
+typedef struct {
+ const char *mime;
+ const char *data;
+} ghostty_clipboard_content_s;
+
+typedef enum {
+ GHOSTTY_CLIPBOARD_REQUEST_PASTE,
+ GHOSTTY_CLIPBOARD_REQUEST_OSC_52_READ,
+ GHOSTTY_CLIPBOARD_REQUEST_OSC_52_WRITE,
+} ghostty_clipboard_request_e;
+
+typedef enum {
+ GHOSTTY_MOUSE_RELEASE,
+ GHOSTTY_MOUSE_PRESS,
+} ghostty_input_mouse_state_e;
+
+typedef enum {
+ GHOSTTY_MOUSE_UNKNOWN,
+ GHOSTTY_MOUSE_LEFT,
+ GHOSTTY_MOUSE_RIGHT,
+ GHOSTTY_MOUSE_MIDDLE,
+ GHOSTTY_MOUSE_FOUR,
+ GHOSTTY_MOUSE_FIVE,
+ GHOSTTY_MOUSE_SIX,
+ GHOSTTY_MOUSE_SEVEN,
+ GHOSTTY_MOUSE_EIGHT,
+ GHOSTTY_MOUSE_NINE,
+ GHOSTTY_MOUSE_TEN,
+ GHOSTTY_MOUSE_ELEVEN,
+} ghostty_input_mouse_button_e;
+
+typedef enum {
+ GHOSTTY_MOUSE_MOMENTUM_NONE,
+ GHOSTTY_MOUSE_MOMENTUM_BEGAN,
+ GHOSTTY_MOUSE_MOMENTUM_STATIONARY,
+ GHOSTTY_MOUSE_MOMENTUM_CHANGED,
+ GHOSTTY_MOUSE_MOMENTUM_ENDED,
+ GHOSTTY_MOUSE_MOMENTUM_CANCELLED,
+ GHOSTTY_MOUSE_MOMENTUM_MAY_BEGIN,
+} ghostty_input_mouse_momentum_e;
+
+typedef enum {
+ GHOSTTY_COLOR_SCHEME_LIGHT = 0,
+ GHOSTTY_COLOR_SCHEME_DARK = 1,
+} ghostty_color_scheme_e;
+
+// This is a packed struct (see src/input/mouse.zig) but the C standard
+// afaik doesn't let us reliably define packed structs so we build it up
+// from scratch.
+typedef int ghostty_input_scroll_mods_t;
+
+typedef enum {
+ GHOSTTY_MODS_NONE = 0,
+ GHOSTTY_MODS_SHIFT = 1 << 0,
+ GHOSTTY_MODS_CTRL = 1 << 1,
+ GHOSTTY_MODS_ALT = 1 << 2,
+ GHOSTTY_MODS_SUPER = 1 << 3,
+ GHOSTTY_MODS_CAPS = 1 << 4,
+ GHOSTTY_MODS_NUM = 1 << 5,
+ GHOSTTY_MODS_SHIFT_RIGHT = 1 << 6,
+ GHOSTTY_MODS_CTRL_RIGHT = 1 << 7,
+ GHOSTTY_MODS_ALT_RIGHT = 1 << 8,
+ GHOSTTY_MODS_SUPER_RIGHT = 1 << 9,
+} ghostty_input_mods_e;
+
+typedef enum {
+ GHOSTTY_BINDING_FLAGS_CONSUMED = 1 << 0,
+ GHOSTTY_BINDING_FLAGS_ALL = 1 << 1,
+ GHOSTTY_BINDING_FLAGS_GLOBAL = 1 << 2,
+ GHOSTTY_BINDING_FLAGS_PERFORMABLE = 1 << 3,
+} ghostty_binding_flags_e;
+
+typedef enum {
+ GHOSTTY_ACTION_RELEASE,
+ GHOSTTY_ACTION_PRESS,
+ GHOSTTY_ACTION_REPEAT,
+} ghostty_input_action_e;
+
+// Based on: https://www.w3.org/TR/uievents-code/
+typedef enum {
+ GHOSTTY_KEY_UNIDENTIFIED,
+
+ // "Writing System Keys" § 3.1.1
+ GHOSTTY_KEY_BACKQUOTE,
+ GHOSTTY_KEY_BACKSLASH,
+ GHOSTTY_KEY_BRACKET_LEFT,
+ GHOSTTY_KEY_BRACKET_RIGHT,
+ GHOSTTY_KEY_COMMA,
+ GHOSTTY_KEY_DIGIT_0,
+ GHOSTTY_KEY_DIGIT_1,
+ GHOSTTY_KEY_DIGIT_2,
+ GHOSTTY_KEY_DIGIT_3,
+ GHOSTTY_KEY_DIGIT_4,
+ GHOSTTY_KEY_DIGIT_5,
+ GHOSTTY_KEY_DIGIT_6,
+ GHOSTTY_KEY_DIGIT_7,
+ GHOSTTY_KEY_DIGIT_8,
+ GHOSTTY_KEY_DIGIT_9,
+ GHOSTTY_KEY_EQUAL,
+ GHOSTTY_KEY_INTL_BACKSLASH,
+ GHOSTTY_KEY_INTL_RO,
+ GHOSTTY_KEY_INTL_YEN,
+ GHOSTTY_KEY_A,
+ GHOSTTY_KEY_B,
+ GHOSTTY_KEY_C,
+ GHOSTTY_KEY_D,
+ GHOSTTY_KEY_E,
+ GHOSTTY_KEY_F,
+ GHOSTTY_KEY_G,
+ GHOSTTY_KEY_H,
+ GHOSTTY_KEY_I,
+ GHOSTTY_KEY_J,
+ GHOSTTY_KEY_K,
+ GHOSTTY_KEY_L,
+ GHOSTTY_KEY_M,
+ GHOSTTY_KEY_N,
+ GHOSTTY_KEY_O,
+ GHOSTTY_KEY_P,
+ GHOSTTY_KEY_Q,
+ GHOSTTY_KEY_R,
+ GHOSTTY_KEY_S,
+ GHOSTTY_KEY_T,
+ GHOSTTY_KEY_U,
+ GHOSTTY_KEY_V,
+ GHOSTTY_KEY_W,
+ GHOSTTY_KEY_X,
+ GHOSTTY_KEY_Y,
+ GHOSTTY_KEY_Z,
+ GHOSTTY_KEY_MINUS,
+ GHOSTTY_KEY_PERIOD,
+ GHOSTTY_KEY_QUOTE,
+ GHOSTTY_KEY_SEMICOLON,
+ GHOSTTY_KEY_SLASH,
+
+ // "Functional Keys" § 3.1.2
+ GHOSTTY_KEY_ALT_LEFT,
+ GHOSTTY_KEY_ALT_RIGHT,
+ GHOSTTY_KEY_BACKSPACE,
+ GHOSTTY_KEY_CAPS_LOCK,
+ GHOSTTY_KEY_CONTEXT_MENU,
+ GHOSTTY_KEY_CONTROL_LEFT,
+ GHOSTTY_KEY_CONTROL_RIGHT,
+ GHOSTTY_KEY_ENTER,
+ GHOSTTY_KEY_META_LEFT,
+ GHOSTTY_KEY_META_RIGHT,
+ GHOSTTY_KEY_SHIFT_LEFT,
+ GHOSTTY_KEY_SHIFT_RIGHT,
+ GHOSTTY_KEY_SPACE,
+ GHOSTTY_KEY_TAB,
+ GHOSTTY_KEY_CONVERT,
+ GHOSTTY_KEY_KANA_MODE,
+ GHOSTTY_KEY_NON_CONVERT,
+
+ // "Control Pad Section" § 3.2
+ GHOSTTY_KEY_DELETE,
+ GHOSTTY_KEY_END,
+ GHOSTTY_KEY_HELP,
+ GHOSTTY_KEY_HOME,
+ GHOSTTY_KEY_INSERT,
+ GHOSTTY_KEY_PAGE_DOWN,
+ GHOSTTY_KEY_PAGE_UP,
+
+ // "Arrow Pad Section" § 3.3
+ GHOSTTY_KEY_ARROW_DOWN,
+ GHOSTTY_KEY_ARROW_LEFT,
+ GHOSTTY_KEY_ARROW_RIGHT,
+ GHOSTTY_KEY_ARROW_UP,
+
+ // "Numpad Section" § 3.4
+ GHOSTTY_KEY_NUM_LOCK,
+ GHOSTTY_KEY_NUMPAD_0,
+ GHOSTTY_KEY_NUMPAD_1,
+ GHOSTTY_KEY_NUMPAD_2,
+ GHOSTTY_KEY_NUMPAD_3,
+ GHOSTTY_KEY_NUMPAD_4,
+ GHOSTTY_KEY_NUMPAD_5,
+ GHOSTTY_KEY_NUMPAD_6,
+ GHOSTTY_KEY_NUMPAD_7,
+ GHOSTTY_KEY_NUMPAD_8,
+ GHOSTTY_KEY_NUMPAD_9,
+ GHOSTTY_KEY_NUMPAD_ADD,
+ GHOSTTY_KEY_NUMPAD_BACKSPACE,
+ GHOSTTY_KEY_NUMPAD_CLEAR,
+ GHOSTTY_KEY_NUMPAD_CLEAR_ENTRY,
+ GHOSTTY_KEY_NUMPAD_COMMA,
+ GHOSTTY_KEY_NUMPAD_DECIMAL,
+ GHOSTTY_KEY_NUMPAD_DIVIDE,
+ GHOSTTY_KEY_NUMPAD_ENTER,
+ GHOSTTY_KEY_NUMPAD_EQUAL,
+ GHOSTTY_KEY_NUMPAD_MEMORY_ADD,
+ GHOSTTY_KEY_NUMPAD_MEMORY_CLEAR,
+ GHOSTTY_KEY_NUMPAD_MEMORY_RECALL,
+ GHOSTTY_KEY_NUMPAD_MEMORY_STORE,
+ GHOSTTY_KEY_NUMPAD_MEMORY_SUBTRACT,
+ GHOSTTY_KEY_NUMPAD_MULTIPLY,
+ GHOSTTY_KEY_NUMPAD_PAREN_LEFT,
+ GHOSTTY_KEY_NUMPAD_PAREN_RIGHT,
+ GHOSTTY_KEY_NUMPAD_SUBTRACT,
+ GHOSTTY_KEY_NUMPAD_SEPARATOR,
+ GHOSTTY_KEY_NUMPAD_UP,
+ GHOSTTY_KEY_NUMPAD_DOWN,
+ GHOSTTY_KEY_NUMPAD_RIGHT,
+ GHOSTTY_KEY_NUMPAD_LEFT,
+ GHOSTTY_KEY_NUMPAD_BEGIN,
+ GHOSTTY_KEY_NUMPAD_HOME,
+ GHOSTTY_KEY_NUMPAD_END,
+ GHOSTTY_KEY_NUMPAD_INSERT,
+ GHOSTTY_KEY_NUMPAD_DELETE,
+ GHOSTTY_KEY_NUMPAD_PAGE_UP,
+ GHOSTTY_KEY_NUMPAD_PAGE_DOWN,
+
+ // "Function Section" § 3.5
+ GHOSTTY_KEY_ESCAPE,
+ GHOSTTY_KEY_F1,
+ GHOSTTY_KEY_F2,
+ GHOSTTY_KEY_F3,
+ GHOSTTY_KEY_F4,
+ GHOSTTY_KEY_F5,
+ GHOSTTY_KEY_F6,
+ GHOSTTY_KEY_F7,
+ GHOSTTY_KEY_F8,
+ GHOSTTY_KEY_F9,
+ GHOSTTY_KEY_F10,
+ GHOSTTY_KEY_F11,
+ GHOSTTY_KEY_F12,
+ GHOSTTY_KEY_F13,
+ GHOSTTY_KEY_F14,
+ GHOSTTY_KEY_F15,
+ GHOSTTY_KEY_F16,
+ GHOSTTY_KEY_F17,
+ GHOSTTY_KEY_F18,
+ GHOSTTY_KEY_F19,
+ GHOSTTY_KEY_F20,
+ GHOSTTY_KEY_F21,
+ GHOSTTY_KEY_F22,
+ GHOSTTY_KEY_F23,
+ GHOSTTY_KEY_F24,
+ GHOSTTY_KEY_F25,
+ GHOSTTY_KEY_FN,
+ GHOSTTY_KEY_FN_LOCK,
+ GHOSTTY_KEY_PRINT_SCREEN,
+ GHOSTTY_KEY_SCROLL_LOCK,
+ GHOSTTY_KEY_PAUSE,
+
+ // "Media Keys" § 3.6
+ GHOSTTY_KEY_BROWSER_BACK,
+ GHOSTTY_KEY_BROWSER_FAVORITES,
+ GHOSTTY_KEY_BROWSER_FORWARD,
+ GHOSTTY_KEY_BROWSER_HOME,
+ GHOSTTY_KEY_BROWSER_REFRESH,
+ GHOSTTY_KEY_BROWSER_SEARCH,
+ GHOSTTY_KEY_BROWSER_STOP,
+ GHOSTTY_KEY_EJECT,
+ GHOSTTY_KEY_LAUNCH_APP_1,
+ GHOSTTY_KEY_LAUNCH_APP_2,
+ GHOSTTY_KEY_LAUNCH_MAIL,
+ GHOSTTY_KEY_MEDIA_PLAY_PAUSE,
+ GHOSTTY_KEY_MEDIA_SELECT,
+ GHOSTTY_KEY_MEDIA_STOP,
+ GHOSTTY_KEY_MEDIA_TRACK_NEXT,
+ GHOSTTY_KEY_MEDIA_TRACK_PREVIOUS,
+ GHOSTTY_KEY_POWER,
+ GHOSTTY_KEY_SLEEP,
+ GHOSTTY_KEY_AUDIO_VOLUME_DOWN,
+ GHOSTTY_KEY_AUDIO_VOLUME_MUTE,
+ GHOSTTY_KEY_AUDIO_VOLUME_UP,
+ GHOSTTY_KEY_WAKE_UP,
+
+ // "Legacy, Non-standard, and Special Keys" § 3.7
+ GHOSTTY_KEY_COPY,
+ GHOSTTY_KEY_CUT,
+ GHOSTTY_KEY_PASTE,
+} ghostty_input_key_e;
+
+typedef struct {
+ ghostty_input_action_e action;
+ ghostty_input_mods_e mods;
+ ghostty_input_mods_e consumed_mods;
+ uint32_t keycode;
+ const char* text;
+ uint32_t unshifted_codepoint;
+ bool composing;
+} ghostty_input_key_s;
+
+typedef enum {
+ GHOSTTY_TRIGGER_PHYSICAL,
+ GHOSTTY_TRIGGER_UNICODE,
+ GHOSTTY_TRIGGER_CATCH_ALL,
+} ghostty_input_trigger_tag_e;
+
+typedef union {
+ ghostty_input_key_e translated;
+ ghostty_input_key_e physical;
+ uint32_t unicode;
+ // catch_all has no payload
+} ghostty_input_trigger_key_u;
+
+typedef struct {
+ ghostty_input_trigger_tag_e tag;
+ ghostty_input_trigger_key_u key;
+ ghostty_input_mods_e mods;
+} ghostty_input_trigger_s;
+
+typedef struct {
+ const char* action_key;
+ const char* action;
+ const char* title;
+ const char* description;
+} ghostty_command_s;
+
+typedef enum {
+ GHOSTTY_BUILD_MODE_DEBUG,
+ GHOSTTY_BUILD_MODE_RELEASE_SAFE,
+ GHOSTTY_BUILD_MODE_RELEASE_FAST,
+ GHOSTTY_BUILD_MODE_RELEASE_SMALL,
+} ghostty_build_mode_e;
+
+typedef struct {
+ ghostty_build_mode_e build_mode;
+ const char* version;
+ uintptr_t version_len;
+} ghostty_info_s;
+
+typedef struct {
+ const char* message;
+} ghostty_diagnostic_s;
+
+typedef struct {
+ const char* ptr;
+ uintptr_t len;
+ bool sentinel;
+} ghostty_string_s;
+
+typedef struct {
+ double tl_px_x;
+ double tl_px_y;
+ uint32_t offset_start;
+ uint32_t offset_len;
+ const char* text;
+ uintptr_t text_len;
+} ghostty_text_s;
+
+typedef enum {
+ GHOSTTY_POINT_ACTIVE,
+ GHOSTTY_POINT_VIEWPORT,
+ GHOSTTY_POINT_SCREEN,
+ GHOSTTY_POINT_SURFACE,
+} ghostty_point_tag_e;
+
+typedef enum {
+ GHOSTTY_POINT_COORD_EXACT,
+ GHOSTTY_POINT_COORD_TOP_LEFT,
+ GHOSTTY_POINT_COORD_BOTTOM_RIGHT,
+} ghostty_point_coord_e;
+
+typedef struct {
+ ghostty_point_tag_e tag;
+ ghostty_point_coord_e coord;
+ uint32_t x;
+ uint32_t y;
+} ghostty_point_s;
+
+typedef struct {
+ ghostty_point_s top_left;
+ ghostty_point_s bottom_right;
+ bool rectangle;
+} ghostty_selection_s;
+
+typedef struct {
+ const char* key;
+ const char* value;
+} ghostty_env_var_s;
+
+typedef struct {
+ void* nsview;
+} ghostty_platform_macos_s;
+
+typedef struct {
+ void* uiview;
+} ghostty_platform_ios_s;
+
+typedef union {
+ ghostty_platform_macos_s macos;
+ ghostty_platform_ios_s ios;
+} ghostty_platform_u;
+
+typedef enum {
+ GHOSTTY_SURFACE_CONTEXT_WINDOW = 0,
+ GHOSTTY_SURFACE_CONTEXT_TAB = 1,
+ GHOSTTY_SURFACE_CONTEXT_SPLIT = 2,
+} ghostty_surface_context_e;
+
+typedef struct {
+ ghostty_platform_e platform_tag;
+ ghostty_platform_u platform;
+ void* userdata;
+ double scale_factor;
+ float font_size;
+ const char* working_directory;
+ const char* command;
+ ghostty_env_var_s* env_vars;
+ size_t env_var_count;
+ const char* initial_input;
+ bool wait_after_command;
+ ghostty_surface_context_e context;
+ bool use_custom_io;
+} ghostty_surface_config_s;
+
+typedef struct {
+ uint16_t columns;
+ uint16_t rows;
+ uint32_t width_px;
+ uint32_t height_px;
+ uint32_t cell_width_px;
+ uint32_t cell_height_px;
+} ghostty_surface_size_s;
+
+// Config types
+
+// config.Path
+typedef struct {
+ const char* path;
+ bool optional;
+} ghostty_config_path_s;
+
+// config.Color
+typedef struct {
+ uint8_t r;
+ uint8_t g;
+ uint8_t b;
+} ghostty_config_color_s;
+
+// config.ColorList
+typedef struct {
+ const ghostty_config_color_s* colors;
+ size_t len;
+} ghostty_config_color_list_s;
+
+// config.RepeatableCommand
+typedef struct {
+ const ghostty_command_s* commands;
+ size_t len;
+} ghostty_config_command_list_s;
+
+// config.Palette
+typedef struct {
+ ghostty_config_color_s colors[256];
+} ghostty_config_palette_s;
+
+// config.QuickTerminalSize
+typedef enum {
+ GHOSTTY_QUICK_TERMINAL_SIZE_NONE,
+ GHOSTTY_QUICK_TERMINAL_SIZE_PERCENTAGE,
+ GHOSTTY_QUICK_TERMINAL_SIZE_PIXELS,
+} ghostty_quick_terminal_size_tag_e;
+
+typedef union {
+ float percentage;
+ uint32_t pixels;
+} ghostty_quick_terminal_size_value_u;
+
+typedef struct {
+ ghostty_quick_terminal_size_tag_e tag;
+ ghostty_quick_terminal_size_value_u value;
+} ghostty_quick_terminal_size_s;
+
+typedef struct {
+ ghostty_quick_terminal_size_s primary;
+ ghostty_quick_terminal_size_s secondary;
+} ghostty_config_quick_terminal_size_s;
+
+// config.Fullscreen
+typedef enum {
+ GHOSTTY_CONFIG_FULLSCREEN_FALSE,
+ GHOSTTY_CONFIG_FULLSCREEN_TRUE,
+ GHOSTTY_CONFIG_FULLSCREEN_NON_NATIVE,
+ GHOSTTY_CONFIG_FULLSCREEN_NON_NATIVE_VISIBLE_MENU,
+ GHOSTTY_CONFIG_FULLSCREEN_NON_NATIVE_PADDED_NOTCH,
+} ghostty_config_fullscreen_e;
+
+// apprt.Target.Key
+typedef enum {
+ GHOSTTY_TARGET_APP,
+ GHOSTTY_TARGET_SURFACE,
+} ghostty_target_tag_e;
+
+typedef union {
+ ghostty_surface_t surface;
+} ghostty_target_u;
+
+typedef struct {
+ ghostty_target_tag_e tag;
+ ghostty_target_u target;
+} ghostty_target_s;
+
+// apprt.action.SplitDirection
+typedef enum {
+ GHOSTTY_SPLIT_DIRECTION_RIGHT,
+ GHOSTTY_SPLIT_DIRECTION_DOWN,
+ GHOSTTY_SPLIT_DIRECTION_LEFT,
+ GHOSTTY_SPLIT_DIRECTION_UP,
+} ghostty_action_split_direction_e;
+
+// apprt.action.GotoSplit
+typedef enum {
+ GHOSTTY_GOTO_SPLIT_PREVIOUS,
+ GHOSTTY_GOTO_SPLIT_NEXT,
+ GHOSTTY_GOTO_SPLIT_UP,
+ GHOSTTY_GOTO_SPLIT_LEFT,
+ GHOSTTY_GOTO_SPLIT_DOWN,
+ GHOSTTY_GOTO_SPLIT_RIGHT,
+} ghostty_action_goto_split_e;
+
+// apprt.action.GotoWindow
+typedef enum {
+ GHOSTTY_GOTO_WINDOW_PREVIOUS,
+ GHOSTTY_GOTO_WINDOW_NEXT,
+} ghostty_action_goto_window_e;
+
+// apprt.action.ResizeSplit.Direction
+typedef enum {
+ GHOSTTY_RESIZE_SPLIT_UP,
+ GHOSTTY_RESIZE_SPLIT_DOWN,
+ GHOSTTY_RESIZE_SPLIT_LEFT,
+ GHOSTTY_RESIZE_SPLIT_RIGHT,
+} ghostty_action_resize_split_direction_e;
+
+// apprt.action.ResizeSplit
+typedef struct {
+ uint16_t amount;
+ ghostty_action_resize_split_direction_e direction;
+} ghostty_action_resize_split_s;
+
+// apprt.action.MoveTab
+typedef struct {
+ ssize_t amount;
+} ghostty_action_move_tab_s;
+
+// apprt.action.GotoTab
+typedef enum {
+ GHOSTTY_GOTO_TAB_PREVIOUS = -1,
+ GHOSTTY_GOTO_TAB_NEXT = -2,
+ GHOSTTY_GOTO_TAB_LAST = -3,
+} ghostty_action_goto_tab_e;
+
+// apprt.action.Fullscreen
+typedef enum {
+ GHOSTTY_FULLSCREEN_NATIVE,
+ GHOSTTY_FULLSCREEN_MACOS_NON_NATIVE,
+ GHOSTTY_FULLSCREEN_MACOS_NON_NATIVE_VISIBLE_MENU,
+ GHOSTTY_FULLSCREEN_MACOS_NON_NATIVE_PADDED_NOTCH,
+} ghostty_action_fullscreen_e;
+
+// apprt.action.FloatWindow
+typedef enum {
+ GHOSTTY_FLOAT_WINDOW_ON,
+ GHOSTTY_FLOAT_WINDOW_OFF,
+ GHOSTTY_FLOAT_WINDOW_TOGGLE,
+} ghostty_action_float_window_e;
+
+// apprt.action.SecureInput
+typedef enum {
+ GHOSTTY_SECURE_INPUT_ON,
+ GHOSTTY_SECURE_INPUT_OFF,
+ GHOSTTY_SECURE_INPUT_TOGGLE,
+} ghostty_action_secure_input_e;
+
+// apprt.action.Inspector
+typedef enum {
+ GHOSTTY_INSPECTOR_TOGGLE,
+ GHOSTTY_INSPECTOR_SHOW,
+ GHOSTTY_INSPECTOR_HIDE,
+} ghostty_action_inspector_e;
+
+// apprt.action.QuitTimer
+typedef enum {
+ GHOSTTY_QUIT_TIMER_START,
+ GHOSTTY_QUIT_TIMER_STOP,
+} ghostty_action_quit_timer_e;
+
+// apprt.action.Readonly
+typedef enum {
+ GHOSTTY_READONLY_OFF,
+ GHOSTTY_READONLY_ON,
+} ghostty_action_readonly_e;
+
+// apprt.action.DesktopNotification.C
+typedef struct {
+ const char* title;
+ const char* body;
+} ghostty_action_desktop_notification_s;
+
+// apprt.action.SetTitle.C
+typedef struct {
+ const char* title;
+} ghostty_action_set_title_s;
+
+// apprt.action.PromptTitle
+typedef enum {
+ GHOSTTY_PROMPT_TITLE_SURFACE,
+ GHOSTTY_PROMPT_TITLE_TAB,
+} ghostty_action_prompt_title_e;
+
+// apprt.action.Pwd.C
+typedef struct {
+ const char* pwd;
+} ghostty_action_pwd_s;
+
+// terminal.MouseShape
+typedef enum {
+ GHOSTTY_MOUSE_SHAPE_DEFAULT,
+ GHOSTTY_MOUSE_SHAPE_CONTEXT_MENU,
+ GHOSTTY_MOUSE_SHAPE_HELP,
+ GHOSTTY_MOUSE_SHAPE_POINTER,
+ GHOSTTY_MOUSE_SHAPE_PROGRESS,
+ GHOSTTY_MOUSE_SHAPE_WAIT,
+ GHOSTTY_MOUSE_SHAPE_CELL,
+ GHOSTTY_MOUSE_SHAPE_CROSSHAIR,
+ GHOSTTY_MOUSE_SHAPE_TEXT,
+ GHOSTTY_MOUSE_SHAPE_VERTICAL_TEXT,
+ GHOSTTY_MOUSE_SHAPE_ALIAS,
+ GHOSTTY_MOUSE_SHAPE_COPY,
+ GHOSTTY_MOUSE_SHAPE_MOVE,
+ GHOSTTY_MOUSE_SHAPE_NO_DROP,
+ GHOSTTY_MOUSE_SHAPE_NOT_ALLOWED,
+ GHOSTTY_MOUSE_SHAPE_GRAB,
+ GHOSTTY_MOUSE_SHAPE_GRABBING,
+ GHOSTTY_MOUSE_SHAPE_ALL_SCROLL,
+ GHOSTTY_MOUSE_SHAPE_COL_RESIZE,
+ GHOSTTY_MOUSE_SHAPE_ROW_RESIZE,
+ GHOSTTY_MOUSE_SHAPE_N_RESIZE,
+ GHOSTTY_MOUSE_SHAPE_E_RESIZE,
+ GHOSTTY_MOUSE_SHAPE_S_RESIZE,
+ GHOSTTY_MOUSE_SHAPE_W_RESIZE,
+ GHOSTTY_MOUSE_SHAPE_NE_RESIZE,
+ GHOSTTY_MOUSE_SHAPE_NW_RESIZE,
+ GHOSTTY_MOUSE_SHAPE_SE_RESIZE,
+ GHOSTTY_MOUSE_SHAPE_SW_RESIZE,
+ GHOSTTY_MOUSE_SHAPE_EW_RESIZE,
+ GHOSTTY_MOUSE_SHAPE_NS_RESIZE,
+ GHOSTTY_MOUSE_SHAPE_NESW_RESIZE,
+ GHOSTTY_MOUSE_SHAPE_NWSE_RESIZE,
+ GHOSTTY_MOUSE_SHAPE_ZOOM_IN,
+ GHOSTTY_MOUSE_SHAPE_ZOOM_OUT,
+} ghostty_action_mouse_shape_e;
+
+// apprt.action.MouseVisibility
+typedef enum {
+ GHOSTTY_MOUSE_VISIBLE,
+ GHOSTTY_MOUSE_HIDDEN,
+} ghostty_action_mouse_visibility_e;
+
+// apprt.action.MouseOverLink
+typedef struct {
+ const char* url;
+ size_t len;
+} ghostty_action_mouse_over_link_s;
+
+// apprt.action.SizeLimit
+typedef struct {
+ uint32_t min_width;
+ uint32_t min_height;
+ uint32_t max_width;
+ uint32_t max_height;
+} ghostty_action_size_limit_s;
+
+// apprt.action.InitialSize
+typedef struct {
+ uint32_t width;
+ uint32_t height;
+} ghostty_action_initial_size_s;
+
+// apprt.action.CellSize
+typedef struct {
+ uint32_t width;
+ uint32_t height;
+} ghostty_action_cell_size_s;
+
+// renderer.Health
+typedef enum {
+ GHOSTTY_RENDERER_HEALTH_HEALTHY,
+ GHOSTTY_RENDERER_HEALTH_UNHEALTHY,
+} ghostty_action_renderer_health_e;
+
+// apprt.action.KeySequence
+typedef struct {
+ bool active;
+ ghostty_input_trigger_s trigger;
+} ghostty_action_key_sequence_s;
+
+// apprt.action.KeyTable.Tag
+typedef enum {
+ GHOSTTY_KEY_TABLE_ACTIVATE,
+ GHOSTTY_KEY_TABLE_DEACTIVATE,
+ GHOSTTY_KEY_TABLE_DEACTIVATE_ALL,
+} ghostty_action_key_table_tag_e;
+
+// apprt.action.KeyTable.CValue
+typedef union {
+ struct {
+ const char *name;
+ size_t len;
+ } activate;
+} ghostty_action_key_table_u;
+
+// apprt.action.KeyTable.C
+typedef struct {
+ ghostty_action_key_table_tag_e tag;
+ ghostty_action_key_table_u value;
+} ghostty_action_key_table_s;
+
+// apprt.action.ColorKind
+typedef enum {
+ GHOSTTY_ACTION_COLOR_KIND_FOREGROUND = -1,
+ GHOSTTY_ACTION_COLOR_KIND_BACKGROUND = -2,
+ GHOSTTY_ACTION_COLOR_KIND_CURSOR = -3,
+} ghostty_action_color_kind_e;
+
+// apprt.action.ColorChange
+typedef struct {
+ ghostty_action_color_kind_e kind;
+ uint8_t r;
+ uint8_t g;
+ uint8_t b;
+} ghostty_action_color_change_s;
+
+// apprt.action.ConfigChange
+typedef struct {
+ ghostty_config_t config;
+} ghostty_action_config_change_s;
+
+// apprt.action.ReloadConfig
+typedef struct {
+ bool soft;
+} ghostty_action_reload_config_s;
+
+// apprt.action.OpenUrlKind
+typedef enum {
+ GHOSTTY_ACTION_OPEN_URL_KIND_UNKNOWN,
+ GHOSTTY_ACTION_OPEN_URL_KIND_TEXT,
+ GHOSTTY_ACTION_OPEN_URL_KIND_HTML,
+} ghostty_action_open_url_kind_e;
+
+// apprt.action.OpenUrl.C
+typedef struct {
+ ghostty_action_open_url_kind_e kind;
+ const char* url;
+ uintptr_t len;
+} ghostty_action_open_url_s;
+
+// apprt.action.CloseTabMode
+typedef enum {
+ GHOSTTY_ACTION_CLOSE_TAB_MODE_THIS,
+ GHOSTTY_ACTION_CLOSE_TAB_MODE_OTHER,
+ GHOSTTY_ACTION_CLOSE_TAB_MODE_RIGHT,
+} ghostty_action_close_tab_mode_e;
+
+// apprt.surface.Message.ChildExited
+typedef struct {
+ uint32_t exit_code;
+ uint64_t timetime_ms;
+} ghostty_surface_message_childexited_s;
+
+// terminal.osc.Command.ProgressReport.State
+typedef enum {
+ GHOSTTY_PROGRESS_STATE_REMOVE,
+ GHOSTTY_PROGRESS_STATE_SET,
+ GHOSTTY_PROGRESS_STATE_ERROR,
+ GHOSTTY_PROGRESS_STATE_INDETERMINATE,
+ GHOSTTY_PROGRESS_STATE_PAUSE,
+} ghostty_action_progress_report_state_e;
+
+// terminal.osc.Command.ProgressReport.C
+typedef struct {
+ ghostty_action_progress_report_state_e state;
+ // -1 if no progress was reported, otherwise 0-100 indicating percent
+ // completeness.
+ int8_t progress;
+} ghostty_action_progress_report_s;
+
+// apprt.action.CommandFinished.C
+typedef struct {
+ // -1 if no exit code was reported, otherwise 0-255
+ int16_t exit_code;
+ // number of nanoseconds that command was running for
+ uint64_t duration;
+} ghostty_action_command_finished_s;
+
+// apprt.action.StartSearch.C
+typedef struct {
+ const char* needle;
+} ghostty_action_start_search_s;
+
+// apprt.action.SearchTotal
+typedef struct {
+ ssize_t total;
+} ghostty_action_search_total_s;
+
+// apprt.action.SearchSelected
+typedef struct {
+ ssize_t selected;
+} ghostty_action_search_selected_s;
+
+// terminal.Scrollbar
+typedef struct {
+ uint64_t total;
+ uint64_t offset;
+ uint64_t len;
+} ghostty_action_scrollbar_s;
+
+// apprt.Action.Key
+typedef enum {
+ GHOSTTY_ACTION_QUIT,
+ GHOSTTY_ACTION_NEW_WINDOW,
+ GHOSTTY_ACTION_NEW_TAB,
+ GHOSTTY_ACTION_CLOSE_TAB,
+ GHOSTTY_ACTION_NEW_SPLIT,
+ GHOSTTY_ACTION_CLOSE_ALL_WINDOWS,
+ GHOSTTY_ACTION_TOGGLE_MAXIMIZE,
+ GHOSTTY_ACTION_TOGGLE_FULLSCREEN,
+ GHOSTTY_ACTION_TOGGLE_TAB_OVERVIEW,
+ GHOSTTY_ACTION_TOGGLE_WINDOW_DECORATIONS,
+ GHOSTTY_ACTION_TOGGLE_QUICK_TERMINAL,
+ GHOSTTY_ACTION_TOGGLE_COMMAND_PALETTE,
+ GHOSTTY_ACTION_TOGGLE_VISIBILITY,
+ GHOSTTY_ACTION_TOGGLE_BACKGROUND_OPACITY,
+ GHOSTTY_ACTION_MOVE_TAB,
+ GHOSTTY_ACTION_GOTO_TAB,
+ GHOSTTY_ACTION_GOTO_SPLIT,
+ GHOSTTY_ACTION_GOTO_WINDOW,
+ GHOSTTY_ACTION_RESIZE_SPLIT,
+ GHOSTTY_ACTION_EQUALIZE_SPLITS,
+ GHOSTTY_ACTION_TOGGLE_SPLIT_ZOOM,
+ GHOSTTY_ACTION_PRESENT_TERMINAL,
+ GHOSTTY_ACTION_SIZE_LIMIT,
+ GHOSTTY_ACTION_RESET_WINDOW_SIZE,
+ GHOSTTY_ACTION_INITIAL_SIZE,
+ GHOSTTY_ACTION_CELL_SIZE,
+ GHOSTTY_ACTION_SCROLLBAR,
+ GHOSTTY_ACTION_RENDER,
+ GHOSTTY_ACTION_INSPECTOR,
+ GHOSTTY_ACTION_SHOW_GTK_INSPECTOR,
+ GHOSTTY_ACTION_RENDER_INSPECTOR,
+ GHOSTTY_ACTION_DESKTOP_NOTIFICATION,
+ GHOSTTY_ACTION_SET_TITLE,
+ GHOSTTY_ACTION_SET_TAB_TITLE,
+ GHOSTTY_ACTION_PROMPT_TITLE,
+ GHOSTTY_ACTION_PWD,
+ GHOSTTY_ACTION_MOUSE_SHAPE,
+ GHOSTTY_ACTION_MOUSE_VISIBILITY,
+ GHOSTTY_ACTION_MOUSE_OVER_LINK,
+ GHOSTTY_ACTION_RENDERER_HEALTH,
+ GHOSTTY_ACTION_OPEN_CONFIG,
+ GHOSTTY_ACTION_QUIT_TIMER,
+ GHOSTTY_ACTION_FLOAT_WINDOW,
+ GHOSTTY_ACTION_SECURE_INPUT,
+ GHOSTTY_ACTION_KEY_SEQUENCE,
+ GHOSTTY_ACTION_KEY_TABLE,
+ GHOSTTY_ACTION_COLOR_CHANGE,
+ GHOSTTY_ACTION_RELOAD_CONFIG,
+ GHOSTTY_ACTION_CONFIG_CHANGE,
+ GHOSTTY_ACTION_CLOSE_WINDOW,
+ GHOSTTY_ACTION_RING_BELL,
+ GHOSTTY_ACTION_UNDO,
+ GHOSTTY_ACTION_REDO,
+ GHOSTTY_ACTION_CHECK_FOR_UPDATES,
+ GHOSTTY_ACTION_OPEN_URL,
+ GHOSTTY_ACTION_SHOW_CHILD_EXITED,
+ GHOSTTY_ACTION_PROGRESS_REPORT,
+ GHOSTTY_ACTION_SHOW_ON_SCREEN_KEYBOARD,
+ GHOSTTY_ACTION_COMMAND_FINISHED,
+ GHOSTTY_ACTION_START_SEARCH,
+ GHOSTTY_ACTION_END_SEARCH,
+ GHOSTTY_ACTION_SEARCH_TOTAL,
+ GHOSTTY_ACTION_SEARCH_SELECTED,
+ GHOSTTY_ACTION_READONLY,
+ GHOSTTY_ACTION_COPY_TITLE_TO_CLIPBOARD,
+} ghostty_action_tag_e;
+
+typedef union {
+ ghostty_action_split_direction_e new_split;
+ ghostty_action_fullscreen_e toggle_fullscreen;
+ ghostty_action_move_tab_s move_tab;
+ ghostty_action_goto_tab_e goto_tab;
+ ghostty_action_goto_split_e goto_split;
+ ghostty_action_goto_window_e goto_window;
+ ghostty_action_resize_split_s resize_split;
+ ghostty_action_size_limit_s size_limit;
+ ghostty_action_initial_size_s initial_size;
+ ghostty_action_cell_size_s cell_size;
+ ghostty_action_scrollbar_s scrollbar;
+ ghostty_action_inspector_e inspector;
+ ghostty_action_desktop_notification_s desktop_notification;
+ ghostty_action_set_title_s set_title;
+ ghostty_action_set_title_s set_tab_title;
+ ghostty_action_prompt_title_e prompt_title;
+ ghostty_action_pwd_s pwd;
+ ghostty_action_mouse_shape_e mouse_shape;
+ ghostty_action_mouse_visibility_e mouse_visibility;
+ ghostty_action_mouse_over_link_s mouse_over_link;
+ ghostty_action_renderer_health_e renderer_health;
+ ghostty_action_quit_timer_e quit_timer;
+ ghostty_action_float_window_e float_window;
+ ghostty_action_secure_input_e secure_input;
+ ghostty_action_key_sequence_s key_sequence;
+ ghostty_action_key_table_s key_table;
+ ghostty_action_color_change_s color_change;
+ ghostty_action_reload_config_s reload_config;
+ ghostty_action_config_change_s config_change;
+ ghostty_action_open_url_s open_url;
+ ghostty_action_close_tab_mode_e close_tab_mode;
+ ghostty_surface_message_childexited_s child_exited;
+ ghostty_action_progress_report_s progress_report;
+ ghostty_action_command_finished_s command_finished;
+ ghostty_action_start_search_s start_search;
+ ghostty_action_search_total_s search_total;
+ ghostty_action_search_selected_s search_selected;
+ ghostty_action_readonly_e readonly;
+} ghostty_action_u;
+
+typedef struct {
+ ghostty_action_tag_e tag;
+ ghostty_action_u action;
+} ghostty_action_s;
+
+typedef void (*ghostty_runtime_wakeup_cb)(void*);
+typedef bool (*ghostty_runtime_read_clipboard_cb)(void*,
+ ghostty_clipboard_e,
+ void*);
+typedef void (*ghostty_runtime_confirm_read_clipboard_cb)(
+ void*,
+ const char*,
+ void*,
+ ghostty_clipboard_request_e);
+typedef void (*ghostty_runtime_write_clipboard_cb)(void*,
+ ghostty_clipboard_e,
+ const ghostty_clipboard_content_s*,
+ size_t,
+ bool);
+typedef void (*ghostty_runtime_close_surface_cb)(void*, bool);
+typedef bool (*ghostty_runtime_action_cb)(ghostty_app_t,
+ ghostty_target_s,
+ ghostty_action_s);
+
+typedef struct {
+ void* userdata;
+ bool supports_selection_clipboard;
+ ghostty_runtime_wakeup_cb wakeup_cb;
+ ghostty_runtime_action_cb action_cb;
+ ghostty_runtime_read_clipboard_cb read_clipboard_cb;
+ ghostty_runtime_confirm_read_clipboard_cb confirm_read_clipboard_cb;
+ ghostty_runtime_write_clipboard_cb write_clipboard_cb;
+ ghostty_runtime_close_surface_cb close_surface_cb;
+} ghostty_runtime_config_s;
+
+// apprt.ipc.Target.Key
+typedef enum {
+ GHOSTTY_IPC_TARGET_CLASS,
+ GHOSTTY_IPC_TARGET_DETECT,
+} ghostty_ipc_target_tag_e;
+
+typedef union {
+ char *klass;
+} ghostty_ipc_target_u;
+
+typedef struct {
+ ghostty_ipc_target_tag_e tag;
+ ghostty_ipc_target_u target;
+} chostty_ipc_target_s;
+
+// apprt.ipc.Action.NewWindow
+typedef struct {
+ // This should be a null terminated list of strings.
+ const char **arguments;
+} ghostty_ipc_action_new_window_s;
+
+typedef union {
+ ghostty_ipc_action_new_window_s new_window;
+} ghostty_ipc_action_u;
+
+// apprt.ipc.Action.Key
+typedef enum {
+ GHOSTTY_IPC_ACTION_NEW_WINDOW,
+} ghostty_ipc_action_tag_e;
+
+//-------------------------------------------------------------------
+// Published API
+
+GHOSTTY_API int ghostty_init(uintptr_t, char**);
+GHOSTTY_API void ghostty_cli_try_action(void);
+GHOSTTY_API ghostty_info_s ghostty_info(void);
+GHOSTTY_API const char* ghostty_translate(const char*);
+GHOSTTY_API void ghostty_string_free(ghostty_string_s);
+
+GHOSTTY_API ghostty_config_t ghostty_config_new();
+GHOSTTY_API void ghostty_config_free(ghostty_config_t);
+GHOSTTY_API ghostty_config_t ghostty_config_clone(ghostty_config_t);
+GHOSTTY_API void ghostty_config_load_cli_args(ghostty_config_t);
+GHOSTTY_API void ghostty_config_load_file(ghostty_config_t, const char*);
+GHOSTTY_API void ghostty_config_load_default_files(ghostty_config_t);
+GHOSTTY_API void ghostty_config_load_recursive_files(ghostty_config_t);
+GHOSTTY_API void ghostty_config_finalize(ghostty_config_t);
+GHOSTTY_API bool ghostty_config_get(ghostty_config_t, void*, const char*, uintptr_t);
+GHOSTTY_API ghostty_input_trigger_s ghostty_config_trigger(ghostty_config_t,
+ const char*,
+ uintptr_t);
+GHOSTTY_API uint32_t ghostty_config_diagnostics_count(ghostty_config_t);
+GHOSTTY_API ghostty_diagnostic_s ghostty_config_get_diagnostic(ghostty_config_t, uint32_t);
+GHOSTTY_API ghostty_string_s ghostty_config_open_path(void);
+
+GHOSTTY_API ghostty_app_t ghostty_app_new(const ghostty_runtime_config_s*,
+ ghostty_config_t);
+GHOSTTY_API void ghostty_app_free(ghostty_app_t);
+GHOSTTY_API void ghostty_app_tick(ghostty_app_t);
+GHOSTTY_API void* ghostty_app_userdata(ghostty_app_t);
+GHOSTTY_API void ghostty_app_set_focus(ghostty_app_t, bool);
+GHOSTTY_API bool ghostty_app_key(ghostty_app_t, ghostty_input_key_s);
+GHOSTTY_API bool ghostty_app_key_is_binding(ghostty_app_t, ghostty_input_key_s);
+GHOSTTY_API void ghostty_app_keyboard_changed(ghostty_app_t);
+GHOSTTY_API void ghostty_app_open_config(ghostty_app_t);
+GHOSTTY_API void ghostty_app_update_config(ghostty_app_t, ghostty_config_t);
+GHOSTTY_API bool ghostty_app_needs_confirm_quit(ghostty_app_t);
+GHOSTTY_API bool ghostty_app_has_global_keybinds(ghostty_app_t);
+GHOSTTY_API void ghostty_app_set_color_scheme(ghostty_app_t, ghostty_color_scheme_e);
+
+GHOSTTY_API ghostty_surface_config_s ghostty_surface_config_new();
+
+GHOSTTY_API ghostty_surface_t ghostty_surface_new(ghostty_app_t,
+ const ghostty_surface_config_s*);
+GHOSTTY_API void ghostty_surface_free(ghostty_surface_t);
+GHOSTTY_API void* ghostty_surface_userdata(ghostty_surface_t);
+GHOSTTY_API ghostty_app_t ghostty_surface_app(ghostty_surface_t);
+GHOSTTY_API ghostty_surface_config_s ghostty_surface_inherited_config(ghostty_surface_t, ghostty_surface_context_e);
+GHOSTTY_API void ghostty_surface_update_config(ghostty_surface_t, ghostty_config_t);
+GHOSTTY_API bool ghostty_surface_needs_confirm_quit(ghostty_surface_t);
+GHOSTTY_API bool ghostty_surface_process_exited(ghostty_surface_t);
+GHOSTTY_API void ghostty_surface_refresh(ghostty_surface_t);
+GHOSTTY_API void ghostty_surface_draw(ghostty_surface_t);
+GHOSTTY_API void ghostty_surface_feed_data(ghostty_surface_t, const uint8_t*, size_t);
+GHOSTTY_API void ghostty_surface_set_write_callback(ghostty_surface_t,
+ ghostty_surface_write_fn,
+ void*);
+GHOSTTY_API void ghostty_surface_set_content_scale(ghostty_surface_t, double, double);
+GHOSTTY_API void ghostty_surface_set_focus(ghostty_surface_t, bool);
+GHOSTTY_API void ghostty_surface_set_occlusion(ghostty_surface_t, bool);
+GHOSTTY_API void ghostty_surface_set_size(ghostty_surface_t, uint32_t, uint32_t);
+GHOSTTY_API ghostty_surface_size_s ghostty_surface_size(ghostty_surface_t);
+GHOSTTY_API uint64_t ghostty_surface_foreground_pid(ghostty_surface_t);
+GHOSTTY_API ghostty_string_s ghostty_surface_tty_name(ghostty_surface_t);
+GHOSTTY_API void ghostty_surface_set_color_scheme(ghostty_surface_t,
+ ghostty_color_scheme_e);
+GHOSTTY_API ghostty_input_mods_e ghostty_surface_key_translation_mods(ghostty_surface_t,
+ ghostty_input_mods_e);
+GHOSTTY_API bool ghostty_surface_key(ghostty_surface_t, ghostty_input_key_s);
+GHOSTTY_API bool ghostty_surface_key_is_binding(ghostty_surface_t,
+ ghostty_input_key_s,
+ ghostty_binding_flags_e*);
+GHOSTTY_API void ghostty_surface_text(ghostty_surface_t, const char*, uintptr_t);
+GHOSTTY_API void ghostty_surface_preedit(ghostty_surface_t, const char*, uintptr_t);
+GHOSTTY_API bool ghostty_surface_mouse_captured(ghostty_surface_t);
+GHOSTTY_API bool ghostty_surface_mouse_button(ghostty_surface_t,
+ ghostty_input_mouse_state_e,
+ ghostty_input_mouse_button_e,
+ ghostty_input_mods_e);
+GHOSTTY_API void ghostty_surface_mouse_pos(ghostty_surface_t,
+ double,
+ double,
+ ghostty_input_mods_e);
+GHOSTTY_API void ghostty_surface_mouse_scroll(ghostty_surface_t,
+ double,
+ double,
+ ghostty_input_scroll_mods_t);
+GHOSTTY_API void ghostty_surface_mouse_pressure(ghostty_surface_t, uint32_t, double);
+GHOSTTY_API void ghostty_surface_ime_point(ghostty_surface_t, double*, double*, double*, double*);
+GHOSTTY_API void ghostty_surface_request_close(ghostty_surface_t);
+GHOSTTY_API void ghostty_surface_split(ghostty_surface_t, ghostty_action_split_direction_e);
+GHOSTTY_API void ghostty_surface_split_focus(ghostty_surface_t,
+ ghostty_action_goto_split_e);
+GHOSTTY_API void ghostty_surface_split_resize(ghostty_surface_t,
+ ghostty_action_resize_split_direction_e,
+ uint16_t);
+GHOSTTY_API void ghostty_surface_split_equalize(ghostty_surface_t);
+GHOSTTY_API bool ghostty_surface_binding_action(ghostty_surface_t, const char*, uintptr_t);
+GHOSTTY_API void ghostty_surface_complete_clipboard_request(ghostty_surface_t,
+ const char*,
+ void*,
+ bool);
+GHOSTTY_API bool ghostty_surface_has_selection(ghostty_surface_t);
+GHOSTTY_API bool ghostty_surface_read_selection(ghostty_surface_t, ghostty_text_s*);
+GHOSTTY_API bool ghostty_surface_read_text(ghostty_surface_t,
+ ghostty_selection_s,
+ ghostty_text_s*);
+GHOSTTY_API void ghostty_surface_free_text(ghostty_surface_t, ghostty_text_s*);
+
+#ifdef __APPLE__
+GHOSTTY_API void ghostty_surface_set_display_id(ghostty_surface_t, uint32_t);
+GHOSTTY_API void* ghostty_surface_quicklook_font(ghostty_surface_t);
+GHOSTTY_API bool ghostty_surface_quicklook_word(ghostty_surface_t, ghostty_text_s*);
+#endif
+
+GHOSTTY_API ghostty_inspector_t ghostty_surface_inspector(ghostty_surface_t);
+GHOSTTY_API void ghostty_inspector_free(ghostty_surface_t);
+GHOSTTY_API void ghostty_inspector_set_focus(ghostty_inspector_t, bool);
+GHOSTTY_API void ghostty_inspector_set_content_scale(ghostty_inspector_t, double, double);
+GHOSTTY_API void ghostty_inspector_set_size(ghostty_inspector_t, uint32_t, uint32_t);
+GHOSTTY_API void ghostty_inspector_mouse_button(ghostty_inspector_t,
+ ghostty_input_mouse_state_e,
+ ghostty_input_mouse_button_e,
+ ghostty_input_mods_e);
+GHOSTTY_API void ghostty_inspector_mouse_pos(ghostty_inspector_t, double, double);
+GHOSTTY_API void ghostty_inspector_mouse_scroll(ghostty_inspector_t,
+ double,
+ double,
+ ghostty_input_scroll_mods_t);
+GHOSTTY_API void ghostty_inspector_key(ghostty_inspector_t,
+ ghostty_input_action_e,
+ ghostty_input_key_e,
+ ghostty_input_mods_e);
+GHOSTTY_API void ghostty_inspector_text(ghostty_inspector_t, const char*);
+
+#ifdef __APPLE__
+GHOSTTY_API bool ghostty_inspector_metal_init(ghostty_inspector_t, void*);
+GHOSTTY_API void ghostty_inspector_metal_render(ghostty_inspector_t, void*, void*);
+GHOSTTY_API bool ghostty_inspector_metal_shutdown(ghostty_inspector_t);
+#endif
+
+// APIs I'd like to get rid of eventually but are still needed for now.
+// Don't use these unless you know what you're doing.
+GHOSTTY_API void ghostty_set_window_background_blur(ghostty_app_t, void*);
+
+// Benchmark API, if available.
+GHOSTTY_API bool ghostty_benchmark_cli(const char*, const char*);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* GHOSTTY_H */
diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt.h
new file mode 100644
index 00000000000..4f8fef88ecc
--- /dev/null
+++ b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt.h
@@ -0,0 +1,87 @@
+/**
+ * @file vt.h
+ *
+ * libghostty-vt - Virtual terminal emulator library
+ *
+ * This library provides functionality for parsing and handling terminal
+ * escape sequences as well as maintaining terminal state such as styles,
+ * cursor position, screen, scrollback, and more.
+ *
+ * WARNING: This is an incomplete, work-in-progress API. It is not yet
+ * stable and is definitely going to change.
+ */
+
+/**
+ * @mainpage libghostty-vt - Virtual Terminal Emulator Library
+ *
+ * libghostty-vt is a C library which implements a modern terminal emulator,
+ * extracted from the [Ghostty](https://ghostty.org) terminal emulator.
+ *
+ * libghostty-vt contains the logic for handling the core parts of a terminal
+ * emulator: parsing terminal escape sequences, maintaining terminal state,
+ * encoding input events, etc. It can handle scrollback, line wrapping,
+ * reflow on resize, and more.
+ *
+ * @warning This library is currently in development and the API is not yet stable.
+ * Breaking changes are expected in future versions. Use with caution in production code.
+ *
+ * @section groups_sec API Reference
+ *
+ * The API is organized into the following groups:
+ * - @ref key "Key Encoding" - Encode key events into terminal sequences
+ * - @ref osc "OSC Parser" - Parse OSC (Operating System Command) sequences
+ * - @ref sgr "SGR Parser" - Parse SGR (Select Graphic Rendition) sequences
+ * - @ref paste "Paste Utilities" - Validate paste data safety
+ * - @ref allocator "Memory Management" - Memory management and custom allocators
+ * - @ref wasm "WebAssembly Utilities" - WebAssembly convenience functions
+ *
+ * @section examples_sec Examples
+ *
+ * Complete working examples:
+ * - @ref c-vt/src/main.c - OSC parser example
+ * - @ref c-vt-key-encode/src/main.c - Key encoding example
+ * - @ref c-vt-paste/src/main.c - Paste safety check example
+ * - @ref c-vt-sgr/src/main.c - SGR parser example
+ *
+ */
+
+/** @example c-vt/src/main.c
+ * This example demonstrates how to use the OSC parser to parse an OSC sequence,
+ * extract command information, and retrieve command-specific data like window titles.
+ */
+
+/** @example c-vt-key-encode/src/main.c
+ * This example demonstrates how to use the key encoder to convert key events
+ * into terminal escape sequences using the Kitty keyboard protocol.
+ */
+
+/** @example c-vt-paste/src/main.c
+ * This example demonstrates how to use the paste utilities to check if
+ * paste data is safe before sending it to the terminal.
+ */
+
+/** @example c-vt-sgr/src/main.c
+ * This example demonstrates how to use the SGR parser to parse terminal
+ * styling sequences and extract text attributes like colors and underline styles.
+ */
+
+#ifndef GHOSTTY_VT_H
+#define GHOSTTY_VT_H
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* GHOSTTY_VT_H */
diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/allocator.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/allocator.h
new file mode 100644
index 00000000000..4cebe91bb10
--- /dev/null
+++ b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/allocator.h
@@ -0,0 +1,196 @@
+/**
+ * @file allocator.h
+ *
+ * Memory management interface for libghostty-vt.
+ */
+
+#ifndef GHOSTTY_VT_ALLOCATOR_H
+#define GHOSTTY_VT_ALLOCATOR_H
+
+#include
+#include
+#include
+
+/** @defgroup allocator Memory Management
+ *
+ * libghostty-vt does require memory allocation for various operations,
+ * but is resilient to allocation failures and will gracefully handle
+ * out-of-memory situations by returning error codes.
+ *
+ * The exact memory management semantics are documented in the relevant
+ * functions and data structures.
+ *
+ * libghostty-vt uses explicit memory allocation via an allocator
+ * interface provided by GhosttyAllocator. The interface is based on the
+ * [Zig](https://ziglang.org) allocator interface, since this has been
+ * shown to be a flexible and powerful interface in practice and enables
+ * a wide variety of allocation strategies.
+ *
+ * **For the common case, you can pass NULL as the allocator for any
+ * function that accepts one,** and libghostty will use a default allocator.
+ * The default allocator will be libc malloc/free if libc is linked.
+ * Otherwise, a custom allocator is used (currently Zig's SMP allocator)
+ * that doesn't require any external dependencies.
+ *
+ * ## Basic Usage
+ *
+ * For simple use cases, you can ignore this interface entirely by passing NULL
+ * as the allocator parameter to functions that accept one. This will use the
+ * default allocator (typically libc malloc/free, if libc is linked, but
+ * we provide our own default allocator if libc isn't linked).
+ *
+ * To use a custom allocator:
+ * 1. Implement the GhosttyAllocatorVtable function pointers
+ * 2. Create a GhosttyAllocator struct with your vtable and context
+ * 3. Pass the allocator to functions that accept one
+ *
+ * @{
+ */
+
+/**
+ * Function table for custom memory allocator operations.
+ *
+ * This vtable defines the interface for a custom memory allocator. All
+ * function pointers must be valid and non-NULL.
+ *
+ * @ingroup allocator
+ *
+ * If you're not going to use a custom allocator, you can ignore all of
+ * this. All functions that take an allocator pointer allow NULL to use a
+ * default allocator.
+ *
+ * The interface is based on the Zig allocator interface. I'll say up front
+ * that it is easy to look at this interface and think "wow, this is really
+ * overcomplicated". The reason for this complexity is well thought out by
+ * the Zig folks, and it enables a diverse set of allocation strategies
+ * as shown by the Zig ecosystem. As a consolation, please note that many
+ * of the arguments are only needed for advanced use cases and can be
+ * safely ignored in simple implementations. For example, if you look at
+ * the Zig implementation of the libc allocator in `lib/std/heap.zig`
+ * (search for CAllocator), you'll see it is very simple.
+ *
+ * We chose to align with the Zig allocator interface because:
+ *
+ * 1. It is a proven interface that serves a wide variety of use cases
+ * in the real world via the Zig ecosystem. It's shown to work.
+ *
+ * 2. Our core implementation itself is Zig, and this lets us very
+ * cheaply and easily convert between C and Zig allocators.
+ *
+ * NOTE(mitchellh): In the future, we can have default implementations of
+ * resize/remap and allow those to be null.
+ */
+typedef struct {
+ /**
+ * Return a pointer to `len` bytes with specified `alignment`, or return
+ * `NULL` indicating the allocation failed.
+ *
+ * @param ctx The allocator context
+ * @param len Number of bytes to allocate
+ * @param alignment Required alignment for the allocation. Guaranteed to
+ * be a power of two between 1 and 16 inclusive.
+ * @param ret_addr First return address of the allocation call stack (0 if not provided)
+ * @return Pointer to allocated memory, or NULL if allocation failed
+ */
+ void* (*alloc)(void *ctx, size_t len, uint8_t alignment, uintptr_t ret_addr);
+
+ /**
+ * Attempt to expand or shrink memory in place.
+ *
+ * `memory_len` must equal the length requested from the most recent
+ * successful call to `alloc`, `resize`, or `remap`. `alignment` must
+ * equal the same value that was passed as the `alignment` parameter to
+ * the original `alloc` call.
+ *
+ * `new_len` must be greater than zero.
+ *
+ * @param ctx The allocator context
+ * @param memory Pointer to the memory block to resize
+ * @param memory_len Current size of the memory block
+ * @param alignment Alignment (must match original allocation)
+ * @param new_len New requested size
+ * @param ret_addr First return address of the allocation call stack (0 if not provided)
+ * @return true if resize was successful in-place, false if relocation would be required
+ */
+ bool (*resize)(void *ctx, void *memory, size_t memory_len, uint8_t alignment, size_t new_len, uintptr_t ret_addr);
+
+ /**
+ * Attempt to expand or shrink memory, allowing relocation.
+ *
+ * `memory_len` must equal the length requested from the most recent
+ * successful call to `alloc`, `resize`, or `remap`. `alignment` must
+ * equal the same value that was passed as the `alignment` parameter to
+ * the original `alloc` call.
+ *
+ * A non-`NULL` return value indicates the resize was successful. The
+ * allocation may have same address, or may have been relocated. In either
+ * case, the allocation now has size of `new_len`. A `NULL` return value
+ * indicates that the resize would be equivalent to allocating new memory,
+ * copying the bytes from the old memory, and then freeing the old memory.
+ * In such case, it is more efficient for the caller to perform the copy.
+ *
+ * `new_len` must be greater than zero.
+ *
+ * @param ctx The allocator context
+ * @param memory Pointer to the memory block to remap
+ * @param memory_len Current size of the memory block
+ * @param alignment Alignment (must match original allocation)
+ * @param new_len New requested size
+ * @param ret_addr First return address of the allocation call stack (0 if not provided)
+ * @return Pointer to resized memory (may be relocated), or NULL if manual copy is needed
+ */
+ void* (*remap)(void *ctx, void *memory, size_t memory_len, uint8_t alignment, size_t new_len, uintptr_t ret_addr);
+
+ /**
+ * Free and invalidate a region of memory.
+ *
+ * `memory_len` must equal the length requested from the most recent
+ * successful call to `alloc`, `resize`, or `remap`. `alignment` must
+ * equal the same value that was passed as the `alignment` parameter to
+ * the original `alloc` call.
+ *
+ * @param ctx The allocator context
+ * @param memory Pointer to the memory block to free
+ * @param memory_len Size of the memory block
+ * @param alignment Alignment (must match original allocation)
+ * @param ret_addr First return address of the allocation call stack (0 if not provided)
+ */
+ void (*free)(void *ctx, void *memory, size_t memory_len, uint8_t alignment, uintptr_t ret_addr);
+} GhosttyAllocatorVtable;
+
+/**
+ * Custom memory allocator.
+ *
+ * For functions that take an allocator pointer, a NULL pointer indicates
+ * that the default allocator should be used. The default allocator will
+ * be libc malloc/free if we're linking to libc. If libc isn't linked,
+ * a custom allocator is used (currently Zig's SMP allocator).
+ *
+ * @ingroup allocator
+ *
+ * Usage example:
+ * @code
+ * GhosttyAllocator allocator = {
+ * .vtable = &my_allocator_vtable,
+ * .ctx = my_allocator_state
+ * };
+ * @endcode
+ */
+typedef struct GhosttyAllocator {
+ /**
+ * Opaque context pointer passed to all vtable functions.
+ * This allows the allocator implementation to maintain state
+ * or reference external resources needed for memory management.
+ */
+ void *ctx;
+
+ /**
+ * Pointer to the allocator's vtable containing function pointers
+ * for memory operations (alloc, resize, remap, free).
+ */
+ const GhosttyAllocatorVtable *vtable;
+} GhosttyAllocator;
+
+/** @} */
+
+#endif /* GHOSTTY_VT_ALLOCATOR_H */
diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/color.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/color.h
new file mode 100644
index 00000000000..0d57b8db4ab
--- /dev/null
+++ b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/color.h
@@ -0,0 +1,96 @@
+/**
+ * @file color.h
+ *
+ * Color types and utilities.
+ */
+
+#ifndef GHOSTTY_VT_COLOR_H
+#define GHOSTTY_VT_COLOR_H
+
+#include
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/**
+ * RGB color value.
+ *
+ * @ingroup sgr
+ */
+typedef struct {
+ uint8_t r; /**< Red component (0-255) */
+ uint8_t g; /**< Green component (0-255) */
+ uint8_t b; /**< Blue component (0-255) */
+} GhosttyColorRgb;
+
+/**
+ * Palette color index (0-255).
+ *
+ * @ingroup sgr
+ */
+typedef uint8_t GhosttyColorPaletteIndex;
+
+/** @addtogroup sgr
+ * @{
+ */
+
+/** Black color (0) @ingroup sgr */
+#define GHOSTTY_COLOR_NAMED_BLACK 0
+/** Red color (1) @ingroup sgr */
+#define GHOSTTY_COLOR_NAMED_RED 1
+/** Green color (2) @ingroup sgr */
+#define GHOSTTY_COLOR_NAMED_GREEN 2
+/** Yellow color (3) @ingroup sgr */
+#define GHOSTTY_COLOR_NAMED_YELLOW 3
+/** Blue color (4) @ingroup sgr */
+#define GHOSTTY_COLOR_NAMED_BLUE 4
+/** Magenta color (5) @ingroup sgr */
+#define GHOSTTY_COLOR_NAMED_MAGENTA 5
+/** Cyan color (6) @ingroup sgr */
+#define GHOSTTY_COLOR_NAMED_CYAN 6
+/** White color (7) @ingroup sgr */
+#define GHOSTTY_COLOR_NAMED_WHITE 7
+/** Bright black color (8) @ingroup sgr */
+#define GHOSTTY_COLOR_NAMED_BRIGHT_BLACK 8
+/** Bright red color (9) @ingroup sgr */
+#define GHOSTTY_COLOR_NAMED_BRIGHT_RED 9
+/** Bright green color (10) @ingroup sgr */
+#define GHOSTTY_COLOR_NAMED_BRIGHT_GREEN 10
+/** Bright yellow color (11) @ingroup sgr */
+#define GHOSTTY_COLOR_NAMED_BRIGHT_YELLOW 11
+/** Bright blue color (12) @ingroup sgr */
+#define GHOSTTY_COLOR_NAMED_BRIGHT_BLUE 12
+/** Bright magenta color (13) @ingroup sgr */
+#define GHOSTTY_COLOR_NAMED_BRIGHT_MAGENTA 13
+/** Bright cyan color (14) @ingroup sgr */
+#define GHOSTTY_COLOR_NAMED_BRIGHT_CYAN 14
+/** Bright white color (15) @ingroup sgr */
+#define GHOSTTY_COLOR_NAMED_BRIGHT_WHITE 15
+
+/** @} */
+
+/**
+ * Get the RGB color components.
+ *
+ * This function extracts the individual red, green, and blue components
+ * from a GhosttyColorRgb value. Primarily useful in WebAssembly environments
+ * where accessing struct fields directly is difficult.
+ *
+ * @param color The RGB color value
+ * @param r Pointer to store the red component (0-255)
+ * @param g Pointer to store the green component (0-255)
+ * @param b Pointer to store the blue component (0-255)
+ *
+ * @ingroup sgr
+ */
+void ghostty_color_rgb_get(GhosttyColorRgb color,
+ uint8_t* r,
+ uint8_t* g,
+ uint8_t* b);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* GHOSTTY_VT_COLOR_H */
diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/key.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/key.h
new file mode 100644
index 00000000000..772b5d43bcf
--- /dev/null
+++ b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/key.h
@@ -0,0 +1,80 @@
+/**
+ * @file key.h
+ *
+ * Key encoding module - encode key events into terminal escape sequences.
+ */
+
+#ifndef GHOSTTY_VT_KEY_H
+#define GHOSTTY_VT_KEY_H
+
+/** @defgroup key Key Encoding
+ *
+ * Utilities for encoding key events into terminal escape sequences,
+ * supporting both legacy encoding as well as Kitty Keyboard Protocol.
+ *
+ * ## Basic Usage
+ *
+ * 1. Create an encoder instance with ghostty_key_encoder_new()
+ * 2. Configure encoder options with ghostty_key_encoder_setopt().
+ * 3. For each key event:
+ * - Create a key event with ghostty_key_event_new()
+ * - Set event properties (action, key, modifiers, etc.)
+ * - Encode with ghostty_key_encoder_encode()
+ * - Free the event with ghostty_key_event_free()
+ * - Note: You can also reuse the same key event multiple times by
+ * changing its properties.
+ * 4. Free the encoder with ghostty_key_encoder_free() when done
+ *
+ * ## Example
+ *
+ * @code{.c}
+ * #include
+ * #include
+ * #include
+ *
+ * int main() {
+ * // Create encoder
+ * GhosttyKeyEncoder encoder;
+ * GhosttyResult result = ghostty_key_encoder_new(NULL, &encoder);
+ * assert(result == GHOSTTY_SUCCESS);
+ *
+ * // Enable Kitty keyboard protocol with all features
+ * ghostty_key_encoder_setopt(encoder, GHOSTTY_KEY_ENCODER_OPT_KITTY_FLAGS,
+ * &(uint8_t){GHOSTTY_KITTY_KEY_ALL});
+ *
+ * // Create and configure key event for Ctrl+C press
+ * GhosttyKeyEvent event;
+ * result = ghostty_key_event_new(NULL, &event);
+ * assert(result == GHOSTTY_SUCCESS);
+ * ghostty_key_event_set_action(event, GHOSTTY_KEY_ACTION_PRESS);
+ * ghostty_key_event_set_key(event, GHOSTTY_KEY_C);
+ * ghostty_key_event_set_mods(event, GHOSTTY_MODS_CTRL);
+ *
+ * // Encode the key event
+ * char buf[128];
+ * size_t written = 0;
+ * result = ghostty_key_encoder_encode(encoder, event, buf, sizeof(buf), &written);
+ * assert(result == GHOSTTY_SUCCESS);
+ *
+ * // Use the encoded sequence (e.g., write to terminal)
+ * fwrite(buf, 1, written, stdout);
+ *
+ * // Cleanup
+ * ghostty_key_event_free(event);
+ * ghostty_key_encoder_free(encoder);
+ * return 0;
+ * }
+ * @endcode
+ *
+ * For a complete working example, see example/c-vt-key-encode in the
+ * repository.
+ *
+ * @{
+ */
+
+#include
+#include
+
+/** @} */
+
+#endif /* GHOSTTY_VT_KEY_H */
diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/key/encoder.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/key/encoder.h
new file mode 100644
index 00000000000..766a2942796
--- /dev/null
+++ b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/key/encoder.h
@@ -0,0 +1,221 @@
+/**
+ * @file encoder.h
+ *
+ * Key event encoding to terminal escape sequences.
+ */
+
+#ifndef GHOSTTY_VT_KEY_ENCODER_H
+#define GHOSTTY_VT_KEY_ENCODER_H
+
+#include
+#include
+#include
+#include
+#include
+
+/**
+ * Opaque handle to a key encoder instance.
+ *
+ * This handle represents a key encoder that converts key events into terminal
+ * escape sequences.
+ *
+ * @ingroup key
+ */
+typedef struct GhosttyKeyEncoder *GhosttyKeyEncoder;
+
+/**
+ * Kitty keyboard protocol flags.
+ *
+ * Bitflags representing the various modes of the Kitty keyboard protocol.
+ * These can be combined using bitwise OR operations. Valid values all
+ * start with `GHOSTTY_KITTY_KEY_`.
+ *
+ * @ingroup key
+ */
+typedef uint8_t GhosttyKittyKeyFlags;
+
+/** Kitty keyboard protocol disabled (all flags off) */
+#define GHOSTTY_KITTY_KEY_DISABLED 0
+
+/** Disambiguate escape codes */
+#define GHOSTTY_KITTY_KEY_DISAMBIGUATE (1 << 0)
+
+/** Report key press and release events */
+#define GHOSTTY_KITTY_KEY_REPORT_EVENTS (1 << 1)
+
+/** Report alternate key codes */
+#define GHOSTTY_KITTY_KEY_REPORT_ALTERNATES (1 << 2)
+
+/** Report all key events including those normally handled by the terminal */
+#define GHOSTTY_KITTY_KEY_REPORT_ALL (1 << 3)
+
+/** Report associated text with key events */
+#define GHOSTTY_KITTY_KEY_REPORT_ASSOCIATED (1 << 4)
+
+/** All Kitty keyboard protocol flags enabled */
+#define GHOSTTY_KITTY_KEY_ALL (GHOSTTY_KITTY_KEY_DISAMBIGUATE | GHOSTTY_KITTY_KEY_REPORT_EVENTS | GHOSTTY_KITTY_KEY_REPORT_ALTERNATES | GHOSTTY_KITTY_KEY_REPORT_ALL | GHOSTTY_KITTY_KEY_REPORT_ASSOCIATED)
+
+/**
+ * macOS option key behavior.
+ *
+ * Determines whether the "option" key on macOS is treated as "alt" or not.
+ * See the Ghostty `macos-option-as-alt` configuration option for more details.
+ *
+ * @ingroup key
+ */
+typedef enum {
+ /** Option key is not treated as alt */
+ GHOSTTY_OPTION_AS_ALT_FALSE = 0,
+ /** Option key is treated as alt */
+ GHOSTTY_OPTION_AS_ALT_TRUE = 1,
+ /** Only left option key is treated as alt */
+ GHOSTTY_OPTION_AS_ALT_LEFT = 2,
+ /** Only right option key is treated as alt */
+ GHOSTTY_OPTION_AS_ALT_RIGHT = 3,
+} GhosttyOptionAsAlt;
+
+/**
+ * Key encoder option identifiers.
+ *
+ * These values are used with ghostty_key_encoder_setopt() to configure
+ * the behavior of the key encoder.
+ *
+ * @ingroup key
+ */
+typedef enum {
+ /** Terminal DEC mode 1: cursor key application mode (value: bool) */
+ GHOSTTY_KEY_ENCODER_OPT_CURSOR_KEY_APPLICATION = 0,
+
+ /** Terminal DEC mode 66: keypad key application mode (value: bool) */
+ GHOSTTY_KEY_ENCODER_OPT_KEYPAD_KEY_APPLICATION = 1,
+
+ /** Terminal DEC mode 1035: ignore keypad with numlock (value: bool) */
+ GHOSTTY_KEY_ENCODER_OPT_IGNORE_KEYPAD_WITH_NUMLOCK = 2,
+
+ /** Terminal DEC mode 1036: alt sends escape prefix (value: bool) */
+ GHOSTTY_KEY_ENCODER_OPT_ALT_ESC_PREFIX = 3,
+
+ /** xterm modifyOtherKeys mode 2 (value: bool) */
+ GHOSTTY_KEY_ENCODER_OPT_MODIFY_OTHER_KEYS_STATE_2 = 4,
+
+ /** Kitty keyboard protocol flags (value: GhosttyKittyKeyFlags bitmask) */
+ GHOSTTY_KEY_ENCODER_OPT_KITTY_FLAGS = 5,
+
+ /** macOS option-as-alt setting (value: GhosttyOptionAsAlt) */
+ GHOSTTY_KEY_ENCODER_OPT_MACOS_OPTION_AS_ALT = 6,
+} GhosttyKeyEncoderOption;
+
+/**
+ * Create a new key encoder instance.
+ *
+ * Creates a new key encoder with default options. The encoder can be configured
+ * using ghostty_key_encoder_setopt() and must be freed using
+ * ghostty_key_encoder_free() when no longer needed.
+ *
+ * @param allocator Pointer to the allocator to use for memory management, or NULL to use the default allocator
+ * @param encoder Pointer to store the created encoder handle
+ * @return GHOSTTY_SUCCESS on success, or an error code on failure
+ *
+ * @ingroup key
+ */
+GhosttyResult ghostty_key_encoder_new(const GhosttyAllocator *allocator, GhosttyKeyEncoder *encoder);
+
+/**
+ * Free a key encoder instance.
+ *
+ * Releases all resources associated with the key encoder. After this call,
+ * the encoder handle becomes invalid and must not be used.
+ *
+ * @param encoder The encoder handle to free (may be NULL)
+ *
+ * @ingroup key
+ */
+void ghostty_key_encoder_free(GhosttyKeyEncoder encoder);
+
+/**
+ * Set an option on the key encoder.
+ *
+ * Configures the behavior of the key encoder. Options control various aspects
+ * of encoding such as terminal modes (cursor key application mode, keypad mode),
+ * protocol selection (Kitty keyboard protocol flags), and platform-specific
+ * behaviors (macOS option-as-alt).
+ *
+ * A null pointer value does nothing. It does not reset the value to the
+ * default. The setopt call will do nothing.
+ *
+ * @param encoder The encoder handle, must not be NULL
+ * @param option The option to set
+ * @param value Pointer to the value to set (type depends on the option)
+ *
+ * @ingroup key
+ */
+void ghostty_key_encoder_setopt(GhosttyKeyEncoder encoder, GhosttyKeyEncoderOption option, const void *value);
+
+/**
+ * Encode a key event into a terminal escape sequence.
+ *
+ * Converts a key event into the appropriate terminal escape sequence based on
+ * the encoder's current options. The sequence is written to the provided buffer.
+ *
+ * Not all key events produce output. For example, unmodified modifier keys
+ * typically don't generate escape sequences. Check the out_len parameter to
+ * determine if any data was written.
+ *
+ * If the output buffer is too small, this function returns GHOSTTY_OUT_OF_MEMORY
+ * and out_len will contain the required buffer size. The caller can then
+ * allocate a larger buffer and call the function again.
+ *
+ * @param encoder The encoder handle, must not be NULL
+ * @param event The key event to encode, must not be NULL
+ * @param out_buf Buffer to write the encoded sequence to
+ * @param out_buf_size Size of the output buffer in bytes
+ * @param out_len Pointer to store the number of bytes written (may be NULL)
+ * @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_MEMORY if buffer too small, or other error code
+ *
+ * ## Example: Calculate required buffer size
+ *
+ * @code{.c}
+ * // Query the required size with a NULL buffer (always returns OUT_OF_MEMORY)
+ * size_t required = 0;
+ * GhosttyResult result = ghostty_key_encoder_encode(encoder, event, NULL, 0, &required);
+ * assert(result == GHOSTTY_OUT_OF_MEMORY);
+ *
+ * // Allocate buffer of required size
+ * char *buf = malloc(required);
+ *
+ * // Encode with properly sized buffer
+ * size_t written = 0;
+ * result = ghostty_key_encoder_encode(encoder, event, buf, required, &written);
+ * assert(result == GHOSTTY_SUCCESS);
+ *
+ * // Use the encoded sequence...
+ *
+ * free(buf);
+ * @endcode
+ *
+ * ## Example: Direct encoding with static buffer
+ *
+ * @code{.c}
+ * // Most escape sequences are short, so a static buffer often suffices
+ * char buf[128];
+ * size_t written = 0;
+ * GhosttyResult result = ghostty_key_encoder_encode(encoder, event, buf, sizeof(buf), &written);
+ *
+ * if (result == GHOSTTY_SUCCESS) {
+ * // Write the encoded sequence to the terminal
+ * write(pty_fd, buf, written);
+ * } else if (result == GHOSTTY_OUT_OF_MEMORY) {
+ * // Buffer too small, written contains required size
+ * char *dynamic_buf = malloc(written);
+ * result = ghostty_key_encoder_encode(encoder, event, dynamic_buf, written, &written);
+ * assert(result == GHOSTTY_SUCCESS);
+ * write(pty_fd, dynamic_buf, written);
+ * free(dynamic_buf);
+ * }
+ * @endcode
+ *
+ * @ingroup key
+ */
+GhosttyResult ghostty_key_encoder_encode(GhosttyKeyEncoder encoder, GhosttyKeyEvent event, char *out_buf, size_t out_buf_size, size_t *out_len);
+
+#endif /* GHOSTTY_VT_KEY_ENCODER_H */
diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/key/event.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/key/event.h
new file mode 100644
index 00000000000..dbd2e9f841a
--- /dev/null
+++ b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/key/event.h
@@ -0,0 +1,474 @@
+/**
+ * @file event.h
+ *
+ * Key event representation and manipulation.
+ */
+
+#ifndef GHOSTTY_VT_KEY_EVENT_H
+#define GHOSTTY_VT_KEY_EVENT_H
+
+#include
+#include
+#include
+#include
+#include
+
+/**
+ * Opaque handle to a key event.
+ *
+ * This handle represents a keyboard input event containing information about
+ * the physical key pressed, modifiers, and generated text.
+ *
+ * @ingroup key
+ */
+typedef struct GhosttyKeyEvent *GhosttyKeyEvent;
+
+/**
+ * Keyboard input event types.
+ *
+ * @ingroup key
+ */
+typedef enum {
+ /** Key was released */
+ GHOSTTY_KEY_ACTION_RELEASE = 0,
+ /** Key was pressed */
+ GHOSTTY_KEY_ACTION_PRESS = 1,
+ /** Key is being repeated (held down) */
+ GHOSTTY_KEY_ACTION_REPEAT = 2,
+} GhosttyKeyAction;
+
+/**
+ * Keyboard modifier keys bitmask.
+ *
+ * A bitmask representing all keyboard modifiers. This tracks which modifier keys
+ * are pressed and, where supported by the platform, which side (left or right)
+ * of each modifier is active.
+ *
+ * Use the GHOSTTY_MODS_* constants to test and set individual modifiers.
+ *
+ * Modifier side bits are only meaningful when the corresponding modifier bit is set.
+ * Not all platforms support distinguishing between left and right modifier
+ * keys and Ghostty is built to expect that some platforms may not provide this
+ * information.
+ *
+ * @ingroup key
+ */
+typedef uint16_t GhosttyMods;
+
+/** Shift key is pressed */
+#define GHOSTTY_MODS_SHIFT (1 << 0)
+/** Control key is pressed */
+#define GHOSTTY_MODS_CTRL (1 << 1)
+/** Alt/Option key is pressed */
+#define GHOSTTY_MODS_ALT (1 << 2)
+/** Super/Command/Windows key is pressed */
+#define GHOSTTY_MODS_SUPER (1 << 3)
+/** Caps Lock is active */
+#define GHOSTTY_MODS_CAPS_LOCK (1 << 4)
+/** Num Lock is active */
+#define GHOSTTY_MODS_NUM_LOCK (1 << 5)
+
+/**
+ * Right shift is pressed (0 = left, 1 = right).
+ * Only meaningful when GHOSTTY_MODS_SHIFT is set.
+ */
+#define GHOSTTY_MODS_SHIFT_SIDE (1 << 6)
+/**
+ * Right ctrl is pressed (0 = left, 1 = right).
+ * Only meaningful when GHOSTTY_MODS_CTRL is set.
+ */
+#define GHOSTTY_MODS_CTRL_SIDE (1 << 7)
+/**
+ * Right alt is pressed (0 = left, 1 = right).
+ * Only meaningful when GHOSTTY_MODS_ALT is set.
+ */
+#define GHOSTTY_MODS_ALT_SIDE (1 << 8)
+/**
+ * Right super is pressed (0 = left, 1 = right).
+ * Only meaningful when GHOSTTY_MODS_SUPER is set.
+ */
+#define GHOSTTY_MODS_SUPER_SIDE (1 << 9)
+
+/**
+ * Physical key codes.
+ *
+ * The set of key codes that Ghostty is aware of. These represent physical keys
+ * on the keyboard and are layout-independent. For example, the "a" key on a US
+ * keyboard is the same as the "ф" key on a Russian keyboard, but both will
+ * report the same key_a value.
+ *
+ * Layout-dependent strings are provided separately as UTF-8 text and are produced
+ * by the platform. These values are based on the W3C UI Events KeyboardEvent code
+ * standard. See: https://www.w3.org/TR/uievents-code
+ *
+ * @ingroup key
+ */
+typedef enum {
+ GHOSTTY_KEY_UNIDENTIFIED = 0,
+
+ // Writing System Keys (W3C § 3.1.1)
+ GHOSTTY_KEY_BACKQUOTE,
+ GHOSTTY_KEY_BACKSLASH,
+ GHOSTTY_KEY_BRACKET_LEFT,
+ GHOSTTY_KEY_BRACKET_RIGHT,
+ GHOSTTY_KEY_COMMA,
+ GHOSTTY_KEY_DIGIT_0,
+ GHOSTTY_KEY_DIGIT_1,
+ GHOSTTY_KEY_DIGIT_2,
+ GHOSTTY_KEY_DIGIT_3,
+ GHOSTTY_KEY_DIGIT_4,
+ GHOSTTY_KEY_DIGIT_5,
+ GHOSTTY_KEY_DIGIT_6,
+ GHOSTTY_KEY_DIGIT_7,
+ GHOSTTY_KEY_DIGIT_8,
+ GHOSTTY_KEY_DIGIT_9,
+ GHOSTTY_KEY_EQUAL,
+ GHOSTTY_KEY_INTL_BACKSLASH,
+ GHOSTTY_KEY_INTL_RO,
+ GHOSTTY_KEY_INTL_YEN,
+ GHOSTTY_KEY_A,
+ GHOSTTY_KEY_B,
+ GHOSTTY_KEY_C,
+ GHOSTTY_KEY_D,
+ GHOSTTY_KEY_E,
+ GHOSTTY_KEY_F,
+ GHOSTTY_KEY_G,
+ GHOSTTY_KEY_H,
+ GHOSTTY_KEY_I,
+ GHOSTTY_KEY_J,
+ GHOSTTY_KEY_K,
+ GHOSTTY_KEY_L,
+ GHOSTTY_KEY_M,
+ GHOSTTY_KEY_N,
+ GHOSTTY_KEY_O,
+ GHOSTTY_KEY_P,
+ GHOSTTY_KEY_Q,
+ GHOSTTY_KEY_R,
+ GHOSTTY_KEY_S,
+ GHOSTTY_KEY_T,
+ GHOSTTY_KEY_U,
+ GHOSTTY_KEY_V,
+ GHOSTTY_KEY_W,
+ GHOSTTY_KEY_X,
+ GHOSTTY_KEY_Y,
+ GHOSTTY_KEY_Z,
+ GHOSTTY_KEY_MINUS,
+ GHOSTTY_KEY_PERIOD,
+ GHOSTTY_KEY_QUOTE,
+ GHOSTTY_KEY_SEMICOLON,
+ GHOSTTY_KEY_SLASH,
+
+ // Functional Keys (W3C § 3.1.2)
+ GHOSTTY_KEY_ALT_LEFT,
+ GHOSTTY_KEY_ALT_RIGHT,
+ GHOSTTY_KEY_BACKSPACE,
+ GHOSTTY_KEY_CAPS_LOCK,
+ GHOSTTY_KEY_CONTEXT_MENU,
+ GHOSTTY_KEY_CONTROL_LEFT,
+ GHOSTTY_KEY_CONTROL_RIGHT,
+ GHOSTTY_KEY_ENTER,
+ GHOSTTY_KEY_META_LEFT,
+ GHOSTTY_KEY_META_RIGHT,
+ GHOSTTY_KEY_SHIFT_LEFT,
+ GHOSTTY_KEY_SHIFT_RIGHT,
+ GHOSTTY_KEY_SPACE,
+ GHOSTTY_KEY_TAB,
+ GHOSTTY_KEY_CONVERT,
+ GHOSTTY_KEY_KANA_MODE,
+ GHOSTTY_KEY_NON_CONVERT,
+
+ // Control Pad Section (W3C § 3.2)
+ GHOSTTY_KEY_DELETE,
+ GHOSTTY_KEY_END,
+ GHOSTTY_KEY_HELP,
+ GHOSTTY_KEY_HOME,
+ GHOSTTY_KEY_INSERT,
+ GHOSTTY_KEY_PAGE_DOWN,
+ GHOSTTY_KEY_PAGE_UP,
+
+ // Arrow Pad Section (W3C § 3.3)
+ GHOSTTY_KEY_ARROW_DOWN,
+ GHOSTTY_KEY_ARROW_LEFT,
+ GHOSTTY_KEY_ARROW_RIGHT,
+ GHOSTTY_KEY_ARROW_UP,
+
+ // Numpad Section (W3C § 3.4)
+ GHOSTTY_KEY_NUM_LOCK,
+ GHOSTTY_KEY_NUMPAD_0,
+ GHOSTTY_KEY_NUMPAD_1,
+ GHOSTTY_KEY_NUMPAD_2,
+ GHOSTTY_KEY_NUMPAD_3,
+ GHOSTTY_KEY_NUMPAD_4,
+ GHOSTTY_KEY_NUMPAD_5,
+ GHOSTTY_KEY_NUMPAD_6,
+ GHOSTTY_KEY_NUMPAD_7,
+ GHOSTTY_KEY_NUMPAD_8,
+ GHOSTTY_KEY_NUMPAD_9,
+ GHOSTTY_KEY_NUMPAD_ADD,
+ GHOSTTY_KEY_NUMPAD_BACKSPACE,
+ GHOSTTY_KEY_NUMPAD_CLEAR,
+ GHOSTTY_KEY_NUMPAD_CLEAR_ENTRY,
+ GHOSTTY_KEY_NUMPAD_COMMA,
+ GHOSTTY_KEY_NUMPAD_DECIMAL,
+ GHOSTTY_KEY_NUMPAD_DIVIDE,
+ GHOSTTY_KEY_NUMPAD_ENTER,
+ GHOSTTY_KEY_NUMPAD_EQUAL,
+ GHOSTTY_KEY_NUMPAD_MEMORY_ADD,
+ GHOSTTY_KEY_NUMPAD_MEMORY_CLEAR,
+ GHOSTTY_KEY_NUMPAD_MEMORY_RECALL,
+ GHOSTTY_KEY_NUMPAD_MEMORY_STORE,
+ GHOSTTY_KEY_NUMPAD_MEMORY_SUBTRACT,
+ GHOSTTY_KEY_NUMPAD_MULTIPLY,
+ GHOSTTY_KEY_NUMPAD_PAREN_LEFT,
+ GHOSTTY_KEY_NUMPAD_PAREN_RIGHT,
+ GHOSTTY_KEY_NUMPAD_SUBTRACT,
+ GHOSTTY_KEY_NUMPAD_SEPARATOR,
+ GHOSTTY_KEY_NUMPAD_UP,
+ GHOSTTY_KEY_NUMPAD_DOWN,
+ GHOSTTY_KEY_NUMPAD_RIGHT,
+ GHOSTTY_KEY_NUMPAD_LEFT,
+ GHOSTTY_KEY_NUMPAD_BEGIN,
+ GHOSTTY_KEY_NUMPAD_HOME,
+ GHOSTTY_KEY_NUMPAD_END,
+ GHOSTTY_KEY_NUMPAD_INSERT,
+ GHOSTTY_KEY_NUMPAD_DELETE,
+ GHOSTTY_KEY_NUMPAD_PAGE_UP,
+ GHOSTTY_KEY_NUMPAD_PAGE_DOWN,
+
+ // Function Section (W3C § 3.5)
+ GHOSTTY_KEY_ESCAPE,
+ GHOSTTY_KEY_F1,
+ GHOSTTY_KEY_F2,
+ GHOSTTY_KEY_F3,
+ GHOSTTY_KEY_F4,
+ GHOSTTY_KEY_F5,
+ GHOSTTY_KEY_F6,
+ GHOSTTY_KEY_F7,
+ GHOSTTY_KEY_F8,
+ GHOSTTY_KEY_F9,
+ GHOSTTY_KEY_F10,
+ GHOSTTY_KEY_F11,
+ GHOSTTY_KEY_F12,
+ GHOSTTY_KEY_F13,
+ GHOSTTY_KEY_F14,
+ GHOSTTY_KEY_F15,
+ GHOSTTY_KEY_F16,
+ GHOSTTY_KEY_F17,
+ GHOSTTY_KEY_F18,
+ GHOSTTY_KEY_F19,
+ GHOSTTY_KEY_F20,
+ GHOSTTY_KEY_F21,
+ GHOSTTY_KEY_F22,
+ GHOSTTY_KEY_F23,
+ GHOSTTY_KEY_F24,
+ GHOSTTY_KEY_F25,
+ GHOSTTY_KEY_FN,
+ GHOSTTY_KEY_FN_LOCK,
+ GHOSTTY_KEY_PRINT_SCREEN,
+ GHOSTTY_KEY_SCROLL_LOCK,
+ GHOSTTY_KEY_PAUSE,
+
+ // Media Keys (W3C § 3.6)
+ GHOSTTY_KEY_BROWSER_BACK,
+ GHOSTTY_KEY_BROWSER_FAVORITES,
+ GHOSTTY_KEY_BROWSER_FORWARD,
+ GHOSTTY_KEY_BROWSER_HOME,
+ GHOSTTY_KEY_BROWSER_REFRESH,
+ GHOSTTY_KEY_BROWSER_SEARCH,
+ GHOSTTY_KEY_BROWSER_STOP,
+ GHOSTTY_KEY_EJECT,
+ GHOSTTY_KEY_LAUNCH_APP_1,
+ GHOSTTY_KEY_LAUNCH_APP_2,
+ GHOSTTY_KEY_LAUNCH_MAIL,
+ GHOSTTY_KEY_MEDIA_PLAY_PAUSE,
+ GHOSTTY_KEY_MEDIA_SELECT,
+ GHOSTTY_KEY_MEDIA_STOP,
+ GHOSTTY_KEY_MEDIA_TRACK_NEXT,
+ GHOSTTY_KEY_MEDIA_TRACK_PREVIOUS,
+ GHOSTTY_KEY_POWER,
+ GHOSTTY_KEY_SLEEP,
+ GHOSTTY_KEY_AUDIO_VOLUME_DOWN,
+ GHOSTTY_KEY_AUDIO_VOLUME_MUTE,
+ GHOSTTY_KEY_AUDIO_VOLUME_UP,
+ GHOSTTY_KEY_WAKE_UP,
+
+ // Legacy, Non-standard, and Special Keys (W3C § 3.7)
+ GHOSTTY_KEY_COPY,
+ GHOSTTY_KEY_CUT,
+ GHOSTTY_KEY_PASTE,
+} GhosttyKey;
+
+/**
+ * Create a new key event instance.
+ *
+ * Creates a new key event with default values. The event must be freed using
+ * ghostty_key_event_free() when no longer needed.
+ *
+ * @param allocator Pointer to the allocator to use for memory management, or NULL to use the default allocator
+ * @param event Pointer to store the created key event handle
+ * @return GHOSTTY_SUCCESS on success, or an error code on failure
+ *
+ * @ingroup key
+ */
+GhosttyResult ghostty_key_event_new(const GhosttyAllocator *allocator, GhosttyKeyEvent *event);
+
+/**
+ * Free a key event instance.
+ *
+ * Releases all resources associated with the key event. After this call,
+ * the event handle becomes invalid and must not be used.
+ *
+ * @param event The key event handle to free (may be NULL)
+ *
+ * @ingroup key
+ */
+void ghostty_key_event_free(GhosttyKeyEvent event);
+
+/**
+ * Set the key action (press, release, repeat).
+ *
+ * @param event The key event handle, must not be NULL
+ * @param action The action to set
+ *
+ * @ingroup key
+ */
+void ghostty_key_event_set_action(GhosttyKeyEvent event, GhosttyKeyAction action);
+
+/**
+ * Get the key action (press, release, repeat).
+ *
+ * @param event The key event handle, must not be NULL
+ * @return The key action
+ *
+ * @ingroup key
+ */
+GhosttyKeyAction ghostty_key_event_get_action(GhosttyKeyEvent event);
+
+/**
+ * Set the physical key code.
+ *
+ * @param event The key event handle, must not be NULL
+ * @param key The physical key code to set
+ *
+ * @ingroup key
+ */
+void ghostty_key_event_set_key(GhosttyKeyEvent event, GhosttyKey key);
+
+/**
+ * Get the physical key code.
+ *
+ * @param event The key event handle, must not be NULL
+ * @return The physical key code
+ *
+ * @ingroup key
+ */
+GhosttyKey ghostty_key_event_get_key(GhosttyKeyEvent event);
+
+/**
+ * Set the modifier keys bitmask.
+ *
+ * @param event The key event handle, must not be NULL
+ * @param mods The modifier keys bitmask to set
+ *
+ * @ingroup key
+ */
+void ghostty_key_event_set_mods(GhosttyKeyEvent event, GhosttyMods mods);
+
+/**
+ * Get the modifier keys bitmask.
+ *
+ * @param event The key event handle, must not be NULL
+ * @return The modifier keys bitmask
+ *
+ * @ingroup key
+ */
+GhosttyMods ghostty_key_event_get_mods(GhosttyKeyEvent event);
+
+/**
+ * Set the consumed modifiers bitmask.
+ *
+ * @param event The key event handle, must not be NULL
+ * @param consumed_mods The consumed modifiers bitmask to set
+ *
+ * @ingroup key
+ */
+void ghostty_key_event_set_consumed_mods(GhosttyKeyEvent event, GhosttyMods consumed_mods);
+
+/**
+ * Get the consumed modifiers bitmask.
+ *
+ * @param event The key event handle, must not be NULL
+ * @return The consumed modifiers bitmask
+ *
+ * @ingroup key
+ */
+GhosttyMods ghostty_key_event_get_consumed_mods(GhosttyKeyEvent event);
+
+/**
+ * Set whether the key event is part of a composition sequence.
+ *
+ * @param event The key event handle, must not be NULL
+ * @param composing Whether the key event is part of a composition sequence
+ *
+ * @ingroup key
+ */
+void ghostty_key_event_set_composing(GhosttyKeyEvent event, bool composing);
+
+/**
+ * Get whether the key event is part of a composition sequence.
+ *
+ * @param event The key event handle, must not be NULL
+ * @return Whether the key event is part of a composition sequence
+ *
+ * @ingroup key
+ */
+bool ghostty_key_event_get_composing(GhosttyKeyEvent event);
+
+/**
+ * Set the UTF-8 text generated by the key event.
+ *
+ * The key event does NOT take ownership of the text pointer. The caller
+ * must ensure the string remains valid for the lifetime needed by the event.
+ *
+ * @param event The key event handle, must not be NULL
+ * @param utf8 The UTF-8 text to set (or NULL for empty)
+ * @param len Length of the UTF-8 text in bytes
+ *
+ * @ingroup key
+ */
+void ghostty_key_event_set_utf8(GhosttyKeyEvent event, const char *utf8, size_t len);
+
+/**
+ * Get the UTF-8 text generated by the key event.
+ *
+ * The returned pointer is valid until the event is freed or the UTF-8 text is modified.
+ *
+ * @param event The key event handle, must not be NULL
+ * @param len Pointer to store the length of the UTF-8 text in bytes (may be NULL)
+ * @return The UTF-8 text (or NULL for empty)
+ *
+ * @ingroup key
+ */
+const char *ghostty_key_event_get_utf8(GhosttyKeyEvent event, size_t *len);
+
+/**
+ * Set the unshifted Unicode codepoint.
+ *
+ * @param event The key event handle, must not be NULL
+ * @param codepoint The unshifted Unicode codepoint to set
+ *
+ * @ingroup key
+ */
+void ghostty_key_event_set_unshifted_codepoint(GhosttyKeyEvent event, uint32_t codepoint);
+
+/**
+ * Get the unshifted Unicode codepoint.
+ *
+ * @param event The key event handle, must not be NULL
+ * @return The unshifted Unicode codepoint
+ *
+ * @ingroup key
+ */
+uint32_t ghostty_key_event_get_unshifted_codepoint(GhosttyKeyEvent event);
+
+#endif /* GHOSTTY_VT_KEY_EVENT_H */
diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/osc.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/osc.h
new file mode 100644
index 00000000000..f53077ab326
--- /dev/null
+++ b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/osc.h
@@ -0,0 +1,233 @@
+/**
+ * @file osc.h
+ *
+ * OSC (Operating System Command) sequence parser and command handling.
+ */
+
+#ifndef GHOSTTY_VT_OSC_H
+#define GHOSTTY_VT_OSC_H
+
+#include
+#include
+#include
+#include
+#include
+
+/**
+ * Opaque handle to an OSC parser instance.
+ *
+ * This handle represents an OSC (Operating System Command) parser that can
+ * be used to parse the contents of OSC sequences.
+ *
+ * @ingroup osc
+ */
+typedef struct GhosttyOscParser *GhosttyOscParser;
+
+/**
+ * Opaque handle to a single OSC command.
+ *
+ * This handle represents a parsed OSC (Operating System Command) command.
+ * The command can be queried for its type and associated data.
+ *
+ * @ingroup osc
+ */
+typedef struct GhosttyOscCommand *GhosttyOscCommand;
+
+/** @defgroup osc OSC Parser
+ *
+ * OSC (Operating System Command) sequence parser and command handling.
+ *
+ * The parser operates in a streaming fashion, processing input byte-by-byte
+ * to handle OSC sequences that may arrive in fragments across multiple reads.
+ * This interface makes it easy to integrate into most environments and avoids
+ * over-allocating buffers.
+ *
+ * ## Basic Usage
+ *
+ * 1. Create a parser instance with ghostty_osc_new()
+ * 2. Feed bytes to the parser using ghostty_osc_next()
+ * 3. Finalize parsing with ghostty_osc_end() to get the command
+ * 4. Query command type and extract data using ghostty_osc_command_type()
+ * and ghostty_osc_command_data()
+ * 5. Free the parser with ghostty_osc_free() when done
+ *
+ * @{
+ */
+
+/**
+ * OSC command types.
+ *
+ * @ingroup osc
+ */
+typedef enum {
+ GHOSTTY_OSC_COMMAND_INVALID = 0,
+ GHOSTTY_OSC_COMMAND_CHANGE_WINDOW_TITLE = 1,
+ GHOSTTY_OSC_COMMAND_CHANGE_WINDOW_ICON = 2,
+ GHOSTTY_OSC_COMMAND_SEMANTIC_PROMPT = 3,
+ GHOSTTY_OSC_COMMAND_CLIPBOARD_CONTENTS = 4,
+ GHOSTTY_OSC_COMMAND_REPORT_PWD = 5,
+ GHOSTTY_OSC_COMMAND_MOUSE_SHAPE = 6,
+ GHOSTTY_OSC_COMMAND_COLOR_OPERATION = 7,
+ GHOSTTY_OSC_COMMAND_KITTY_COLOR_PROTOCOL = 8,
+ GHOSTTY_OSC_COMMAND_SHOW_DESKTOP_NOTIFICATION = 9,
+ GHOSTTY_OSC_COMMAND_HYPERLINK_START = 10,
+ GHOSTTY_OSC_COMMAND_HYPERLINK_END = 11,
+ GHOSTTY_OSC_COMMAND_CONEMU_SLEEP = 12,
+ GHOSTTY_OSC_COMMAND_CONEMU_SHOW_MESSAGE_BOX = 13,
+ GHOSTTY_OSC_COMMAND_CONEMU_CHANGE_TAB_TITLE = 14,
+ GHOSTTY_OSC_COMMAND_CONEMU_PROGRESS_REPORT = 15,
+ GHOSTTY_OSC_COMMAND_CONEMU_WAIT_INPUT = 16,
+ GHOSTTY_OSC_COMMAND_CONEMU_GUIMACRO = 17,
+ GHOSTTY_OSC_COMMAND_CONEMU_RUN_PROCESS = 18,
+ GHOSTTY_OSC_COMMAND_CONEMU_OUTPUT_ENVIRONMENT_VARIABLE = 19,
+ GHOSTTY_OSC_COMMAND_CONEMU_XTERM_EMULATION = 20,
+ GHOSTTY_OSC_COMMAND_CONEMU_COMMENT = 21,
+ GHOSTTY_OSC_COMMAND_KITTY_TEXT_SIZING = 22,
+} GhosttyOscCommandType;
+
+/**
+ * OSC command data types.
+ *
+ * These values specify what type of data to extract from an OSC command
+ * using `ghostty_osc_command_data`.
+ *
+ * @ingroup osc
+ */
+typedef enum {
+ /** Invalid data type. Never results in any data extraction. */
+ GHOSTTY_OSC_DATA_INVALID = 0,
+
+ /**
+ * Window title string data.
+ *
+ * Valid for: GHOSTTY_OSC_COMMAND_CHANGE_WINDOW_TITLE
+ *
+ * Output type: const char ** (pointer to null-terminated string)
+ *
+ * Lifetime: Valid until the next call to any ghostty_osc_* function with
+ * the same parser instance. Memory is owned by the parser.
+ */
+ GHOSTTY_OSC_DATA_CHANGE_WINDOW_TITLE_STR = 1,
+} GhosttyOscCommandData;
+
+/**
+ * Create a new OSC parser instance.
+ *
+ * Creates a new OSC (Operating System Command) parser using the provided
+ * allocator. The parser must be freed using ghostty_vt_osc_free() when
+ * no longer needed.
+ *
+ * @param allocator Pointer to the allocator to use for memory management, or NULL to use the default allocator
+ * @param parser Pointer to store the created parser handle
+ * @return GHOSTTY_SUCCESS on success, or an error code on failure
+ *
+ * @ingroup osc
+ */
+GhosttyResult ghostty_osc_new(const GhosttyAllocator *allocator, GhosttyOscParser *parser);
+
+/**
+ * Free an OSC parser instance.
+ *
+ * Releases all resources associated with the OSC parser. After this call,
+ * the parser handle becomes invalid and must not be used.
+ *
+ * @param parser The parser handle to free (may be NULL)
+ *
+ * @ingroup osc
+ */
+void ghostty_osc_free(GhosttyOscParser parser);
+
+/**
+ * Reset an OSC parser instance to its initial state.
+ *
+ * Resets the parser state, clearing any partially parsed OSC sequences
+ * and returning the parser to its initial state. This is useful for
+ * reusing a parser instance or recovering from parse errors.
+ *
+ * @param parser The parser handle to reset, must not be null.
+ *
+ * @ingroup osc
+ */
+void ghostty_osc_reset(GhosttyOscParser parser);
+
+/**
+ * Parse the next byte in an OSC sequence.
+ *
+ * Processes a single byte as part of an OSC sequence. The parser maintains
+ * internal state to track the progress through the sequence. Call this
+ * function for each byte in the sequence data.
+ *
+ * When finished pumping the parser with bytes, call ghostty_osc_end
+ * to get the final result.
+ *
+ * @param parser The parser handle, must not be null.
+ * @param byte The next byte to parse
+ *
+ * @ingroup osc
+ */
+void ghostty_osc_next(GhosttyOscParser parser, uint8_t byte);
+
+/**
+ * Finalize OSC parsing and retrieve the parsed command.
+ *
+ * Call this function after feeding all bytes of an OSC sequence to the parser
+ * using ghostty_osc_next() with the exception of the terminating character
+ * (ESC or ST). This function finalizes the parsing process and returns the
+ * parsed OSC command.
+ *
+ * The return value is never NULL. Invalid commands will return a command
+ * with type GHOSTTY_OSC_COMMAND_INVALID.
+ *
+ * The terminator parameter specifies the byte that terminated the OSC sequence
+ * (typically 0x07 for BEL or 0x5C for ST after ESC). This information is
+ * preserved in the parsed command so that responses can use the same terminator
+ * format for better compatibility with the calling program. For commands that
+ * do not require a response, this parameter is ignored and the resulting
+ * command will not retain the terminator information.
+ *
+ * The returned command handle is valid until the next call to any
+ * `ghostty_osc_*` function with the same parser instance with the exception
+ * of command introspection functions such as `ghostty_osc_command_type`.
+ *
+ * @param parser The parser handle, must not be null.
+ * @param terminator The terminating byte of the OSC sequence (0x07 for BEL, 0x5C for ST)
+ * @return Handle to the parsed OSC command
+ *
+ * @ingroup osc
+ */
+GhosttyOscCommand ghostty_osc_end(GhosttyOscParser parser, uint8_t terminator);
+
+/**
+ * Get the type of an OSC command.
+ *
+ * Returns the type identifier for the given OSC command. This can be used
+ * to determine what kind of command was parsed and what data might be
+ * available from it.
+ *
+ * @param command The OSC command handle to query (may be NULL)
+ * @return The command type, or GHOSTTY_OSC_COMMAND_INVALID if command is NULL
+ *
+ * @ingroup osc
+ */
+GhosttyOscCommandType ghostty_osc_command_type(GhosttyOscCommand command);
+
+/**
+ * Extract data from an OSC command.
+ *
+ * Extracts typed data from the given OSC command based on the specified
+ * data type. The output pointer must be of the appropriate type for the
+ * requested data kind. Valid command types, output types, and memory
+ * safety information are documented in the `GhosttyOscCommandData` enum.
+ *
+ * @param command The OSC command handle to query (may be NULL)
+ * @param data The type of data to extract
+ * @param out Pointer to store the extracted data (type depends on data parameter)
+ * @return true if data extraction was successful, false otherwise
+ *
+ * @ingroup osc
+ */
+bool ghostty_osc_command_data(GhosttyOscCommand command, GhosttyOscCommandData data, void *out);
+
+/** @} */
+
+#endif /* GHOSTTY_VT_OSC_H */
diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/paste.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/paste.h
new file mode 100644
index 00000000000..d90f303d43e
--- /dev/null
+++ b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/paste.h
@@ -0,0 +1,75 @@
+/**
+ * @file paste.h
+ *
+ * Paste utilities - validate and encode paste data for terminal input.
+ */
+
+#ifndef GHOSTTY_VT_PASTE_H
+#define GHOSTTY_VT_PASTE_H
+
+/** @defgroup paste Paste Utilities
+ *
+ * Utilities for validating paste data safety.
+ *
+ * ## Basic Usage
+ *
+ * Use ghostty_paste_is_safe() to check if paste data contains potentially
+ * dangerous sequences before sending it to the terminal.
+ *
+ * ## Example
+ *
+ * @code{.c}
+ * #include
+ * #include
+ * #include
+ *
+ * int main() {
+ * const char* safe_data = "hello world";
+ * const char* unsafe_data = "rm -rf /\n";
+ *
+ * if (ghostty_paste_is_safe(safe_data, strlen(safe_data))) {
+ * printf("Safe to paste\n");
+ * }
+ *
+ * if (!ghostty_paste_is_safe(unsafe_data, strlen(unsafe_data))) {
+ * printf("Unsafe! Contains newline\n");
+ * }
+ *
+ * return 0;
+ * }
+ * @endcode
+ *
+ * @{
+ */
+
+#include
+#include
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/**
+ * Check if paste data is safe to paste into the terminal.
+ *
+ * Data is considered unsafe if it contains:
+ * - Newlines (`\n`) which can inject commands
+ * - The bracketed paste end sequence (`\x1b[201~`) which can be used
+ * to exit bracketed paste mode and inject commands
+ *
+ * This check is conservative and considers data unsafe regardless of
+ * current terminal state.
+ *
+ * @param data The paste data to check (must not be NULL)
+ * @param len The length of the data in bytes
+ * @return true if the data is safe to paste, false otherwise
+ */
+bool ghostty_paste_is_safe(const char* data, size_t len);
+
+#ifdef __cplusplus
+}
+#endif
+
+/** @} */
+
+#endif /* GHOSTTY_VT_PASTE_H */
diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/result.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/result.h
new file mode 100644
index 00000000000..65938ee766f
--- /dev/null
+++ b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/result.h
@@ -0,0 +1,22 @@
+/**
+ * @file result.h
+ *
+ * Result codes for libghostty-vt operations.
+ */
+
+#ifndef GHOSTTY_VT_RESULT_H
+#define GHOSTTY_VT_RESULT_H
+
+/**
+ * Result codes for libghostty-vt operations.
+ */
+typedef enum {
+ /** Operation completed successfully */
+ GHOSTTY_SUCCESS = 0,
+ /** Operation failed due to failed allocation */
+ GHOSTTY_OUT_OF_MEMORY = -1,
+ /** Operation failed due to invalid value */
+ GHOSTTY_INVALID_VALUE = -2,
+} GhosttyResult;
+
+#endif /* GHOSTTY_VT_RESULT_H */
diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/sgr.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/sgr.h
new file mode 100644
index 00000000000..0c1afc309bd
--- /dev/null
+++ b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/sgr.h
@@ -0,0 +1,394 @@
+/**
+ * @file sgr.h
+ *
+ * SGR (Select Graphic Rendition) attribute parsing and handling.
+ */
+
+#ifndef GHOSTTY_VT_SGR_H
+#define GHOSTTY_VT_SGR_H
+
+/** @defgroup sgr SGR Parser
+ *
+ * SGR (Select Graphic Rendition) attribute parser.
+ *
+ * SGR sequences are the syntax used to set styling attributes such as
+ * bold, italic, underline, and colors for text in terminal emulators.
+ * For example, you may be familiar with sequences like `ESC[1;31m`. The
+ * `1;31` is the SGR attribute list.
+ *
+ * The parser processes SGR parameters from CSI sequences (e.g., `ESC[1;31m`)
+ * and returns individual text attributes like bold, italic, colors, etc.
+ * It supports both semicolon (`;`) and colon (`:`) separators, possibly mixed,
+ * and handles various color formats including 8-color, 16-color, 256-color,
+ * X11 named colors, and RGB in multiple formats.
+ *
+ * ## Basic Usage
+ *
+ * 1. Create a parser instance with ghostty_sgr_new()
+ * 2. Set SGR parameters with ghostty_sgr_set_params()
+ * 3. Iterate through attributes using ghostty_sgr_next()
+ * 4. Free the parser with ghostty_sgr_free() when done
+ *
+ * ## Example
+ *
+ * @code{.c}
+ * #include
+ * #include
+ * #include
+ *
+ * int main() {
+ * // Create parser
+ * GhosttySgrParser parser;
+ * GhosttyResult result = ghostty_sgr_new(NULL, &parser);
+ * assert(result == GHOSTTY_SUCCESS);
+ *
+ * // Parse "bold, red foreground" sequence: ESC[1;31m
+ * uint16_t params[] = {1, 31};
+ * result = ghostty_sgr_set_params(parser, params, NULL, 2);
+ * assert(result == GHOSTTY_SUCCESS);
+ *
+ * // Iterate through attributes
+ * GhosttySgrAttribute attr;
+ * while (ghostty_sgr_next(parser, &attr)) {
+ * switch (attr.tag) {
+ * case GHOSTTY_SGR_ATTR_BOLD:
+ * printf("Bold enabled\n");
+ * break;
+ * case GHOSTTY_SGR_ATTR_FG_8:
+ * printf("Foreground color: %d\n", attr.value.fg_8);
+ * break;
+ * default:
+ * break;
+ * }
+ * }
+ *
+ * // Cleanup
+ * ghostty_sgr_free(parser);
+ * return 0;
+ * }
+ * @endcode
+ *
+ * @{
+ */
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/**
+ * Opaque handle to an SGR parser instance.
+ *
+ * This handle represents an SGR (Select Graphic Rendition) parser that can
+ * be used to parse SGR sequences and extract individual text attributes.
+ *
+ * @ingroup sgr
+ */
+typedef struct GhosttySgrParser* GhosttySgrParser;
+
+/**
+ * SGR attribute tags.
+ *
+ * These values identify the type of an SGR attribute in a tagged union.
+ * Use the tag to determine which field in the attribute value union to access.
+ *
+ * @ingroup sgr
+ */
+typedef enum {
+ GHOSTTY_SGR_ATTR_UNSET = 0,
+ GHOSTTY_SGR_ATTR_UNKNOWN = 1,
+ GHOSTTY_SGR_ATTR_BOLD = 2,
+ GHOSTTY_SGR_ATTR_RESET_BOLD = 3,
+ GHOSTTY_SGR_ATTR_ITALIC = 4,
+ GHOSTTY_SGR_ATTR_RESET_ITALIC = 5,
+ GHOSTTY_SGR_ATTR_FAINT = 6,
+ GHOSTTY_SGR_ATTR_UNDERLINE = 7,
+ GHOSTTY_SGR_ATTR_RESET_UNDERLINE = 8,
+ GHOSTTY_SGR_ATTR_UNDERLINE_COLOR = 9,
+ GHOSTTY_SGR_ATTR_UNDERLINE_COLOR_256 = 10,
+ GHOSTTY_SGR_ATTR_RESET_UNDERLINE_COLOR = 11,
+ GHOSTTY_SGR_ATTR_OVERLINE = 12,
+ GHOSTTY_SGR_ATTR_RESET_OVERLINE = 13,
+ GHOSTTY_SGR_ATTR_BLINK = 14,
+ GHOSTTY_SGR_ATTR_RESET_BLINK = 15,
+ GHOSTTY_SGR_ATTR_INVERSE = 16,
+ GHOSTTY_SGR_ATTR_RESET_INVERSE = 17,
+ GHOSTTY_SGR_ATTR_INVISIBLE = 18,
+ GHOSTTY_SGR_ATTR_RESET_INVISIBLE = 19,
+ GHOSTTY_SGR_ATTR_STRIKETHROUGH = 20,
+ GHOSTTY_SGR_ATTR_RESET_STRIKETHROUGH = 21,
+ GHOSTTY_SGR_ATTR_DIRECT_COLOR_FG = 22,
+ GHOSTTY_SGR_ATTR_DIRECT_COLOR_BG = 23,
+ GHOSTTY_SGR_ATTR_BG_8 = 24,
+ GHOSTTY_SGR_ATTR_FG_8 = 25,
+ GHOSTTY_SGR_ATTR_RESET_FG = 26,
+ GHOSTTY_SGR_ATTR_RESET_BG = 27,
+ GHOSTTY_SGR_ATTR_BRIGHT_BG_8 = 28,
+ GHOSTTY_SGR_ATTR_BRIGHT_FG_8 = 29,
+ GHOSTTY_SGR_ATTR_BG_256 = 30,
+ GHOSTTY_SGR_ATTR_FG_256 = 31,
+} GhosttySgrAttributeTag;
+
+/**
+ * Underline style types.
+ *
+ * @ingroup sgr
+ */
+typedef enum {
+ GHOSTTY_SGR_UNDERLINE_NONE = 0,
+ GHOSTTY_SGR_UNDERLINE_SINGLE = 1,
+ GHOSTTY_SGR_UNDERLINE_DOUBLE = 2,
+ GHOSTTY_SGR_UNDERLINE_CURLY = 3,
+ GHOSTTY_SGR_UNDERLINE_DOTTED = 4,
+ GHOSTTY_SGR_UNDERLINE_DASHED = 5,
+} GhosttySgrUnderline;
+
+/**
+ * Unknown SGR attribute data.
+ *
+ * Contains the full parameter list and the partial list where parsing
+ * encountered an unknown or invalid sequence.
+ *
+ * @ingroup sgr
+ */
+typedef struct {
+ const uint16_t* full_ptr;
+ size_t full_len;
+ const uint16_t* partial_ptr;
+ size_t partial_len;
+} GhosttySgrUnknown;
+
+/**
+ * SGR attribute value union.
+ *
+ * This union contains all possible attribute values. Use the tag field
+ * to determine which union member is active. Attributes without associated
+ * data (like bold, italic) don't use the union value.
+ *
+ * @ingroup sgr
+ */
+typedef union {
+ GhosttySgrUnknown unknown;
+ GhosttySgrUnderline underline;
+ GhosttyColorRgb underline_color;
+ GhosttyColorPaletteIndex underline_color_256;
+ GhosttyColorRgb direct_color_fg;
+ GhosttyColorRgb direct_color_bg;
+ GhosttyColorPaletteIndex bg_8;
+ GhosttyColorPaletteIndex fg_8;
+ GhosttyColorPaletteIndex bright_bg_8;
+ GhosttyColorPaletteIndex bright_fg_8;
+ GhosttyColorPaletteIndex bg_256;
+ GhosttyColorPaletteIndex fg_256;
+ uint64_t _padding[8];
+} GhosttySgrAttributeValue;
+
+/**
+ * SGR attribute (tagged union).
+ *
+ * A complete SGR attribute with both its type tag and associated value.
+ * Always check the tag field to determine which value union member is valid.
+ *
+ * Attributes without associated data (e.g., GHOSTTY_SGR_ATTR_BOLD) can be
+ * identified by tag alone; the value union is not used for these and
+ * the memory in the value field is undefined.
+ *
+ * @ingroup sgr
+ */
+typedef struct {
+ GhosttySgrAttributeTag tag;
+ GhosttySgrAttributeValue value;
+} GhosttySgrAttribute;
+
+/**
+ * Create a new SGR parser instance.
+ *
+ * Creates a new SGR (Select Graphic Rendition) parser using the provided
+ * allocator. The parser must be freed using ghostty_sgr_free() when
+ * no longer needed.
+ *
+ * @param allocator Pointer to the allocator to use for memory management, or
+ * NULL to use the default allocator
+ * @param parser Pointer to store the created parser handle
+ * @return GHOSTTY_SUCCESS on success, or an error code on failure
+ *
+ * @ingroup sgr
+ */
+GhosttyResult ghostty_sgr_new(const GhosttyAllocator* allocator,
+ GhosttySgrParser* parser);
+
+/**
+ * Free an SGR parser instance.
+ *
+ * Releases all resources associated with the SGR parser. After this call,
+ * the parser handle becomes invalid and must not be used. This includes
+ * any attributes previously returned by ghostty_sgr_next().
+ *
+ * @param parser The parser handle to free (may be NULL)
+ *
+ * @ingroup sgr
+ */
+void ghostty_sgr_free(GhosttySgrParser parser);
+
+/**
+ * Reset an SGR parser instance to the beginning of the parameter list.
+ *
+ * Resets the parser's iteration state without clearing the parameters.
+ * After calling this, ghostty_sgr_next() will start from the beginning
+ * of the parameter list again.
+ *
+ * @param parser The parser handle to reset, must not be NULL
+ *
+ * @ingroup sgr
+ */
+void ghostty_sgr_reset(GhosttySgrParser parser);
+
+/**
+ * Set SGR parameters for parsing.
+ *
+ * Sets the SGR parameter list to parse. Parameters are the numeric values
+ * from a CSI SGR sequence (e.g., for `ESC[1;31m`, params would be {1, 31}).
+ *
+ * The separators array optionally specifies the separator type for each
+ * parameter position. Each byte should be either ';' for semicolon or ':'
+ * for colon. This is needed for certain color formats that use colon
+ * separators (e.g., `ESC[4:3m` for curly underline). Any invalid separator
+ * values are treated as semicolons. The separators array must have the same
+ * length as the params array, if it is not NULL.
+ *
+ * If separators is NULL, all parameters are assumed to be semicolon-separated.
+ *
+ * This function makes an internal copy of the parameter and separator data,
+ * so the caller can safely free or modify the input arrays after this call.
+ *
+ * After calling this function, the parser is automatically reset and ready
+ * to iterate from the beginning.
+ *
+ * @param parser The parser handle, must not be NULL
+ * @param params Array of SGR parameter values
+ * @param separators Optional array of separator characters (';' or ':'), or
+ * NULL
+ * @param len Number of parameters (and separators if provided)
+ * @return GHOSTTY_SUCCESS on success, or an error code on failure
+ *
+ * @ingroup sgr
+ */
+GhosttyResult ghostty_sgr_set_params(GhosttySgrParser parser,
+ const uint16_t* params,
+ const char* separators,
+ size_t len);
+
+/**
+ * Get the next SGR attribute.
+ *
+ * Parses and returns the next attribute from the parameter list.
+ * Call this function repeatedly until it returns false to process
+ * all attributes in the sequence.
+ *
+ * @param parser The parser handle, must not be NULL
+ * @param attr Pointer to store the next attribute
+ * @return true if an attribute was returned, false if no more attributes
+ *
+ * @ingroup sgr
+ */
+bool ghostty_sgr_next(GhosttySgrParser parser, GhosttySgrAttribute* attr);
+
+/**
+ * Get the full parameter list from an unknown SGR attribute.
+ *
+ * This function retrieves the full parameter list that was provided to the
+ * parser when an unknown attribute was encountered. Primarily useful in
+ * WebAssembly environments where accessing struct fields directly is difficult.
+ *
+ * @param unknown The unknown attribute data
+ * @param ptr Pointer to store the pointer to the parameter array (may be NULL)
+ * @return The length of the full parameter array
+ *
+ * @ingroup sgr
+ */
+size_t ghostty_sgr_unknown_full(GhosttySgrUnknown unknown,
+ const uint16_t** ptr);
+
+/**
+ * Get the partial parameter list from an unknown SGR attribute.
+ *
+ * This function retrieves the partial parameter list where parsing stopped
+ * when an unknown attribute was encountered. Primarily useful in WebAssembly
+ * environments where accessing struct fields directly is difficult.
+ *
+ * @param unknown The unknown attribute data
+ * @param ptr Pointer to store the pointer to the parameter array (may be NULL)
+ * @return The length of the partial parameter array
+ *
+ * @ingroup sgr
+ */
+size_t ghostty_sgr_unknown_partial(GhosttySgrUnknown unknown,
+ const uint16_t** ptr);
+
+/**
+ * Get the tag from an SGR attribute.
+ *
+ * This function extracts the tag that identifies which type of attribute
+ * this is. Primarily useful in WebAssembly environments where accessing
+ * struct fields directly is difficult.
+ *
+ * @param attr The SGR attribute
+ * @return The attribute tag
+ *
+ * @ingroup sgr
+ */
+GhosttySgrAttributeTag ghostty_sgr_attribute_tag(GhosttySgrAttribute attr);
+
+/**
+ * Get the value from an SGR attribute.
+ *
+ * This function returns a pointer to the value union from an SGR attribute. Use
+ * the tag to determine which field of the union is valid. Primarily useful in
+ * WebAssembly environments where accessing struct fields directly is difficult.
+ *
+ * @param attr Pointer to the SGR attribute
+ * @return Pointer to the attribute value union
+ *
+ * @ingroup sgr
+ */
+GhosttySgrAttributeValue* ghostty_sgr_attribute_value(
+ GhosttySgrAttribute* attr);
+
+#ifdef __wasm__
+/**
+ * Allocate memory for an SGR attribute (WebAssembly only).
+ *
+ * This is a convenience function for WebAssembly environments to allocate
+ * memory for an SGR attribute structure that can be passed to ghostty_sgr_next.
+ *
+ * @return Pointer to the allocated attribute structure
+ *
+ * @ingroup wasm
+ */
+GhosttySgrAttribute* ghostty_wasm_alloc_sgr_attribute(void);
+
+/**
+ * Free memory for an SGR attribute (WebAssembly only).
+ *
+ * Frees memory allocated by ghostty_wasm_alloc_sgr_attribute.
+ *
+ * @param attr Pointer to the attribute structure to free
+ *
+ * @ingroup wasm
+ */
+void ghostty_wasm_free_sgr_attribute(GhosttySgrAttribute* attr);
+#endif
+
+#ifdef __cplusplus
+}
+#endif
+
+/** @} */
+
+#endif /* GHOSTTY_VT_SGR_H */
diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/wasm.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/wasm.h
new file mode 100644
index 00000000000..37a8263265d
--- /dev/null
+++ b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/ghostty/vt/wasm.h
@@ -0,0 +1,159 @@
+/**
+ * @file wasm.h
+ *
+ * WebAssembly utility functions for libghostty-vt.
+ */
+
+#ifndef GHOSTTY_VT_WASM_H
+#define GHOSTTY_VT_WASM_H
+
+#ifdef __wasm__
+
+#include
+#include
+
+/** @defgroup wasm WebAssembly Utilities
+ *
+ * Convenience functions for allocating various types in WebAssembly builds.
+ * **These are only available the libghostty-vt wasm module.**
+ *
+ * Ghostty relies on pointers to various types for ABI compatibility, and
+ * creating those pointers in Wasm can be tedious. These functions provide
+ * a purely additive set of utilities that simplify memory management in
+ * Wasm environments without changing the core C library API.
+ *
+ * @note These functions always use the default allocator. If you need
+ * custom allocation strategies, you should allocate types manually using
+ * your custom allocator. This is a very rare use case in the WebAssembly
+ * world so these are optimized for simplicity.
+ *
+ * ## Example Usage
+ *
+ * Here's a simple example of using the Wasm utilities with the key encoder:
+ *
+ * @code
+ * const { exports } = wasmInstance;
+ * const view = new DataView(wasmMemory.buffer);
+ *
+ * // Create key encoder
+ * const encoderPtr = exports.ghostty_wasm_alloc_opaque();
+ * exports.ghostty_key_encoder_new(null, encoderPtr);
+ * const encoder = view.getUint32(encoder, true);
+ *
+ * // Configure encoder with Kitty protocol flags
+ * const flagsPtr = exports.ghostty_wasm_alloc_u8();
+ * view.setUint8(flagsPtr, 0x1F);
+ * exports.ghostty_key_encoder_setopt(encoder, 5, flagsPtr);
+ *
+ * // Allocate output buffer and size pointer
+ * const bufferSize = 32;
+ * const bufPtr = exports.ghostty_wasm_alloc_u8_array(bufferSize);
+ * const writtenPtr = exports.ghostty_wasm_alloc_usize();
+ *
+ * // Encode the key event
+ * exports.ghostty_key_encoder_encode(
+ * encoder, eventPtr, bufPtr, bufferSize, writtenPtr
+ * );
+ *
+ * // Read encoded output
+ * const bytesWritten = view.getUint32(writtenPtr, true);
+ * const encoded = new Uint8Array(wasmMemory.buffer, bufPtr, bytesWritten);
+ * @endcode
+ *
+ * @remark The code above is pretty ugly! This is the lowest level interface
+ * to the libghostty-vt Wasm module. In practice, this should be wrapped
+ * in a higher-level API that abstracts away all this.
+ *
+ * @{
+ */
+
+/**
+ * Allocate an opaque pointer. This can be used for any opaque pointer
+ * types such as GhosttyKeyEncoder, GhosttyKeyEvent, etc.
+ *
+ * @return Pointer to allocated opaque pointer, or NULL if allocation failed
+ * @ingroup wasm
+ */
+void** ghostty_wasm_alloc_opaque(void);
+
+/**
+ * Free an opaque pointer allocated by ghostty_wasm_alloc_opaque().
+ *
+ * @param ptr Pointer to free, or NULL (NULL is safely ignored)
+ * @ingroup wasm
+ */
+void ghostty_wasm_free_opaque(void **ptr);
+
+/**
+ * Allocate an array of uint8_t values.
+ *
+ * @param len Number of uint8_t elements to allocate
+ * @return Pointer to allocated array, or NULL if allocation failed
+ * @ingroup wasm
+ */
+uint8_t* ghostty_wasm_alloc_u8_array(size_t len);
+
+/**
+ * Free an array allocated by ghostty_wasm_alloc_u8_array().
+ *
+ * @param ptr Pointer to the array to free, or NULL (NULL is safely ignored)
+ * @param len Length of the array (must match the length passed to alloc)
+ * @ingroup wasm
+ */
+void ghostty_wasm_free_u8_array(uint8_t *ptr, size_t len);
+
+/**
+ * Allocate an array of uint16_t values.
+ *
+ * @param len Number of uint16_t elements to allocate
+ * @return Pointer to allocated array, or NULL if allocation failed
+ * @ingroup wasm
+ */
+uint16_t* ghostty_wasm_alloc_u16_array(size_t len);
+
+/**
+ * Free an array allocated by ghostty_wasm_alloc_u16_array().
+ *
+ * @param ptr Pointer to the array to free, or NULL (NULL is safely ignored)
+ * @param len Length of the array (must match the length passed to alloc)
+ * @ingroup wasm
+ */
+void ghostty_wasm_free_u16_array(uint16_t *ptr, size_t len);
+
+/**
+ * Allocate a single uint8_t value.
+ *
+ * @return Pointer to allocated uint8_t, or NULL if allocation failed
+ * @ingroup wasm
+ */
+uint8_t* ghostty_wasm_alloc_u8(void);
+
+/**
+ * Free a uint8_t allocated by ghostty_wasm_alloc_u8().
+ *
+ * @param ptr Pointer to free, or NULL (NULL is safely ignored)
+ * @ingroup wasm
+ */
+void ghostty_wasm_free_u8(uint8_t *ptr);
+
+/**
+ * Allocate a single size_t value.
+ *
+ * @return Pointer to allocated size_t, or NULL if allocation failed
+ * @ingroup wasm
+ */
+size_t* ghostty_wasm_alloc_usize(void);
+
+/**
+ * Free a size_t allocated by ghostty_wasm_alloc_usize().
+ *
+ * @param ptr Pointer to free, or NULL (NULL is safely ignored)
+ * @ingroup wasm
+ */
+void ghostty_wasm_free_usize(size_t *ptr);
+
+/** @} */
+
+#endif /* __wasm__ */
+
+#endif /* GHOSTTY_VT_WASM_H */
diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/module.modulemap b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/module.modulemap
new file mode 100644
index 00000000000..8961f5c04ba
--- /dev/null
+++ b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/Headers/module.modulemap
@@ -0,0 +1,7 @@
+// This makes Ghostty available to the XCode build for the macOS app.
+// We append "Kit" to it not to be cute, but because targets have to have
+// unique names and we use Ghostty for other things.
+module GhosttyKit {
+ umbrella header "ghostty.h"
+ export *
+}
diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/libghostty-fat.a b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/libghostty-fat.a
new file mode 100644
index 00000000000..d35d909c34f
Binary files /dev/null and b/apps/mobile/modules/t3-terminal/Vendor/libghostty/GhosttyKit.xcframework/ios-arm64/libghostty-fat.a differ
diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty/VERSION b/apps/mobile/modules/t3-terminal/Vendor/libghostty/VERSION
new file mode 100644
index 00000000000..15420168ec9
--- /dev/null
+++ b/apps/mobile/modules/t3-terminal/Vendor/libghostty/VERSION
@@ -0,0 +1 @@
+d36c3b8dffd0d756dd5e5f4933962f774a0e6753
diff --git a/apps/mobile/modules/t3-terminal/android/build.gradle b/apps/mobile/modules/t3-terminal/android/build.gradle
new file mode 100644
index 00000000000..90c0d4fc21e
--- /dev/null
+++ b/apps/mobile/modules/t3-terminal/android/build.gradle
@@ -0,0 +1,19 @@
+apply plugin: 'com.android.library'
+apply plugin: 'org.jetbrains.kotlin.android'
+
+group = 'com.t3tools.terminal'
+version = '0.0.0'
+
+android {
+ namespace 'expo.modules.t3terminal'
+ compileSdk rootProject.ext.compileSdkVersion
+
+ defaultConfig {
+ minSdkVersion rootProject.ext.minSdkVersion
+ targetSdkVersion rootProject.ext.targetSdkVersion
+ }
+}
+
+dependencies {
+ implementation project(':expo-modules-core')
+}
diff --git a/apps/mobile/modules/t3-terminal/android/src/main/AndroidManifest.xml b/apps/mobile/modules/t3-terminal/android/src/main/AndroidManifest.xml
new file mode 100644
index 00000000000..94cbbcfc396
--- /dev/null
+++ b/apps/mobile/modules/t3-terminal/android/src/main/AndroidManifest.xml
@@ -0,0 +1 @@
+
diff --git a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalModule.kt b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalModule.kt
new file mode 100644
index 00000000000..abb3982be1e
--- /dev/null
+++ b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalModule.kt
@@ -0,0 +1,46 @@
+package expo.modules.t3terminal
+
+import expo.modules.kotlin.modules.Module
+import expo.modules.kotlin.modules.ModuleDefinition
+
+class T3TerminalModule : Module() {
+ override fun definition() = ModuleDefinition {
+ Name("T3TerminalSurface")
+
+ View(T3TerminalView::class) {
+ Prop("terminalKey") { view: T3TerminalView, terminalKey: String ->
+ view.terminalKey = terminalKey
+ }
+
+ Prop("initialBuffer") { view: T3TerminalView, initialBuffer: String ->
+ view.initialBuffer = initialBuffer
+ }
+
+ Prop("fontSize") { view: T3TerminalView, fontSize: Double ->
+ view.fontSize = fontSize.toFloat()
+ }
+
+ Prop("appearanceScheme") { view: T3TerminalView, appearanceScheme: String ->
+ view.appearanceScheme = appearanceScheme
+ }
+
+ Prop("themeConfig") { view: T3TerminalView, themeConfig: String ->
+ view.themeConfig = themeConfig
+ }
+
+ Prop("backgroundColor") { view: T3TerminalView, backgroundColor: String ->
+ view.backgroundColorHex = backgroundColor
+ }
+
+ Prop("foregroundColor") { view: T3TerminalView, foregroundColor: String ->
+ view.foregroundColorHex = foregroundColor
+ }
+
+ Prop("mutedForegroundColor") { view: T3TerminalView, mutedForegroundColor: String ->
+ view.mutedForegroundColorHex = mutedForegroundColor
+ }
+
+ Events("onInput", "onResize")
+ }
+ }
+}
diff --git a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt
new file mode 100644
index 00000000000..ec85d0ba070
--- /dev/null
+++ b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt
@@ -0,0 +1,211 @@
+package expo.modules.t3terminal
+
+import android.content.Context
+import android.graphics.Color
+import android.graphics.Typeface
+import android.view.View
+import android.view.ViewGroup
+import android.view.inputmethod.InputMethodManager
+import android.view.inputmethod.EditorInfo
+import android.widget.EditText
+import android.widget.LinearLayout
+import android.widget.ScrollView
+import android.widget.TextView
+import androidx.core.widget.doAfterTextChanged
+import expo.modules.kotlin.AppContext
+import expo.modules.kotlin.views.ExpoView
+import expo.modules.kotlin.viewevent.EventDispatcher
+import kotlin.math.max
+import kotlin.math.min
+
+class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(context, appContext) {
+ private val container = LinearLayout(context)
+ private val scrollView = ScrollView(context)
+ private val textView = TextView(context)
+ private val inputView = EditText(context)
+ private val onInput by EventDispatcher()
+ private val onResize by EventDispatcher()
+ private var lastWidth = 0
+ private var lastHeight = 0
+ private var clearingInput = false
+ private var backgroundColorValue = Color.parseColor("#24292E")
+ private var foregroundColorValue = Color.parseColor("#D1D5DA")
+ private var mutedForegroundColorValue = Color.parseColor("#959DA5")
+
+ var terminalKey: String = ""
+ set(value) {
+ field = value
+ contentDescription = "t3-terminal-$value"
+ }
+
+ var initialBuffer: String = ""
+ set(value) {
+ field = value
+ textView.text = value.ifEmpty { "$ " }
+ scrollView.post {
+ scrollView.fullScroll(View.FOCUS_DOWN)
+ }
+ }
+
+ var fontSize: Float = 10f
+ set(value) {
+ field = value
+ textView.textSize = value
+ inputView.textSize = max(value, 13f)
+ emitResize()
+ }
+
+ var appearanceScheme: String = "dark"
+ set(value) {
+ field = value
+ }
+
+ var themeConfig: String = ""
+
+ var backgroundColorHex: String = "#24292E"
+ set(value) {
+ field = value
+ backgroundColorValue = parseColor(value, backgroundColorValue)
+ applyTheme()
+ }
+
+ var foregroundColorHex: String = "#D1D5DA"
+ set(value) {
+ field = value
+ foregroundColorValue = parseColor(value, foregroundColorValue)
+ applyTheme()
+ }
+
+ var mutedForegroundColorHex: String = "#959DA5"
+ set(value) {
+ field = value
+ mutedForegroundColorValue = parseColor(value, mutedForegroundColorValue)
+ applyTheme()
+ }
+
+ init {
+ applyTheme()
+ container.orientation = LinearLayout.VERTICAL
+ textView.typeface = Typeface.MONOSPACE
+ textView.textSize = fontSize
+ textView.setPadding(8, 8, 8, 8)
+ textView.text = "$ "
+
+ inputView.setSingleLine(true)
+ inputView.setTextColor(Color.TRANSPARENT)
+ inputView.setHintTextColor(Color.TRANSPARENT)
+ inputView.setBackgroundColor(Color.TRANSPARENT)
+ inputView.typeface = Typeface.MONOSPACE
+ inputView.textSize = max(fontSize, 13f)
+ inputView.hint = ""
+ inputView.alpha = 0.02f
+ inputView.imeOptions = EditorInfo.IME_ACTION_SEND
+ inputView.setPadding(0, 0, 0, 0)
+ inputView.setOnFocusChangeListener { _, hasFocus ->
+ if (hasFocus) {
+ showKeyboard()
+ }
+ }
+ inputView.setOnEditorActionListener { view, actionId, _ ->
+ if (actionId != EditorInfo.IME_ACTION_SEND) return@setOnEditorActionListener false
+ onInput(mapOf("data" to "\n"))
+ true
+ }
+ inputView.setOnKeyListener { _, keyCode, event ->
+ if (event.action != android.view.KeyEvent.ACTION_DOWN) return@setOnKeyListener false
+ when (keyCode) {
+ android.view.KeyEvent.KEYCODE_DEL -> {
+ onInput(mapOf("data" to "\u007F"))
+ true
+ }
+ else -> false
+ }
+ }
+ inputView.doAfterTextChanged { editable ->
+ if (clearingInput) return@doAfterTextChanged
+ val text = editable?.toString().orEmpty()
+ if (text.isEmpty()) return@doAfterTextChanged
+ onInput(mapOf("data" to text))
+ clearingInput = true
+ inputView.text?.clear()
+ clearingInput = false
+ }
+
+ textView.setOnClickListener { requestKeyboardFocus() }
+ scrollView.setOnClickListener { requestKeyboardFocus() }
+ container.setOnClickListener { requestKeyboardFocus() }
+ isClickable = true
+ setOnClickListener { requestKeyboardFocus() }
+
+ scrollView.addView(
+ textView,
+ LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT),
+ )
+ container.addView(
+ scrollView,
+ LinearLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ 0,
+ 1f,
+ ),
+ )
+ container.addView(
+ inputView,
+ LinearLayout.LayoutParams(1, 1),
+ )
+ addView(
+ container,
+ LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT),
+ )
+
+ post {
+ requestKeyboardFocus()
+ }
+ }
+
+ override fun onSizeChanged(width: Int, height: Int, oldWidth: Int, oldHeight: Int) {
+ super.onSizeChanged(width, height, oldWidth, oldHeight)
+ if (width == lastWidth && height == lastHeight) return
+ lastWidth = width
+ lastHeight = height
+ emitResize()
+ }
+
+ private fun emitResize() {
+ if (width <= 0 || height <= 0) return
+ val density = resources.displayMetrics.scaledDensity
+ val fontPx = max(fontSize * density, 1f)
+ val cols = max(20, min(400, (width / (fontPx * 0.62f)).toInt()))
+ val terminalHeight = max(height - inputView.height, 0)
+ val rows = max(5, min(200, (terminalHeight / (fontPx * 1.35f)).toInt()))
+ onResize(mapOf("cols" to cols, "rows" to rows))
+ }
+
+ private fun requestKeyboardFocus() {
+ inputView.requestFocus()
+ showKeyboard()
+ }
+
+ private fun applyTheme() {
+ setBackgroundColor(backgroundColorValue)
+ container.setBackgroundColor(backgroundColorValue)
+ scrollView.setBackgroundColor(backgroundColorValue)
+ textView.setTextColor(foregroundColorValue)
+ textView.setBackgroundColor(backgroundColorValue)
+ inputView.setTextColor(Color.TRANSPARENT)
+ inputView.setHintTextColor(mutedForegroundColorValue)
+ inputView.setBackgroundColor(Color.TRANSPARENT)
+ }
+
+ private fun parseColor(value: String, fallback: Int): Int =
+ try {
+ Color.parseColor(value)
+ } catch (_: IllegalArgumentException) {
+ fallback
+ }
+
+ private fun showKeyboard() {
+ val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
+ imm?.showSoftInput(inputView, InputMethodManager.SHOW_IMPLICIT)
+ }
+}
diff --git a/apps/mobile/modules/t3-terminal/expo-module.config.json b/apps/mobile/modules/t3-terminal/expo-module.config.json
new file mode 100644
index 00000000000..a4b0f112411
--- /dev/null
+++ b/apps/mobile/modules/t3-terminal/expo-module.config.json
@@ -0,0 +1,10 @@
+{
+ "platforms": ["apple", "android"],
+ "apple": {
+ "modules": ["T3TerminalModule"],
+ "podspecPath": "T3TerminalNative.podspec"
+ },
+ "android": {
+ "modules": ["expo.modules.t3terminal.T3TerminalModule"]
+ }
+}
diff --git a/apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift b/apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift
new file mode 100644
index 00000000000..8f35bcc3aa2
--- /dev/null
+++ b/apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift
@@ -0,0 +1,43 @@
+import ExpoModulesCore
+
+public class T3TerminalModule: Module {
+ public func definition() -> ModuleDefinition {
+ Name("T3TerminalSurface")
+
+ View(T3TerminalView.self) {
+ Prop("terminalKey") { (view: T3TerminalView, terminalKey: String) in
+ view.terminalKey = terminalKey
+ }
+
+ Prop("initialBuffer") { (view: T3TerminalView, initialBuffer: String) in
+ view.initialBuffer = initialBuffer
+ }
+
+ Prop("fontSize") { (view: T3TerminalView, fontSize: Double) in
+ view.fontSize = CGFloat(fontSize)
+ }
+
+ Prop("appearanceScheme") { (view: T3TerminalView, appearanceScheme: String) in
+ view.appearanceScheme = appearanceScheme
+ }
+
+ Prop("themeConfig") { (view: T3TerminalView, themeConfig: String) in
+ view.themeConfig = themeConfig
+ }
+
+ Prop("backgroundColor") { (view: T3TerminalView, backgroundColor: String) in
+ view.backgroundColorHex = backgroundColor
+ }
+
+ Prop("foregroundColor") { (view: T3TerminalView, foregroundColor: String) in
+ view.foregroundColorHex = foregroundColor
+ }
+
+ Prop("mutedForegroundColor") { (view: T3TerminalView, mutedForegroundColor: String) in
+ view.mutedForegroundColorHex = mutedForegroundColor
+ }
+
+ Events("onInput", "onResize")
+ }
+ }
+}
diff --git a/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift b/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift
new file mode 100644
index 00000000000..685c2642a1f
--- /dev/null
+++ b/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift
@@ -0,0 +1,560 @@
+import ExpoModulesCore
+import Foundation
+import GhosttyKit
+import QuartzCore
+import UIKit
+
+private enum GhosttyRuntime {
+ private static let lock = NSLock()
+ private static var initialized = false
+
+ static func ensureInitialized() -> Bool {
+ lock.lock()
+ defer { lock.unlock() }
+
+ if initialized {
+ return true
+ }
+
+ let result = ghostty_init(0, nil)
+ initialized = result == GHOSTTY_SUCCESS
+ return initialized
+ }
+}
+
+private final class TerminalInputField: UITextField {
+ var onDeleteBackward: (() -> Void)?
+
+ override func deleteBackward() {
+ onDeleteBackward?()
+ super.deleteBackward()
+ }
+}
+
+private enum TerminalAppearanceScheme: String {
+ case light
+ case dark
+
+ init(value: String) {
+ self = TerminalAppearanceScheme(rawValue: value) ?? .dark
+ }
+
+ var ghosttyColorScheme: ghostty_color_scheme_e {
+ switch self {
+ case .light:
+ return GHOSTTY_COLOR_SCHEME_LIGHT
+ case .dark:
+ return GHOSTTY_COLOR_SCHEME_DARK
+ }
+ }
+}
+
+private extension UIColor {
+ convenience init(hexString: String) {
+ let sanitized = hexString.replacingOccurrences(of: "#", with: "")
+ let value = Int(sanitized, radix: 16) ?? 0
+ self.init(
+ red: CGFloat((value >> 16) & 0xFF) / 255,
+ green: CGFloat((value >> 8) & 0xFF) / 255,
+ blue: CGFloat(value & 0xFF) / 255,
+ alpha: 1
+ )
+ }
+}
+
+public final class T3TerminalView: ExpoView, UITextFieldDelegate {
+ private static let minimumVerticalScrollStepPoints: CGFloat = 18
+ private static let verticalScrollStepMultiplier: CGFloat = 1.15
+
+ private let terminalViewport = UIView()
+ private let inputField = TerminalInputField()
+ private let focusTapGesture = UITapGestureRecognizer()
+ private let scrollPanGesture = UIPanGestureRecognizer()
+ private var lastViewportSize: CGSize = .zero
+ private var lastContentScale: CGFloat = 0
+ private var lastReportedGrid: (cols: Int, rows: Int)?
+ private var lastAppliedBuffer = ""
+ private var pendingVerticalScrollPoints: CGFloat = 0
+ private var app: ghostty_app_t?
+ private var surface: ghostty_surface_t?
+ private var isCreatingSurface = false
+ private var surfaceCreationFailed = false
+ private var appearance = TerminalAppearanceScheme.dark
+ private var backgroundColorValue = UIColor(hexString: "#24292e")
+
+ let onInput = EventDispatcher()
+ let onResize = EventDispatcher()
+
+ var terminalKey: String = "" {
+ didSet {
+ accessibilityIdentifier = "t3-terminal-\(terminalKey)"
+ if oldValue != terminalKey {
+ resetSurface()
+ }
+ }
+ }
+
+ var initialBuffer: String = "" {
+ didSet {
+ applyRemoteBuffer(initialBuffer)
+ }
+ }
+
+ var fontSize: CGFloat = 10 {
+ didSet {
+ guard oldValue != fontSize else { return }
+ inputField.font = UIFont.monospacedSystemFont(ofSize: max(fontSize, 13), weight: .regular)
+ refreshSurface()
+ }
+ }
+
+ var appearanceScheme: String = TerminalAppearanceScheme.dark.rawValue {
+ didSet {
+ guard oldValue != appearanceScheme else { return }
+ appearance = TerminalAppearanceScheme(value: appearanceScheme)
+ refreshSurface()
+ }
+ }
+
+ var themeConfig: String = "" {
+ didSet {
+ guard oldValue != themeConfig else { return }
+ refreshSurface()
+ }
+ }
+
+ var backgroundColorHex: String = "#24292e" {
+ didSet {
+ backgroundColorValue = UIColor(hexString: backgroundColorHex)
+ applyTheme()
+ }
+ }
+
+ var foregroundColorHex: String = "#d1d5da"
+ var mutedForegroundColorHex: String = "#959da5"
+
+ public required init(appContext: AppContext? = nil) {
+ super.init(appContext: appContext)
+
+ applyTheme()
+ clipsToBounds = true
+ contentScaleFactor = UIScreen.main.scale
+
+ terminalViewport.clipsToBounds = true
+ terminalViewport.contentScaleFactor = contentScaleFactor
+ terminalViewport.translatesAutoresizingMaskIntoConstraints = false
+ terminalViewport.isUserInteractionEnabled = true
+
+ inputField.delegate = self
+ inputField.backgroundColor = UIColor.clear
+ inputField.textColor = UIColor.clear
+ inputField.tintColor = UIColor.clear
+ inputField.font = UIFont.monospacedSystemFont(ofSize: max(fontSize, 13), weight: .regular)
+ inputField.placeholder = ""
+ inputField.autocorrectionType = .no
+ inputField.autocapitalizationType = .none
+ inputField.spellCheckingType = .no
+ inputField.smartDashesType = .no
+ inputField.smartQuotesType = .no
+ inputField.returnKeyType = .send
+ inputField.keyboardType = .asciiCapable
+ inputField.enablesReturnKeyAutomatically = false
+ inputField.translatesAutoresizingMaskIntoConstraints = false
+ inputField.alpha = 0.02
+ inputField.isAccessibilityElement = false
+ inputField.accessibilityElementsHidden = true
+ inputField.addTarget(self, action: #selector(handleInputEditingDidBegin), for: .editingDidBegin)
+ inputField.onDeleteBackward = { [weak self] in
+ self?.emitInput("\u{7F}")
+ }
+
+ focusTapGesture.addTarget(self, action: #selector(handleViewportTap))
+ terminalViewport.addGestureRecognizer(focusTapGesture)
+ scrollPanGesture.addTarget(self, action: #selector(handleViewportPan(_:)))
+ scrollPanGesture.maximumNumberOfTouches = 1
+ scrollPanGesture.cancelsTouchesInView = false
+ terminalViewport.addGestureRecognizer(scrollPanGesture)
+
+ addSubview(terminalViewport)
+ addSubview(inputField)
+
+ NSLayoutConstraint.activate([
+ terminalViewport.leadingAnchor.constraint(equalTo: leadingAnchor),
+ terminalViewport.trailingAnchor.constraint(equalTo: trailingAnchor),
+ terminalViewport.topAnchor.constraint(equalTo: topAnchor),
+ terminalViewport.bottomAnchor.constraint(equalTo: bottomAnchor),
+
+ inputField.trailingAnchor.constraint(equalTo: trailingAnchor),
+ inputField.topAnchor.constraint(equalTo: bottomAnchor, constant: 8),
+ inputField.widthAnchor.constraint(equalToConstant: 1),
+ inputField.heightAnchor.constraint(equalToConstant: 1),
+ ])
+ }
+
+ deinit {
+ destroySurface()
+ }
+
+ public override func layoutSubviews() {
+ super.layoutSubviews()
+ updateContentScale()
+
+ let viewportSize = terminalViewport.bounds.size
+ if surface == nil {
+ createSurfaceIfPossible()
+ }
+
+ guard viewportSize != lastViewportSize || contentScaleFactor != lastContentScale else {
+ return
+ }
+
+ lastViewportSize = viewportSize
+ lastContentScale = contentScaleFactor
+ resizeSurface()
+ }
+
+ public override func didMoveToWindow() {
+ super.didMoveToWindow()
+
+ guard window != nil else { return }
+ DispatchQueue.main.async { [weak self] in
+ self?.requestKeyboardFocus()
+ }
+ }
+
+ public func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
+ if !string.isEmpty {
+ emitInput(string)
+ return false
+ }
+
+ return false
+ }
+
+ public func textFieldShouldReturn(_ textField: UITextField) -> Bool {
+ emitInput("\n")
+ textField.text = ""
+ return false
+ }
+
+ @objc
+ private func handleViewportTap() {
+ requestKeyboardFocus()
+ }
+
+ @objc
+ private func handleViewportPan(_ gesture: UIPanGestureRecognizer) {
+ guard let surface else { return }
+
+ let location = gesture.location(in: terminalViewport)
+ ghostty_surface_mouse_pos(
+ surface,
+ Double(location.x * contentScaleFactor),
+ Double(location.y * contentScaleFactor),
+ GHOSTTY_MODS_NONE
+ )
+
+ switch gesture.state {
+ case .began:
+ pendingVerticalScrollPoints = 0
+ gesture.setTranslation(.zero, in: terminalViewport)
+ case .changed:
+ let translation = gesture.translation(in: terminalViewport)
+ let stepSize = max(
+ fontSize * Self.verticalScrollStepMultiplier,
+ Self.minimumVerticalScrollStepPoints
+ )
+ let totalVerticalPoints = pendingVerticalScrollPoints + translation.y
+ let verticalSteps = Int(totalVerticalPoints / stepSize)
+ pendingVerticalScrollPoints = totalVerticalPoints - (CGFloat(verticalSteps) * stepSize)
+
+ guard verticalSteps != 0 else {
+ gesture.setTranslation(.zero, in: terminalViewport)
+ return
+ }
+
+ ghostty_surface_mouse_scroll(surface, 0, Double(verticalSteps), 0)
+ redrawSurface()
+ gesture.setTranslation(.zero, in: terminalViewport)
+ default:
+ pendingVerticalScrollPoints = 0
+ gesture.setTranslation(.zero, in: terminalViewport)
+ }
+ }
+
+ @objc
+ private func handleInputEditingDidBegin() {
+ textInputModeDidChange()
+ }
+
+ private func createSurfaceIfPossible() {
+ guard surface == nil, app == nil, !isCreatingSurface, !surfaceCreationFailed else { return }
+ guard terminalViewport.bounds.width > 0, terminalViewport.bounds.height > 0 else { return }
+ guard GhosttyRuntime.ensureInitialized() else {
+ surfaceCreationFailed = true
+ return
+ }
+
+ isCreatingSurface = true
+ defer { isCreatingSurface = false }
+
+ var runtimeConfig = ghostty_runtime_config_s(
+ userdata: Unmanaged.passUnretained(self).toOpaque(),
+ supports_selection_clipboard: false,
+ wakeup_cb: { _ in },
+ action_cb: { _, _, _ in false },
+ read_clipboard_cb: { _, _, _ in false },
+ confirm_read_clipboard_cb: { _, _, _, _ in },
+ write_clipboard_cb: { _, _, _, _, _ in },
+ close_surface_cb: { _, _ in }
+ )
+
+ guard let config = ghostty_config_new() else {
+ surfaceCreationFailed = true
+ return
+ }
+ loadThemeConfig(into: config)
+ ghostty_config_finalize(config)
+ defer { ghostty_config_free(config) }
+
+ guard let createdApp = ghostty_app_new(&runtimeConfig, config) else {
+ surfaceCreationFailed = true
+ return
+ }
+
+ var surfaceConfig = ghostty_surface_config_new()
+ surfaceConfig.platform_tag = GHOSTTY_PLATFORM_IOS
+ surfaceConfig.platform.ios.uiview = Unmanaged.passUnretained(terminalViewport).toOpaque()
+ surfaceConfig.userdata = Unmanaged.passUnretained(self).toOpaque()
+ surfaceConfig.scale_factor = Double(contentScaleFactor)
+ surfaceConfig.font_size = Float(fontSize)
+ surfaceConfig.context = GHOSTTY_SURFACE_CONTEXT_WINDOW
+ surfaceConfig.use_custom_io = true
+
+ guard let createdSurface = ghostty_surface_new(createdApp, &surfaceConfig) else {
+ ghostty_app_free(createdApp)
+ surfaceCreationFailed = true
+ return
+ }
+
+ app = createdApp
+ surface = createdSurface
+ ghostty_app_set_color_scheme(createdApp, appearance.ghosttyColorScheme)
+ ghostty_surface_set_color_scheme(createdSurface, appearance.ghosttyColorScheme)
+ setupWriteCallback()
+ resizeSurface()
+ feedBuffer(initialBuffer)
+ }
+
+ private func resetSurface() {
+ destroySurface()
+ lastAppliedBuffer = ""
+ lastViewportSize = .zero
+ lastContentScale = 0
+ lastReportedGrid = nil
+ surfaceCreationFailed = false
+ setNeedsLayout()
+ }
+
+ private func refreshSurface() {
+ resetSurface()
+ createSurfaceIfPossible()
+ }
+
+ private func destroySurface() {
+ if let surface {
+ ghostty_surface_set_write_callback(surface, nil, nil)
+ ghostty_surface_free(surface)
+ }
+ if let app {
+ ghostty_app_free(app)
+ }
+ surface = nil
+ app = nil
+ }
+
+ private func applyRemoteBuffer(_ buffer: String) {
+ guard surface != nil else {
+ createSurfaceIfPossible()
+ return
+ }
+
+ if buffer.hasPrefix(lastAppliedBuffer) {
+ let suffix = String(buffer.dropFirst(lastAppliedBuffer.count))
+ feedData(Data(suffix.utf8))
+ lastAppliedBuffer = buffer
+ return
+ }
+
+ resetSurface()
+ createSurfaceIfPossible()
+ }
+
+ private func feedBuffer(_ buffer: String) {
+ guard !buffer.isEmpty else { return }
+ feedData(Data(buffer.utf8))
+ lastAppliedBuffer = buffer
+ }
+
+ private func feedData(_ data: Data) {
+ guard let surface, !data.isEmpty else { return }
+
+ data.withUnsafeBytes { buffer in
+ guard let pointer = buffer.baseAddress?.assumingMemoryBound(to: UInt8.self) else {
+ return
+ }
+ ghostty_surface_feed_data(surface, pointer, buffer.count)
+ }
+
+ redrawSurface()
+ }
+
+ private func setupWriteCallback() {
+ guard let surface else { return }
+
+ let userdata = Unmanaged.passUnretained(self).toOpaque()
+ ghostty_surface_set_write_callback(surface, { userdata, data, len in
+ guard let userdata, let data, len > 0 else { return }
+ let view = Unmanaged.fromOpaque(userdata).takeUnretainedValue()
+ let bytes = Data(bytes: data, count: len)
+ guard let input = String(data: bytes, encoding: .utf8), !input.isEmpty else { return }
+
+ DispatchQueue.main.async {
+ view.onInput(["data": input])
+ }
+ }, userdata)
+ }
+
+ private func resizeSurface() {
+ guard let surface else {
+ emitEstimatedResize()
+ return
+ }
+
+ let scale = contentScaleFactor
+ let width = UInt32(max(floor(terminalViewport.bounds.width * scale), 1))
+ let height = UInt32(max(floor(terminalViewport.bounds.height * scale), 1))
+
+ terminalViewport.contentScaleFactor = scale
+ ghostty_surface_set_content_scale(surface, Double(scale), Double(scale))
+ ghostty_surface_set_size(surface, width, height)
+ ghostty_surface_set_occlusion(surface, window != nil)
+ configureIOSurfaceLayers()
+ redrawSurface()
+ emitGhosttyResize()
+ }
+
+ private func redrawSurface() {
+ guard let surface else { return }
+ ghostty_surface_refresh(surface)
+ ghostty_surface_draw(surface)
+ markIOSurfaceLayersForDisplay()
+ emitGhosttyResize()
+ }
+
+ private func emitGhosttyResize() {
+ guard let surface else {
+ emitEstimatedResize()
+ return
+ }
+
+ let size = ghostty_surface_size(surface)
+ let cols = max(1, Int(size.columns))
+ let rows = max(1, Int(size.rows))
+ emitResize(cols: cols, rows: rows)
+ }
+
+ private func emitEstimatedResize() {
+ guard bounds.width > 0, bounds.height > 0 else { return }
+
+ let cellWidth = max(fontSize * 0.62, 1)
+ let cellHeight = max(fontSize * 1.35, 1)
+ let cols = max(20, min(400, Int(bounds.width / cellWidth)))
+ let terminalHeight = max(bounds.height, 0)
+ let rows = max(5, min(200, Int(terminalHeight / cellHeight)))
+ emitResize(cols: cols, rows: rows)
+ }
+
+ private func emitResize(cols: Int, rows: Int) {
+ guard lastReportedGrid?.cols != cols || lastReportedGrid?.rows != rows else {
+ return
+ }
+
+ lastReportedGrid = (cols, rows)
+ onResize([
+ "cols": cols,
+ "rows": rows,
+ ])
+ }
+
+ private func updateContentScale() {
+ let scale = window?.screen.scale ?? UIScreen.main.scale
+ if contentScaleFactor != scale {
+ contentScaleFactor = scale
+ }
+ }
+
+ private func requestKeyboardFocus() {
+ guard window != nil else { return }
+ inputField.becomeFirstResponder()
+ textInputModeDidChange()
+ }
+
+ private func emitInput(_ data: String) {
+ guard !data.isEmpty else { return }
+ onInput(["data": data])
+ }
+
+ private func textInputModeDidChange() {
+ guard let app else { return }
+ ghostty_app_keyboard_changed(app)
+ }
+
+ private func configureIOSurfaceLayers() {
+ let targetBounds = CGRect(origin: .zero, size: terminalViewport.bounds.size)
+ CATransaction.begin()
+ CATransaction.setDisableActions(true)
+ terminalViewport.layer.sublayers?.forEach { sublayer in
+ sublayer.frame = targetBounds
+ sublayer.contentsScale = contentScaleFactor
+ }
+ CATransaction.commit()
+ }
+
+ private func markIOSurfaceLayersForDisplay() {
+ terminalViewport.layer.setNeedsDisplay()
+ terminalViewport.layer.sublayers?.forEach { layer in
+ layer.setNeedsDisplay()
+ }
+ }
+
+ private func applyTheme() {
+ backgroundColor = backgroundColorValue
+ terminalViewport.backgroundColor = backgroundColorValue
+ }
+
+ private func loadThemeConfig(into config: ghostty_config_t) {
+ guard let path = writeThemeConfigFile() else { return }
+ path.withCString { cString in
+ ghostty_config_load_file(config, cString)
+ }
+ }
+
+ private func writeThemeConfigFile() -> String? {
+ guard !themeConfig.isEmpty else { return nil }
+ let configContents = themeConfig
+ let url = URL(fileURLWithPath: NSTemporaryDirectory())
+ .appendingPathComponent("t3-terminal-theme-\(appearance.rawValue).ghostty")
+
+ do {
+ if let existing = try? String(contentsOf: url, encoding: .utf8), existing == configContents {
+ return url.path
+ }
+
+ try configContents.write(to: url, atomically: true, encoding: .utf8)
+ return url.path
+ } catch {
+ return nil
+ }
+ }
+}
diff --git a/apps/mobile/modules/t3-terminal/package.json b/apps/mobile/modules/t3-terminal/package.json
new file mode 100644
index 00000000000..d658be1a219
--- /dev/null
+++ b/apps/mobile/modules/t3-terminal/package.json
@@ -0,0 +1,17 @@
+{
+ "name": "@t3tools/mobile-terminal-native",
+ "version": "0.0.0",
+ "private": true,
+ "expo-module": {
+ "ios": {
+ "modules": [
+ "T3TerminalModule"
+ ]
+ },
+ "android": {
+ "modules": [
+ "expo.modules.t3terminal.T3TerminalModule"
+ ]
+ }
+ }
+}
diff --git a/apps/mobile/modules/t3-terminal/scripts/build-libghostty-ios16.sh b/apps/mobile/modules/t3-terminal/scripts/build-libghostty-ios16.sh
new file mode 100755
index 00000000000..d2f1e19bc40
--- /dev/null
+++ b/apps/mobile/modules/t3-terminal/scripts/build-libghostty-ios16.sh
@@ -0,0 +1,106 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+MODULE_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
+VENDOR_DIR="${MODULE_DIR}/Vendor/libghostty"
+
+GHOSTTY_SOURCE_DIR="${GHOSTTY_SOURCE_DIR:-${HOME}/ghostty}"
+GHOSTTY_ZIG_VERSION="${GHOSTTY_ZIG_VERSION:-0.15.2}"
+GHOSTTY_ZIG="${GHOSTTY_ZIG:-}"
+
+log() {
+ printf '[libghostty-ios16] %s\n' "$*"
+}
+
+die() {
+ printf '[libghostty-ios16] error: %s\n' "$*" >&2
+ exit 1
+}
+
+require_cmd() {
+ command -v "$1" >/dev/null 2>&1 || die "missing required command: $1"
+}
+
+ensure_zig() {
+ if [[ -n "${GHOSTTY_ZIG}" ]]; then
+ [[ -x "${GHOSTTY_ZIG}" ]] || die "GHOSTTY_ZIG is not executable: ${GHOSTTY_ZIG}"
+ return
+ fi
+
+ if command -v zig >/dev/null 2>&1 && [[ "$(zig version)" == "${GHOSTTY_ZIG_VERSION}" ]]; then
+ GHOSTTY_ZIG="$(command -v zig)"
+ return
+ fi
+
+ local cache_dir="${HOME}/.cache/t3code/zig-${GHOSTTY_ZIG_VERSION}"
+ local archive_arch
+ archive_arch="$(uname -m)"
+ case "${archive_arch}" in
+ arm64) archive_arch="aarch64" ;;
+ x86_64) archive_arch="x86_64" ;;
+ *) die "unsupported macOS architecture for Zig download: ${archive_arch}" ;;
+ esac
+
+ GHOSTTY_ZIG="${cache_dir}/zig"
+ if [[ -x "${GHOSTTY_ZIG}" ]]; then
+ return
+ fi
+
+ require_cmd curl
+ require_cmd tar
+ mkdir -p "${cache_dir}"
+ log "downloading Zig ${GHOSTTY_ZIG_VERSION}"
+ curl -fsSL "https://ziglang.org/download/${GHOSTTY_ZIG_VERSION}/zig-${archive_arch}-macos-${GHOSTTY_ZIG_VERSION}.tar.xz" \
+ | tar -xJ --strip-components=1 -C "${cache_dir}"
+}
+
+require_cmd git
+require_cmd xcodebuild
+require_cmd xcrun
+require_cmd rsync
+ensure_zig
+
+ghostty_ref="$(git -C "${GHOSTTY_SOURCE_DIR}" rev-parse HEAD)"
+log "using Ghostty source: ${GHOSTTY_SOURCE_DIR} @ ${ghostty_ref}"
+log "using Zig: ${GHOSTTY_ZIG} ($("${GHOSTTY_ZIG}" version))"
+log "building GhosttyKit.xcframework"
+
+(
+ cd "${GHOSTTY_SOURCE_DIR}"
+ PATH="$(dirname "${GHOSTTY_ZIG}"):${PATH}" "${GHOSTTY_ZIG}" build \
+ -Dapp-runtime=none \
+ -Demit-xcframework=true \
+ -Demit-macos-app=false \
+ -Demit-exe=false \
+ -Demit-docs=false \
+ -Demit-webdata=false \
+ -Demit-helpgen=false \
+ -Demit-terminfo=false \
+ -Demit-termcap=false \
+ -Demit-themes=false \
+ -Doptimize=ReleaseFast \
+ -Dstrip \
+ -Dxcframework-target=universal
+)
+
+xcframework="${GHOSTTY_SOURCE_DIR}/macos/GhosttyKit.xcframework"
+ios_archive="${xcframework}/ios-arm64/libghostty-fat.a"
+sim_archive="${xcframework}/ios-arm64-simulator/libghostty-fat.a"
+[[ -f "${ios_archive}" ]] || die "missing built iOS archive: ${ios_archive}"
+[[ -f "${sim_archive}" ]] || die "missing built iOS simulator archive: ${sim_archive}"
+
+log "stripping iOS archives"
+xcrun strip -S -x "${ios_archive}"
+xcrun strip -S -x "${sim_archive}"
+
+log "copying iOS archives into ${VENDOR_DIR}/GhosttyKit.xcframework"
+cp "${ios_archive}" "${VENDOR_DIR}/GhosttyKit.xcframework/ios-arm64/libghostty-fat.a"
+cp "${sim_archive}" "${VENDOR_DIR}/GhosttyKit.xcframework/ios-arm64-simulator/libghostty-fat.a"
+rsync -a --delete "${xcframework}/ios-arm64/Headers/" \
+ "${VENDOR_DIR}/GhosttyKit.xcframework/ios-arm64/Headers/"
+rsync -a --delete "${xcframework}/ios-arm64-simulator/Headers/" \
+ "${VENDOR_DIR}/GhosttyKit.xcframework/ios-arm64-simulator/Headers/"
+
+log "done"
diff --git a/apps/mobile/package.json b/apps/mobile/package.json
new file mode 100644
index 00000000000..fce4e2b17fb
--- /dev/null
+++ b/apps/mobile/package.json
@@ -0,0 +1,103 @@
+{
+ "name": "@t3tools/mobile",
+ "version": "0.0.0",
+ "private": true,
+ "main": "index.ts",
+ "scripts": {
+ "dev": "expo start --clear",
+ "dev:client": "APP_VARIANT=development expo start --dev-client --clear",
+ "start": "expo start",
+ "start:dev": "APP_VARIANT=development expo start",
+ "start:preview": "APP_VARIANT=preview expo start",
+ "start:prod": "APP_VARIANT=production expo start",
+ "android": "EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform android && expo run:android",
+ "android:dev": "APP_VARIANT=development EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform android && expo run:android",
+ "android:preview": "APP_VARIANT=preview EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform android && expo run:android",
+ "android:prod": "APP_VARIANT=production EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform android && expo run:android",
+ "eas:android:dev": "eas build --profile development -p android",
+ "eas:android:preview": "eas build --profile preview -p android",
+ "eas:android:prod": "eas build --profile production -p android",
+ "ios": "EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform ios && expo run:ios",
+ "ios:dev": "APP_VARIANT=development EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform ios && expo run:ios",
+ "ios:preview": "APP_VARIANT=preview EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform ios && expo run:ios",
+ "ios:prod": "APP_VARIANT=production EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform ios && expo run:ios",
+ "eas:ios:dev": "eas build --profile development -p ios",
+ "eas:ios:preview": "eas build --profile preview -p ios",
+ "eas:ios:prod": "eas build --profile production -p ios",
+ "eas:dev": "eas build --profile development",
+ "eas:preview": "eas build --profile preview",
+ "eas:prod": "eas build --profile production",
+ "config:dev": "APP_VARIANT=development expo config",
+ "config:preview": "APP_VARIANT=preview expo config",
+ "config:prod": "APP_VARIANT=production expo config",
+ "profile:android:hermes": "mkdir -p profiles/review && react-native profile-hermes profiles/review",
+ "test": "vitest run",
+ "typecheck": "tsc --noEmit"
+ },
+ "dependencies": {
+ "@callstack/liquid-glass": "^0.7.1",
+ "@effect/atom-react": "catalog:",
+ "@expo-google-fonts/dm-sans": "^0.4.2",
+ "@legendapp/list": "3.0.0-beta.44",
+ "@pierre/diffs": "catalog:",
+ "@react-native-menu/menu": "^2.0.0",
+ "@shikijs/core": "3.23.0",
+ "@shikijs/engine-javascript": "3.23.0",
+ "@shikijs/langs": "3.23.0",
+ "@shikijs/themes": "3.23.0",
+ "@t3tools/client-runtime": "workspace:*",
+ "@t3tools/contracts": "workspace:*",
+ "@t3tools/mobile-review-diff-native": "file:./modules/t3-review-diff",
+ "@t3tools/mobile-terminal-native": "file:./modules/t3-terminal",
+ "@t3tools/shared": "workspace:*",
+ "clsx": "^2.1.1",
+ "diff": "8.0.3",
+ "effect": "catalog:",
+ "expo": "~55.0.14",
+ "expo-build-properties": "~55.0.13",
+ "expo-camera": "~55.0.15",
+ "expo-clipboard": "~55.0.9",
+ "expo-constants": "^55.0.9",
+ "expo-dev-client": "~55.0.26",
+ "expo-file-system": "~55.0.16",
+ "expo-font": "^55.0.4",
+ "expo-glass-effect": "~55.0.8",
+ "expo-haptics": "^55.0.9",
+ "expo-image-picker": "~55.0.14",
+ "expo-linking": "~55.0.12",
+ "expo-modules-core": "~55.0.22",
+ "expo-paste-input": "^0.1.15",
+ "expo-router": "~55.0.12",
+ "expo-secure-store": "~55.0.9",
+ "expo-splash-screen": "~55.0.13",
+ "expo-symbols": "~55.0.5",
+ "expo-updates": "~55.0.16",
+ "punycode": "^2.3.1",
+ "react": "19.2.5",
+ "react-dom": "19.2.5",
+ "react-native": "0.83.4",
+ "react-native-gesture-handler": "~2.30.0",
+ "react-native-image-viewing": "^0.2.2",
+ "react-native-keyboard-controller": "1.20.7",
+ "react-native-nitro-markdown": "^0.5.0",
+ "react-native-nitro-modules": "^0.35.4",
+ "react-native-reanimated": "4.2.1",
+ "react-native-safe-area-context": "~5.6.2",
+ "react-native-screens": "~4.23.0",
+ "react-native-shiki-engine": "^0.3.9",
+ "react-native-svg": "15.15.3",
+ "react-native-worklets": "0.7.2",
+ "shiki": "3.23.0",
+ "tailwind-merge": "^3.5.0",
+ "uniwind": "^1.6.2"
+ },
+ "devDependencies": {
+ "@types/react": "~19.2.0",
+ "babel-preset-expo": "~55.0.8",
+ "tailwindcss": "^4.0.0",
+ "typescript": "catalog:"
+ },
+ "overrides": {
+ "react-native-nitro-markdown": "file:deps/react-native-nitro-markdown-0.5.0.tgz"
+ }
+}
diff --git a/apps/mobile/plugins/withAndroidCleartextTraffic.cjs b/apps/mobile/plugins/withAndroidCleartextTraffic.cjs
new file mode 100644
index 00000000000..f612f751e91
--- /dev/null
+++ b/apps/mobile/plugins/withAndroidCleartextTraffic.cjs
@@ -0,0 +1,18 @@
+const { withAndroidManifest } = require("expo/config-plugins");
+
+module.exports = function withAndroidCleartextTraffic(config) {
+ return withAndroidManifest(config, (nextConfig) => {
+ const application = nextConfig.modResults.manifest.application?.[0];
+
+ if (application == null) {
+ throw new Error(
+ "AndroidManifest.xml is missing the application element required for cleartext traffic configuration.",
+ );
+ }
+
+ application.$ ??= {};
+ application.$["android:usesCleartextTraffic"] = "true";
+
+ return nextConfig;
+ });
+};
diff --git a/apps/mobile/src/app/+not-found.tsx b/apps/mobile/src/app/+not-found.tsx
new file mode 100644
index 00000000000..124077b0909
--- /dev/null
+++ b/apps/mobile/src/app/+not-found.tsx
@@ -0,0 +1,43 @@
+import { Link } from "expo-router";
+import { Pressable, ScrollView, StyleSheet } from "react-native";
+import { useResolveClassNames } from "uniwind";
+
+import { AppText as Text } from "../components/AppText";
+
+export default function NotFoundRoute() {
+ const screenBgStyle = StyleSheet.flatten(useResolveClassNames("bg-screen"));
+ const primaryBgStyle = StyleSheet.flatten(useResolveClassNames("bg-primary"));
+
+ return (
+
+
+ Route not found
+
+
+
+ Return home
+
+
+
+ );
+}
diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx
new file mode 100644
index 00000000000..15ff31339fa
--- /dev/null
+++ b/apps/mobile/src/app/_layout.tsx
@@ -0,0 +1,104 @@
+import "../../global.css";
+import {
+ DMSans_400Regular,
+ DMSans_500Medium,
+ DMSans_700Bold,
+ useFonts,
+} from "@expo-google-fonts/dm-sans";
+import Stack from "expo-router/stack";
+import { StatusBar, useColorScheme } from "react-native";
+import { GestureHandlerRootView } from "react-native-gesture-handler";
+import { KeyboardProvider } from "react-native-keyboard-controller";
+import { SafeAreaProvider } from "react-native-safe-area-context";
+import { useCSSVariable, useResolveClassNames } from "uniwind";
+
+import { LoadingScreen } from "../components/LoadingScreen";
+
+import {
+ useRemoteEnvironmentBootstrap,
+ useRemoteEnvironmentState,
+} from "../state/use-remote-environment-registry";
+import { RegistryContext } from "@effect/atom-react";
+import { appAtomRegistry } from "../state/atom-registry";
+
+function AppNavigator() {
+ const { isLoadingSavedConnection } = useRemoteEnvironmentState();
+ const colorScheme = useColorScheme();
+ const statusBarBg = useCSSVariable("--color-status-bar");
+ const sheetStyle = useResolveClassNames("bg-sheet");
+
+ const newTaskScreenOptions = {
+ contentStyle: sheetStyle,
+ gestureEnabled: true,
+ headerShown: false,
+ presentation: "formSheet" as const,
+ sheetAllowedDetents: [0.92],
+ sheetGrabberVisible: true,
+ };
+
+ const connectionSheetScreenOptions = {
+ contentStyle: sheetStyle,
+ gestureEnabled: true,
+ headerShown: false,
+ presentation: "formSheet" as const,
+ sheetAllowedDetents: [0.55, 0.7],
+ sheetGrabberVisible: true,
+ };
+
+ if (isLoadingSavedConnection) {
+ return ;
+ }
+
+ return (
+ <>
+
+
+
+
+
+
+
+ >
+ );
+}
+
+export default function RootLayout() {
+ const [fontsLoaded] = useFonts({
+ DMSans_400Regular,
+ DMSans_500Medium,
+ DMSans_700Bold,
+ });
+ useRemoteEnvironmentBootstrap();
+
+ return (
+
+
+
+
+ {fontsLoaded ? : }
+
+
+
+
+ );
+}
diff --git a/apps/mobile/src/app/connections/_layout.tsx b/apps/mobile/src/app/connections/_layout.tsx
new file mode 100644
index 00000000000..38ec4c11ed9
--- /dev/null
+++ b/apps/mobile/src/app/connections/_layout.tsx
@@ -0,0 +1,30 @@
+import Stack from "expo-router/stack";
+import { useResolveClassNames } from "uniwind";
+
+import { useThemeColor } from "../../lib/useThemeColor";
+
+export const unstable_settings = {
+ anchor: "index",
+};
+
+export default function ConnectionsLayout() {
+ const contentStyle = useResolveClassNames("bg-sheet");
+ const connSheetBg = String(useThemeColor("--color-sheet"));
+ const headerTint = String(useThemeColor("--color-icon"));
+
+ return (
+
+
+
+
+ );
+}
diff --git a/apps/mobile/src/app/connections/index.tsx b/apps/mobile/src/app/connections/index.tsx
new file mode 100644
index 00000000000..8e1ec1a9bf3
--- /dev/null
+++ b/apps/mobile/src/app/connections/index.tsx
@@ -0,0 +1,103 @@
+import { Link, Stack } from "expo-router";
+import { SymbolView } from "expo-symbols";
+import type { EnvironmentId } from "@t3tools/contracts";
+import { useCallback, useState } from "react";
+import { Pressable, ScrollView, View } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { useThemeColor } from "../../lib/useThemeColor";
+
+import { AppText as Text } from "../../components/AppText";
+import { cn } from "../../lib/cn";
+import { useRemoteConnections } from "../../state/use-remote-environment-registry";
+import { ConnectionEnvironmentRow } from "../../features/connection/ConnectionEnvironmentRow";
+
+export default function ConnectionsRouteScreen() {
+ const {
+ connectedEnvironments,
+ onReconnectEnvironment,
+ onRemoveEnvironmentPress,
+ onUpdateEnvironment,
+ } = useRemoteConnections();
+ const insets = useSafeAreaInsets();
+ const hasEnvironments = connectedEnvironments.length > 0;
+ const [expandedId, setExpandedId] = useState(null);
+
+ const primaryFg = useThemeColor("--color-primary-foreground");
+ const accentColor = useThemeColor("--color-icon-muted");
+
+ const handleToggle = useCallback((environmentId: EnvironmentId) => {
+ setExpandedId((prev) => (prev === environmentId ? null : environmentId));
+ }, []);
+
+ return (
+
+ (
+
+
+
+
+
+ ),
+ }}
+ />
+
+ {hasEnvironments ? (
+
+ {connectedEnvironments.map((environment, index) => (
+
+ handleToggle(environment.environmentId)}
+ onReconnect={onReconnectEnvironment}
+ onRemove={onRemoveEnvironmentPress}
+ onUpdate={onUpdateEnvironment}
+ />
+
+ ))}
+
+ ) : (
+
+
+
+
+
+ No backends connected yet.{"\n"}Tap{" "}
+ + to add one.
+
+
+ )}
+
+
+ );
+}
diff --git a/apps/mobile/src/app/connections/new.tsx b/apps/mobile/src/app/connections/new.tsx
new file mode 100644
index 00000000000..1a05ea40c51
--- /dev/null
+++ b/apps/mobile/src/app/connections/new.tsx
@@ -0,0 +1,253 @@
+import { CameraView, useCameraPermissions } from "expo-camera";
+import { Stack, useLocalSearchParams, useRouter } from "expo-router";
+import { SymbolView } from "expo-symbols";
+import { useCallback, useEffect, useState } from "react";
+import { Alert, Pressable, ScrollView, View } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { useThemeColor } from "../../lib/useThemeColor";
+
+import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
+import { ErrorBanner } from "../../components/ErrorBanner";
+import { dismissRoute } from "../../lib/routes";
+import { ConnectionSheetButton } from "../../features/connection/ConnectionSheetButton";
+import { extractPairingUrlFromQrPayload } from "../../features/connection/pairing";
+import { useRemoteConnections } from "../../state/use-remote-environment-registry";
+import { buildPairingUrl, parsePairingUrl } from "../../features/connection/pairing";
+
+export default function ConnectionsNewRouteScreen() {
+ const {
+ connectionError,
+ connectionPairingUrl,
+ connectionState,
+ onChangeConnectionPairingUrl,
+ onConnectPress,
+ } = useRemoteConnections();
+ const router = useRouter();
+ const params = useLocalSearchParams<{ mode?: string }>();
+ const insets = useSafeAreaInsets();
+ const [hostInput, setHostInput] = useState("");
+ const [codeInput, setCodeInput] = useState("");
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [showScanner, setShowScanner] = useState(params.mode === "scan_qr");
+ const [cameraPermission, requestCameraPermission] = useCameraPermissions();
+ const [scannerLocked, setScannerLocked] = useState(false);
+
+ const textColor = useThemeColor("--color-icon");
+ const placeholderColor = useThemeColor("--color-placeholder");
+
+ const connectDisabled =
+ isSubmitting || connectionState === "connecting" || hostInput.trim().length === 0;
+
+ useEffect(() => {
+ const { host, code } = parsePairingUrl(connectionPairingUrl);
+ setHostInput(host);
+ setCodeInput(code);
+ }, [connectionPairingUrl]);
+
+ useEffect(() => {
+ if (connectionError) {
+ setIsSubmitting(false);
+ }
+ }, [connectionError]);
+
+ const handleHostChange = useCallback((value: string) => {
+ setHostInput(value);
+ }, []);
+
+ const handleCodeChange = useCallback((value: string) => {
+ setCodeInput(value);
+ }, []);
+
+ const openScanner = useCallback(async () => {
+ if (cameraPermission?.granted) {
+ setScannerLocked(false);
+ setShowScanner(true);
+ return;
+ }
+
+ const permission = await requestCameraPermission();
+ if (permission.granted) {
+ setScannerLocked(false);
+ setShowScanner(true);
+ return;
+ }
+
+ Alert.alert("Camera access needed", "Allow camera access to scan a backend pairing QR code.");
+ }, [cameraPermission?.granted, requestCameraPermission]);
+
+ const closeScanner = useCallback(() => {
+ setShowScanner(false);
+ setScannerLocked(false);
+ }, []);
+
+ const handleQrScan = useCallback(
+ ({ data }: { readonly data: string }) => {
+ if (scannerLocked) {
+ return;
+ }
+
+ setScannerLocked(true);
+
+ try {
+ const pairingUrl = extractPairingUrlFromQrPayload(data);
+ const { host, code } = parsePairingUrl(pairingUrl);
+ setHostInput(host);
+ setCodeInput(code);
+ onChangeConnectionPairingUrl(pairingUrl);
+ setShowScanner(false);
+ } catch (error) {
+ Alert.alert(
+ "Invalid QR code",
+ error instanceof Error ? error.message : "Scanned QR code was not recognized.",
+ );
+ } finally {
+ setTimeout(() => {
+ setScannerLocked(false);
+ }, 600);
+ }
+ },
+ [onChangeConnectionPairingUrl, scannerLocked],
+ );
+
+ const handleSubmit = useCallback(async () => {
+ setIsSubmitting(true);
+
+ try {
+ const pairingUrl = buildPairingUrl(hostInput, codeInput);
+ onChangeConnectionPairingUrl(pairingUrl);
+ await onConnectPress(pairingUrl);
+ dismissRoute(router);
+ } catch {
+ setIsSubmitting(false);
+ }
+ }, [codeInput, hostInput, onChangeConnectionPairingUrl, onConnectPress, router]);
+
+ return (
+
+ (
+ {
+ if (showScanner) {
+ closeScanner();
+ } else {
+ void openScanner();
+ }
+ }}
+ >
+
+
+ ),
+ }}
+ />
+
+
+
+ {showScanner ? (
+ cameraPermission?.granted ? (
+
+
+
+ ) : (
+
+
+ Camera permission is required to scan a QR code.
+
+ {
+ void openScanner();
+ }}
+ />
+
+ )
+ ) : (
+
+
+
+ Host
+
+
+
+
+
+
+ Pairing code
+
+
+
+
+ {connectionError ? : null}
+
+ {
+ void handleSubmit();
+ }}
+ />
+
+ )}
+
+
+
+ );
+}
diff --git a/apps/mobile/src/app/index.tsx b/apps/mobile/src/app/index.tsx
new file mode 100644
index 00000000000..bc70ed2622a
--- /dev/null
+++ b/apps/mobile/src/app/index.tsx
@@ -0,0 +1,110 @@
+import { Stack, useRouter } from "expo-router";
+import { useState } from "react";
+import { Text as RNText, View, useColorScheme } from "react-native";
+import { useThemeColor } from "../lib/useThemeColor";
+
+import { buildThreadRoutePath } from "../lib/routes";
+import { useRemoteCatalog } from "../state/use-remote-catalog";
+import { useRemoteEnvironmentState } from "../state/use-remote-environment-registry";
+import { HomeScreen } from "../features/home/HomeScreen";
+
+/* ─── Route screen ───────────────────────────────────────────────────── */
+
+export default function HomeRouteScreen() {
+ const { projects, threads } = useRemoteCatalog();
+ const { savedConnectionsById } = useRemoteEnvironmentState();
+ const router = useRouter();
+ const [searchQuery, setSearchQuery] = useState("");
+
+ const isDark = useColorScheme() === "dark";
+ const iconColor = String(useThemeColor("--color-icon"));
+
+ return (
+ <>
+ {
+ setSearchQuery(event.nativeEvent.text);
+ },
+ allowToolbarIntegration: true,
+ },
+ }}
+ />
+
+ {/* Header left: plain text, no Liquid Glass button chrome */}
+
+
+
+
+ T3 Code
+
+
+
+ Alpha
+
+
+
+
+
+
+
+ router.push("/connections")}
+ separateBackground
+ />
+
+
+ {/* Bottom toolbar: search + compose, visually split like iMessage */}
+
+
+
+ router.push("/new")}
+ separateBackground
+ />
+
+
+ {
+ router.push(buildThreadRoutePath(thread));
+ }}
+ />
+ >
+ );
+}
diff --git a/apps/mobile/src/app/new/_layout.tsx b/apps/mobile/src/app/new/_layout.tsx
new file mode 100644
index 00000000000..7205e74509e
--- /dev/null
+++ b/apps/mobile/src/app/new/_layout.tsx
@@ -0,0 +1,25 @@
+import Stack from "expo-router/stack";
+import { useResolveClassNames } from "uniwind";
+
+import { NewTaskFlowProvider } from "../../features/threads/new-task-flow-provider";
+
+export const unstable_settings = {
+ anchor: "index",
+};
+
+export default function NewTaskLayout() {
+ const sheetStyle = useResolveClassNames("bg-sheet");
+
+ return (
+
+
+
+
+
+
+ );
+}
diff --git a/apps/mobile/src/app/new/draft.tsx b/apps/mobile/src/app/new/draft.tsx
new file mode 100644
index 00000000000..b2f1f24e76c
--- /dev/null
+++ b/apps/mobile/src/app/new/draft.tsx
@@ -0,0 +1,24 @@
+import { Stack, useLocalSearchParams } from "expo-router";
+
+import { NewTaskDraftScreen } from "../../features/threads/NewTaskDraftScreen";
+
+export default function NewTaskDraftRoute() {
+ const params = useLocalSearchParams<{
+ environmentId?: string | string[];
+ projectId?: string | string[];
+ }>();
+
+ return (
+ <>
+
+
+ >
+ );
+}
diff --git a/apps/mobile/src/app/new/index.tsx b/apps/mobile/src/app/new/index.tsx
new file mode 100644
index 00000000000..cd87abd1cf2
--- /dev/null
+++ b/apps/mobile/src/app/new/index.tsx
@@ -0,0 +1,135 @@
+import { Link, Stack } from "expo-router";
+import { SymbolView } from "expo-symbols";
+import { useMemo } from "react";
+import { Pressable, ScrollView, View } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { useThemeColor } from "../../lib/useThemeColor";
+
+import { AppText as Text } from "../../components/AppText";
+import { ProjectFavicon } from "../../components/ProjectFavicon";
+import { groupProjectsByRepository } from "../../lib/repositoryGroups";
+import { useRemoteCatalog } from "../../state/use-remote-catalog";
+import { useRemoteEnvironmentState } from "../../state/use-remote-environment-registry";
+
+export default function NewTaskRoute() {
+ const { projects, threads } = useRemoteCatalog();
+ const { savedConnectionsById } = useRemoteEnvironmentState();
+ const insets = useSafeAreaInsets();
+ const chevronColor = useThemeColor("--color-chevron");
+ const borderSubtleColor = useThemeColor("--color-border-subtle");
+ const repositoryGroups = useMemo(
+ () => groupProjectsByRepository({ projects, threads }),
+ [projects, threads],
+ );
+ const items = useMemo(
+ () =>
+ repositoryGroups
+ .map((group) => {
+ const project = group.projects[0]?.project;
+ if (!project) {
+ return null;
+ }
+
+ return {
+ environmentId: project.environmentId,
+ id: project.id,
+ key: group.key,
+ title: project.title,
+ workspaceRoot: project.workspaceRoot,
+ };
+ })
+ .filter((entry) => entry !== null),
+ [repositoryGroups],
+ );
+
+ return (
+
+
+
+
+
+
+
+ New task
+
+ Choose project
+
+
+
+ {items.length === 0 ? (
+
+
+ Loading projects...
+
+
+ ) : (
+
+ {items.map((item, index) => {
+ const isFirst = index === 0;
+ const isLast = index === items.length - 1;
+
+ return (
+
+
+
+
+
+ {item.title}
+
+
+
+
+
+ );
+ })}
+
+ )}
+
+
+ );
+}
diff --git a/apps/mobile/src/app/threads/[environmentId]/[threadId]/_layout.tsx b/apps/mobile/src/app/threads/[environmentId]/[threadId]/_layout.tsx
new file mode 100644
index 00000000000..c741560e2f3
--- /dev/null
+++ b/apps/mobile/src/app/threads/[environmentId]/[threadId]/_layout.tsx
@@ -0,0 +1,80 @@
+import Stack from "expo-router/stack";
+import { StyleSheet } from "react-native";
+import { useResolveClassNames } from "uniwind";
+
+export default function ThreadLayout() {
+ const sheetStyle = StyleSheet.flatten(useResolveClassNames("bg-sheet"));
+ const headerBg = {
+ backgroundColor: (sheetStyle as { backgroundColor?: string })?.backgroundColor,
+ };
+
+ return (
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/mobile/src/app/threads/[environmentId]/[threadId]/git-confirm.tsx b/apps/mobile/src/app/threads/[environmentId]/[threadId]/git-confirm.tsx
new file mode 100644
index 00000000000..3773ce55b2e
--- /dev/null
+++ b/apps/mobile/src/app/threads/[environmentId]/[threadId]/git-confirm.tsx
@@ -0,0 +1,5 @@
+import { GitConfirmSheet } from "../../../../features/threads/git/GitConfirmSheet";
+
+export default function GitConfirmRoute() {
+ return ;
+}
diff --git a/apps/mobile/src/app/threads/[environmentId]/[threadId]/git/_layout.tsx b/apps/mobile/src/app/threads/[environmentId]/[threadId]/git/_layout.tsx
new file mode 100644
index 00000000000..15b10c295f3
--- /dev/null
+++ b/apps/mobile/src/app/threads/[environmentId]/[threadId]/git/_layout.tsx
@@ -0,0 +1,58 @@
+import Stack from "expo-router/stack";
+import { StyleSheet } from "react-native";
+import { useResolveClassNames } from "uniwind";
+
+export const unstable_settings = {
+ anchor: "index",
+};
+
+export default function GitSheetLayout() {
+ const sheetStyle = StyleSheet.flatten(useResolveClassNames("bg-sheet"));
+ const headerBg = {
+ backgroundColor: (sheetStyle as { backgroundColor?: string })?.backgroundColor,
+ };
+
+ return (
+
+
+
+
+
+
+ );
+}
diff --git a/apps/mobile/src/app/threads/[environmentId]/[threadId]/git/branches.tsx b/apps/mobile/src/app/threads/[environmentId]/[threadId]/git/branches.tsx
new file mode 100644
index 00000000000..955e4febd7a
--- /dev/null
+++ b/apps/mobile/src/app/threads/[environmentId]/[threadId]/git/branches.tsx
@@ -0,0 +1,5 @@
+import { GitBranchesSheet } from "../../../../../features/threads/git/GitBranchesSheet";
+
+export default function GitBranchesRoute() {
+ return ;
+}
diff --git a/apps/mobile/src/app/threads/[environmentId]/[threadId]/git/commit.tsx b/apps/mobile/src/app/threads/[environmentId]/[threadId]/git/commit.tsx
new file mode 100644
index 00000000000..4510f81a8c9
--- /dev/null
+++ b/apps/mobile/src/app/threads/[environmentId]/[threadId]/git/commit.tsx
@@ -0,0 +1,5 @@
+import { GitCommitSheet } from "../../../../../features/threads/git/GitCommitSheet";
+
+export default function GitCommitRoute() {
+ return ;
+}
diff --git a/apps/mobile/src/app/threads/[environmentId]/[threadId]/git/index.tsx b/apps/mobile/src/app/threads/[environmentId]/[threadId]/git/index.tsx
new file mode 100644
index 00000000000..dd099d80d24
--- /dev/null
+++ b/apps/mobile/src/app/threads/[environmentId]/[threadId]/git/index.tsx
@@ -0,0 +1,5 @@
+import { GitOverviewSheet } from "../../../../../features/threads/git/GitOverviewSheet";
+
+export default function GitRoute() {
+ return ;
+}
diff --git a/apps/mobile/src/app/threads/[environmentId]/[threadId]/git/review.tsx b/apps/mobile/src/app/threads/[environmentId]/[threadId]/git/review.tsx
new file mode 100644
index 00000000000..8ca686c1469
--- /dev/null
+++ b/apps/mobile/src/app/threads/[environmentId]/[threadId]/git/review.tsx
@@ -0,0 +1,18 @@
+import { Redirect, useLocalSearchParams } from "expo-router";
+import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
+
+export default function ReviewRoute() {
+ const { environmentId, threadId } = useLocalSearchParams<{
+ environmentId: EnvironmentId;
+ threadId: ThreadId;
+ }>();
+
+ return (
+
+ );
+}
diff --git a/apps/mobile/src/app/threads/[environmentId]/[threadId]/index.tsx b/apps/mobile/src/app/threads/[environmentId]/[threadId]/index.tsx
new file mode 100644
index 00000000000..4586ee93ca0
--- /dev/null
+++ b/apps/mobile/src/app/threads/[environmentId]/[threadId]/index.tsx
@@ -0,0 +1,5 @@
+import { ThreadRouteScreen } from "../../../../features/threads/ThreadRouteScreen";
+
+export default function ThreadRoute() {
+ return ;
+}
diff --git a/apps/mobile/src/app/threads/[environmentId]/[threadId]/review-comment.tsx b/apps/mobile/src/app/threads/[environmentId]/[threadId]/review-comment.tsx
new file mode 100644
index 00000000000..281ec341079
--- /dev/null
+++ b/apps/mobile/src/app/threads/[environmentId]/[threadId]/review-comment.tsx
@@ -0,0 +1,5 @@
+import { ReviewCommentComposerSheet } from "../../../../features/review/ReviewCommentComposerSheet";
+
+export default function ReviewCommentRoute() {
+ return ;
+}
diff --git a/apps/mobile/src/app/threads/[environmentId]/[threadId]/review.tsx b/apps/mobile/src/app/threads/[environmentId]/[threadId]/review.tsx
new file mode 100644
index 00000000000..84c48c6edc8
--- /dev/null
+++ b/apps/mobile/src/app/threads/[environmentId]/[threadId]/review.tsx
@@ -0,0 +1,5 @@
+import { ReviewSheet } from "../../../../features/review/ReviewSheet";
+
+export default function ReviewRoute() {
+ return ;
+}
diff --git a/apps/mobile/src/app/threads/[environmentId]/[threadId]/terminal.tsx b/apps/mobile/src/app/threads/[environmentId]/[threadId]/terminal.tsx
new file mode 100644
index 00000000000..e209a180a42
--- /dev/null
+++ b/apps/mobile/src/app/threads/[environmentId]/[threadId]/terminal.tsx
@@ -0,0 +1,5 @@
+import { ThreadTerminalRouteScreen } from "../../../../features/terminal/ThreadTerminalRouteScreen";
+
+export default function ThreadTerminalRoute() {
+ return ;
+}
diff --git a/apps/mobile/src/components/AppText.tsx b/apps/mobile/src/components/AppText.tsx
new file mode 100644
index 00000000000..ece05b1df41
--- /dev/null
+++ b/apps/mobile/src/components/AppText.tsx
@@ -0,0 +1,40 @@
+import {
+ Text as RNText,
+ TextInput as RNTextInput,
+ type TextInputProps as RNTextInputProps,
+ type TextProps as RNTextProps,
+} from "react-native";
+import { useThemeColor } from "../lib/useThemeColor";
+
+import { cn } from "../lib/cn";
+
+export type AppTextProps = RNTextProps & { readonly className?: string };
+
+/**
+ * Thin wrapper around RN Text with default font-family and foreground color.
+ * Uses Uniwind className — no manual style parsing.
+ */
+export function AppText({ className, ...props }: AppTextProps) {
+ return ;
+}
+
+export type AppTextInputProps = RNTextInputProps & { readonly className?: string };
+
+/**
+ * Thin wrapper around RN TextInput with default input styling.
+ * Uses Uniwind className — no manual style parsing.
+ */
+export function AppTextInput({ className, placeholderTextColor, ...props }: AppTextInputProps) {
+ const placeholderColor = useThemeColor("--color-placeholder");
+
+ return (
+
+ );
+}
diff --git a/apps/mobile/src/components/BrandMark.tsx b/apps/mobile/src/components/BrandMark.tsx
new file mode 100644
index 00000000000..0373cd8932c
--- /dev/null
+++ b/apps/mobile/src/components/BrandMark.tsx
@@ -0,0 +1,48 @@
+import { Image, View } from "react-native";
+
+import { AppText as Text } from "./AppText";
+
+const BRAND_MARK_SOURCE = require("../../../../assets/dev/blueprint-ios-1024.png");
+
+export function BrandMark(props: { readonly compact?: boolean; readonly stageLabel?: string }) {
+ const compact = props.compact ?? false;
+ const iconSize = compact ? 32 : 44;
+ const stageLabel = props.stageLabel ?? "Alpha";
+
+ return (
+
+
+
+
+
+ T3 Code
+
+
+
+ {stageLabel}
+
+
+
+ {!compact ? (
+
+ Mobile control surface for your live coding environments
+
+ ) : null}
+
+
+ );
+}
diff --git a/apps/mobile/src/components/ComposerAttachmentStrip.tsx b/apps/mobile/src/components/ComposerAttachmentStrip.tsx
new file mode 100644
index 00000000000..5b4d3d626c9
--- /dev/null
+++ b/apps/mobile/src/components/ComposerAttachmentStrip.tsx
@@ -0,0 +1,96 @@
+import { SymbolView } from "expo-symbols";
+import { Image, Pressable, ScrollView, View } from "react-native";
+import { useThemeColor } from "../lib/useThemeColor";
+
+import type { DraftComposerImageAttachment } from "../lib/composerImages";
+
+export interface ComposerAttachmentStripProps {
+ /** Attachment images to display. */
+ readonly attachments: ReadonlyArray;
+ /** Called when the user taps the remove button on an image. */
+ readonly onRemove: (imageId: string) => void;
+ /** Called when the user taps on an image thumbnail to preview it. */
+ readonly onPressImage?: (previewUri: string) => void;
+ /** Image thumbnail size in points. Defaults to 72. */
+ readonly imageSize?: number;
+ /** Border radius of each image thumbnail. Defaults to 16. */
+ readonly imageBorderRadius?: number;
+ /** Whether the remove button should sit in its own gutter instead of overlapping the image. */
+ readonly removeButtonPlacement?: "overlay" | "gutter";
+}
+
+/**
+ * A horizontally-scrollable strip of image attachment thumbnails with remove
+ * buttons. Used by both the thread composer and the new-task draft screen.
+ */
+export function ComposerAttachmentStrip(props: ComposerAttachmentStripProps) {
+ const subtleBg = useThemeColor("--color-subtle");
+ const size = props.imageSize ?? 72;
+ const radius = props.imageBorderRadius ?? 16;
+ const removeButtonPlacement = props.removeButtonPlacement ?? "overlay";
+ const removeButtonGutter = removeButtonPlacement === "gutter" ? 10 : 0;
+
+ if (props.attachments.length === 0) {
+ return null;
+ }
+
+ return (
+
+
+ {props.attachments.map((image) => (
+
+ props.onPressImage!(image.previewUri) : undefined}
+ >
+
+
+ props.onRemove(image.id)}
+ >
+
+
+
+ ))}
+
+
+ );
+}
diff --git a/apps/mobile/src/components/ControlPill.tsx b/apps/mobile/src/components/ControlPill.tsx
new file mode 100644
index 00000000000..fbbba2691e4
--- /dev/null
+++ b/apps/mobile/src/components/ControlPill.tsx
@@ -0,0 +1,67 @@
+import type { ComponentProps, ReactNode } from "react";
+import { Pressable, View } from "react-native";
+import { SymbolView } from "expo-symbols";
+import { useThemeColor } from "../lib/useThemeColor";
+
+import { cn } from "../lib/cn";
+import { AppText as Text } from "./AppText";
+
+export function ControlPill(props: {
+ readonly icon?: ComponentProps["name"];
+ readonly iconNode?: ReactNode;
+ readonly label?: string;
+ readonly onPress?: () => void;
+ readonly variant?: "circle" | "pill" | "primary" | "danger";
+ readonly disabled?: boolean;
+}) {
+ const variant = props.variant ?? "circle";
+
+ const iconColor = useThemeColor("--color-icon");
+ const iconSubtle = useThemeColor("--color-icon-subtle");
+ const primaryFg = useThemeColor("--color-primary-foreground");
+ const dangerFg = useThemeColor("--color-danger-foreground");
+ const iconTintColor =
+ variant === "primary"
+ ? props.disabled
+ ? iconSubtle
+ : primaryFg
+ : variant === "danger"
+ ? dangerFg
+ : iconColor;
+
+ const isCircle =
+ variant === "circle" || variant === "danger" || (variant === "primary" && !props.label);
+ const containerClassName = cn(
+ isCircle
+ ? "h-11 w-11 items-center justify-center rounded-full"
+ : variant === "primary"
+ ? "h-11 flex-row items-center justify-center gap-2 rounded-full px-5"
+ : "h-11 flex-row items-center justify-center gap-2 rounded-full px-3.5",
+ variant === "primary"
+ ? props.disabled
+ ? "bg-subtle-strong"
+ : "bg-primary"
+ : variant === "danger"
+ ? "bg-danger"
+ : "bg-subtle",
+ );
+ const labelClassName = cn(
+ "text-center text-[12px] font-t3-bold",
+ variant === "primary"
+ ? props.disabled
+ ? "text-foreground-muted"
+ : "text-primary-foreground"
+ : "",
+ );
+
+ return (
+
+ {props.iconNode ? (
+ {props.iconNode}
+ ) : props.icon ? (
+
+ ) : null}
+ {props.label ? {props.label} : null}
+
+ );
+}
diff --git a/apps/mobile/src/components/EmptyState.tsx b/apps/mobile/src/components/EmptyState.tsx
new file mode 100644
index 00000000000..30ded6045a1
--- /dev/null
+++ b/apps/mobile/src/components/EmptyState.tsx
@@ -0,0 +1,14 @@
+import { View } from "react-native";
+
+import { AppText as Text } from "./AppText";
+
+export function EmptyState(props: { readonly title: string; readonly detail: string }) {
+ return (
+
+ {props.title}
+
+ {props.detail}
+
+
+ );
+}
diff --git a/apps/mobile/src/components/ErrorBanner.tsx b/apps/mobile/src/components/ErrorBanner.tsx
new file mode 100644
index 00000000000..3fb8ba5d917
--- /dev/null
+++ b/apps/mobile/src/components/ErrorBanner.tsx
@@ -0,0 +1,12 @@
+import { View } from "react-native";
+
+import { AppText as Text } from "./AppText";
+export function ErrorBanner(props: { readonly message: string }) {
+ return (
+
+
+ {props.message}
+
+
+ );
+}
diff --git a/apps/mobile/src/components/GlassSafeAreaView.tsx b/apps/mobile/src/components/GlassSafeAreaView.tsx
new file mode 100644
index 00000000000..f7cc49c368e
--- /dev/null
+++ b/apps/mobile/src/components/GlassSafeAreaView.tsx
@@ -0,0 +1,59 @@
+import type { ReactNode } from "react";
+import { useColorScheme, View, type StyleProp, type ViewStyle } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+
+import { GlassSurface } from "./GlassSurface";
+
+export interface GlassSafeAreaViewProps {
+ readonly leftSlot?: ReactNode;
+ readonly centerSlot?: ReactNode;
+ readonly rightSlot?: ReactNode;
+ readonly style?: StyleProp;
+}
+
+export function GlassSafeAreaView({
+ leftSlot,
+ centerSlot,
+ rightSlot,
+ style,
+}: GlassSafeAreaViewProps) {
+ const isDarkMode = useColorScheme() === "dark";
+ const insets = useSafeAreaInsets();
+ const headerPaddingTop = insets.top + 16;
+ const surfaceStyle = {
+ borderRadius: 0,
+ backgroundColor: isDarkMode ? "rgba(10,10,10,0.97)" : "rgba(255,255,255,0.97)",
+ borderBottomWidth: 1,
+ borderBottomColor: isDarkMode ? "rgba(255,255,255,0.06)" : "rgba(0,0,0,0.06)",
+ } as const;
+
+ return (
+
+
+
+ {leftSlot}
+
+ {centerSlot}
+
+ {rightSlot}
+
+
+
+ );
+}
diff --git a/apps/mobile/src/components/GlassSurface.tsx b/apps/mobile/src/components/GlassSurface.tsx
new file mode 100644
index 00000000000..fba6d8e801e
--- /dev/null
+++ b/apps/mobile/src/components/GlassSurface.tsx
@@ -0,0 +1,73 @@
+import { GlassView, isGlassEffectAPIAvailable } from "expo-glass-effect";
+import type { ReactNode } from "react";
+import { Platform, useColorScheme, View, type ViewProps, type ViewStyle } from "react-native";
+
+export interface GlassSurfaceProps extends ViewProps {
+ readonly children: ReactNode;
+ readonly glassEffectStyle?: "clear" | "regular" | "none";
+ readonly tintColor?: string;
+ readonly chrome?: "default" | "none";
+}
+
+export function GlassSurface({
+ children,
+ glassEffectStyle = "regular",
+ chrome = "default",
+ tintColor,
+ style,
+ ...props
+}: GlassSurfaceProps) {
+ const isDarkMode = useColorScheme() === "dark";
+ const supportsGlass = Platform.OS === "ios" && isGlassEffectAPIAvailable();
+ const surfaceStyle: ViewStyle = {
+ borderRadius: 32,
+ overflow: "hidden",
+ borderWidth: chrome === "none" ? 0 : 1,
+ borderColor:
+ chrome === "none"
+ ? "transparent"
+ : isDarkMode
+ ? "rgba(255,255,255,0.08)"
+ : "rgba(226,232,240,0.9)",
+ backgroundColor:
+ chrome === "none"
+ ? "transparent"
+ : isDarkMode
+ ? "rgba(15,23,42,0.78)"
+ : "rgba(255,255,255,0.72)",
+ shadowColor: chrome === "none" ? "transparent" : "#020617",
+ shadowOpacity: chrome === "none" ? 0 : isDarkMode ? 0.22 : 0.08,
+ shadowRadius: chrome === "none" ? 0 : 28,
+ shadowOffset:
+ chrome === "none"
+ ? {
+ width: 0,
+ height: 0,
+ }
+ : {
+ width: 0,
+ height: 14,
+ },
+ elevation: chrome === "none" ? 0 : 12,
+ };
+
+ if (supportsGlass) {
+ return (
+
+ {children}
+
+ );
+ }
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/apps/mobile/src/components/LoadingScreen.tsx b/apps/mobile/src/components/LoadingScreen.tsx
new file mode 100644
index 00000000000..8b8083832cf
--- /dev/null
+++ b/apps/mobile/src/components/LoadingScreen.tsx
@@ -0,0 +1,27 @@
+import { ActivityIndicator, StatusBar, View, useColorScheme } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { useThemeColor } from "../lib/useThemeColor";
+
+import { AppText as Text } from "./AppText";
+import { BrandMark } from "./BrandMark";
+
+export function LoadingScreen(props: { readonly message: string }) {
+ const colorScheme = useColorScheme();
+ const screenBg = useThemeColor("--color-screen");
+ const insets = useSafeAreaInsets();
+
+ return (
+
+
+
+
+
+ {props.message}
+
+
+ );
+}
diff --git a/apps/mobile/src/components/ProjectFavicon.tsx b/apps/mobile/src/components/ProjectFavicon.tsx
new file mode 100644
index 00000000000..32297d8d9d2
--- /dev/null
+++ b/apps/mobile/src/components/ProjectFavicon.tsx
@@ -0,0 +1,70 @@
+import { SymbolView } from "expo-symbols";
+import { useState } from "react";
+import { Image, View } from "react-native";
+import { useThemeColor } from "../lib/useThemeColor";
+
+/* ─── Favicon cache (matches web pattern) ────────────────────────────── */
+const loadedFaviconUrls = new Set();
+
+/* ─── Component ──────────────────────────────────────────────────────── */
+export function ProjectFavicon(props: {
+ readonly size?: number;
+ readonly projectTitle: string;
+ readonly httpBaseUrl?: string | null;
+ readonly workspaceRoot?: string | null;
+ readonly bearerToken?: string | null;
+}) {
+ const size = props.size ?? 42;
+ const iconMuted = useThemeColor("--color-icon-subtle");
+
+ const faviconUrl =
+ props.httpBaseUrl && props.workspaceRoot
+ ? `${props.httpBaseUrl}/api/project-favicon?cwd=${encodeURIComponent(props.workspaceRoot)}`
+ : null;
+
+ const [status, setStatus] = useState<"loading" | "loaded" | "error">(() =>
+ faviconUrl && loadedFaviconUrls.has(faviconUrl) ? "loaded" : "loading",
+ );
+
+ const showImage = faviconUrl && status === "loaded";
+
+ return (
+
+ {/* Folder icon fallback (matches web's FolderIcon) */}
+ {!showImage ? (
+
+ ) : null}
+
+ {/* Favicon image (hidden until loaded) */}
+ {faviconUrl ? (
+ {
+ if (faviconUrl) loadedFaviconUrls.add(faviconUrl);
+ setStatus("loaded");
+ }}
+ onError={() => setStatus("error")}
+ />
+ ) : null}
+
+ );
+}
diff --git a/apps/mobile/src/components/ProviderIcon.tsx b/apps/mobile/src/components/ProviderIcon.tsx
new file mode 100644
index 00000000000..d62f8d9a4bf
--- /dev/null
+++ b/apps/mobile/src/components/ProviderIcon.tsx
@@ -0,0 +1,32 @@
+import { useColorScheme } from "react-native";
+import { Path, Svg } from "react-native-svg";
+
+type ProviderIconProps = {
+ readonly provider: string | null | undefined;
+ readonly size?: number;
+};
+
+export function ProviderIcon(props: ProviderIconProps) {
+ const isDarkMode = useColorScheme() === "dark";
+ const size = props.size ?? 16;
+
+ if (props.provider === "claudeAgent") {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/apps/mobile/src/components/StatusPill.tsx b/apps/mobile/src/components/StatusPill.tsx
new file mode 100644
index 00000000000..34e6f74b609
--- /dev/null
+++ b/apps/mobile/src/components/StatusPill.tsx
@@ -0,0 +1,37 @@
+import { View } from "react-native";
+
+import { AppText as Text } from "./AppText";
+import { cn } from "../lib/cn";
+
+export interface StatusTone {
+ readonly label: string;
+ readonly pillClassName: string;
+ readonly textClassName: string;
+}
+
+export function StatusPill(
+ props: StatusTone & {
+ readonly size?: "default" | "compact";
+ },
+) {
+ const size = props.size ?? "default";
+ return (
+
+
+ {props.label}
+
+
+ );
+}
diff --git a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx
new file mode 100644
index 00000000000..a73356129f3
--- /dev/null
+++ b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx
@@ -0,0 +1,158 @@
+import { SymbolView } from "expo-symbols";
+import type { EnvironmentId } from "@t3tools/contracts";
+import { useCallback, useState } from "react";
+import { Pressable, View } from "react-native";
+import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated";
+import { useThemeColor } from "../../lib/useThemeColor";
+
+import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
+import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types";
+import { ConnectionStatusDot } from "./ConnectionStatusDot";
+
+export function ConnectionEnvironmentRow(props: {
+ readonly environment: ConnectedEnvironmentSummary;
+ readonly expanded: boolean;
+ readonly onToggle: () => void;
+ readonly onReconnect: (environmentId: EnvironmentId) => void;
+ readonly onRemove: (environmentId: EnvironmentId) => void;
+ readonly onUpdate: (
+ environmentId: EnvironmentId,
+ updates: { readonly label: string; readonly displayUrl: string },
+ ) => void;
+}) {
+ const [label, setLabel] = useState(props.environment.environmentLabel);
+ const [url, setUrl] = useState(props.environment.displayUrl);
+
+ const mutedColor = useThemeColor("--color-icon-subtle");
+ const placeholderColor = useThemeColor("--color-placeholder");
+ const primaryFg = useThemeColor("--color-primary-foreground");
+ const dangerFg = useThemeColor("--color-danger-foreground");
+
+ const handleSave = useCallback(() => {
+ props.onUpdate(props.environment.environmentId, {
+ label: label.trim(),
+ displayUrl: url.trim(),
+ });
+ props.onToggle();
+ }, [label, url, props]);
+
+ return (
+
+
+
+
+
+
+ {props.environment.environmentLabel}
+
+
+ {props.environment.displayUrl}
+
+ {props.environment.connectionError ? (
+
+ {props.environment.connectionError}
+
+ ) : null}
+
+
+
+
+
+ {props.expanded ? (
+
+
+
+ Label
+
+
+
+
+
+
+ URL
+
+
+
+
+
+
+
+
+ Save
+
+
+
+ props.onReconnect(props.environment.environmentId)}
+ >
+
+
+
+ props.onRemove(props.environment.environmentId)}
+ >
+
+
+
+
+ ) : null}
+
+ );
+}
diff --git a/apps/mobile/src/features/connection/ConnectionSheetButton.tsx b/apps/mobile/src/features/connection/ConnectionSheetButton.tsx
new file mode 100644
index 00000000000..1a03061e23f
--- /dev/null
+++ b/apps/mobile/src/features/connection/ConnectionSheetButton.tsx
@@ -0,0 +1,114 @@
+import { SymbolView } from "expo-symbols";
+import { Platform, Pressable } from "react-native";
+import { useThemeColor } from "../../lib/useThemeColor";
+
+import { AppText as Text } from "../../components/AppText";
+import { cn } from "../../lib/cn";
+
+const CARD_SHADOW = Platform.select({
+ ios: {
+ shadowColor: "rgba(23,23,23,0.08)",
+ shadowOffset: { width: 0, height: 4 },
+ shadowOpacity: 1,
+ shadowRadius: 16,
+ },
+ android: { elevation: 3 },
+});
+
+const CARD_SHADOW_DARK = Platform.select({
+ ios: {
+ shadowColor: "#000",
+ shadowOffset: { width: 0, height: 2 },
+ shadowOpacity: 0.18,
+ shadowRadius: 8,
+ },
+ android: { elevation: 4 },
+});
+
+export { CARD_SHADOW, CARD_SHADOW_DARK };
+
+export function ConnectionSheetButton(props: {
+ readonly icon: React.ComponentProps["name"];
+ readonly label: string;
+ readonly disabled?: boolean;
+ readonly tone?: "primary" | "secondary" | "danger";
+ readonly compact?: boolean;
+ readonly onPress: () => void;
+}) {
+ const tone = props.tone ?? "secondary";
+
+ const primaryBg = useThemeColor("--color-primary");
+ const primaryFg = useThemeColor("--color-primary-foreground");
+ const dangerBg = useThemeColor("--color-danger");
+ const dangerBorderColor = useThemeColor("--color-danger-border");
+ const dangerFg = useThemeColor("--color-danger-foreground");
+ const secondaryBg = useThemeColor("--color-secondary");
+ const secondaryFg = useThemeColor("--color-secondary-foreground");
+ const borderColor = useThemeColor("--color-border");
+
+ const colors =
+ tone === "primary"
+ ? {
+ backgroundColor: primaryBg,
+ borderColor: "transparent",
+ textColor: primaryFg,
+ }
+ : tone === "danger"
+ ? {
+ backgroundColor: dangerBg,
+ borderColor: dangerBorderColor,
+ textColor: dangerFg,
+ }
+ : {
+ backgroundColor: secondaryBg,
+ borderColor: borderColor,
+ textColor: secondaryFg,
+ };
+
+ const primaryShadow =
+ tone === "primary"
+ ? Platform.select({
+ ios: {
+ shadowColor: "#000",
+ shadowOffset: { width: 0, height: 3 },
+ shadowOpacity: 0.14,
+ shadowRadius: 6,
+ },
+ android: { elevation: 3 },
+ })
+ : undefined;
+
+ return (
+
+
+
+ {props.label}
+
+
+ );
+}
diff --git a/apps/mobile/src/features/connection/ConnectionStatusDot.tsx b/apps/mobile/src/features/connection/ConnectionStatusDot.tsx
new file mode 100644
index 00000000000..60d86e0118c
--- /dev/null
+++ b/apps/mobile/src/features/connection/ConnectionStatusDot.tsx
@@ -0,0 +1,112 @@
+import { useEffect } from "react";
+import { View } from "react-native";
+import Animated, {
+ cancelAnimation,
+ Easing,
+ useAnimatedStyle,
+ useSharedValue,
+ withRepeat,
+ withTiming,
+} from "react-native-reanimated";
+
+import type { RemoteClientConnectionState } from "../../lib/connection";
+
+function statusDotTone(state: RemoteClientConnectionState): {
+ readonly dotColor: string;
+ readonly haloColor: string;
+} {
+ switch (state) {
+ case "ready":
+ return {
+ dotColor: "#34d399",
+ haloColor: "rgba(52,211,153,0.48)",
+ };
+ case "connecting":
+ case "reconnecting":
+ return {
+ dotColor: "#f59e0b",
+ haloColor: "rgba(245,158,11,0.5)",
+ };
+ case "idle":
+ case "disconnected":
+ return {
+ dotColor: "#ef4444",
+ haloColor: "rgba(239,68,68,0.48)",
+ };
+ }
+}
+
+function usePulseAnimation(pulse: boolean) {
+ const pulseProgress = useSharedValue(0);
+
+ useEffect(() => {
+ if (pulse) {
+ pulseProgress.value = withRepeat(
+ withTiming(1, {
+ duration: 1100,
+ easing: Easing.out(Easing.cubic),
+ }),
+ -1,
+ false,
+ );
+ return;
+ }
+
+ cancelAnimation(pulseProgress);
+ pulseProgress.value = withTiming(0, {
+ duration: 180,
+ easing: Easing.out(Easing.quad),
+ });
+ }, [pulse, pulseProgress]);
+
+ return pulseProgress;
+}
+
+export function ConnectionStatusDot(props: {
+ readonly state: RemoteClientConnectionState;
+ readonly pulse: boolean;
+ readonly size?: number;
+}) {
+ const pulseProgress = usePulseAnimation(props.pulse);
+ const tone = statusDotTone(props.state);
+ const dotSize = props.size ?? 10;
+ const haloSize = dotSize + 4;
+ const containerSize = haloSize + 4;
+
+ const haloStyle = useAnimatedStyle(() => ({
+ opacity: props.pulse ? 0.14 + (1 - pulseProgress.value) * 0.3 : 0,
+ transform: [{ scale: 0.78 + pulseProgress.value * 1.16 }],
+ }));
+
+ return (
+
+
+
+
+ );
+}
diff --git a/apps/mobile/src/features/connection/NewConnectionRouteScreen.tsx b/apps/mobile/src/features/connection/NewConnectionRouteScreen.tsx
new file mode 100644
index 00000000000..85a7e587e20
--- /dev/null
+++ b/apps/mobile/src/features/connection/NewConnectionRouteScreen.tsx
@@ -0,0 +1,253 @@
+import { CameraView, useCameraPermissions } from "expo-camera";
+import { Stack, useLocalSearchParams, useRouter } from "expo-router";
+import { SymbolView } from "expo-symbols";
+import { useCallback, useEffect, useState } from "react";
+import { Alert, Pressable, ScrollView, View } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { useThemeColor } from "../../lib/useThemeColor";
+
+import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
+import { ErrorBanner } from "../../components/ErrorBanner";
+import { dismissRoute } from "../../lib/routes";
+import { ConnectionSheetButton } from "./ConnectionSheetButton";
+import { extractPairingUrlFromQrPayload } from "./pairing";
+import { useRemoteConnections } from "../../state/use-remote-environment-registry";
+import { buildPairingUrl, parsePairingUrl } from "./pairing";
+
+export function NewConnectionRouteScreen() {
+ const {
+ connectionError,
+ connectionPairingUrl,
+ connectionState,
+ onChangeConnectionPairingUrl,
+ onConnectPress,
+ } = useRemoteConnections();
+ const router = useRouter();
+ const params = useLocalSearchParams<{ mode?: string }>();
+ const insets = useSafeAreaInsets();
+ const [hostInput, setHostInput] = useState("");
+ const [codeInput, setCodeInput] = useState("");
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [showScanner, setShowScanner] = useState(params.mode === "scan_qr");
+ const [cameraPermission, requestCameraPermission] = useCameraPermissions();
+ const [scannerLocked, setScannerLocked] = useState(false);
+
+ const textColor = useThemeColor("--color-icon");
+ const placeholderColor = useThemeColor("--color-placeholder");
+
+ const connectDisabled =
+ isSubmitting || connectionState === "connecting" || hostInput.trim().length === 0;
+
+ useEffect(() => {
+ const { host, code } = parsePairingUrl(connectionPairingUrl);
+ setHostInput(host);
+ setCodeInput(code);
+ }, [connectionPairingUrl]);
+
+ useEffect(() => {
+ if (connectionError) {
+ setIsSubmitting(false);
+ }
+ }, [connectionError]);
+
+ const handleHostChange = useCallback((value: string) => {
+ setHostInput(value);
+ }, []);
+
+ const handleCodeChange = useCallback((value: string) => {
+ setCodeInput(value);
+ }, []);
+
+ const openScanner = useCallback(async () => {
+ if (cameraPermission?.granted) {
+ setScannerLocked(false);
+ setShowScanner(true);
+ return;
+ }
+
+ const permission = await requestCameraPermission();
+ if (permission.granted) {
+ setScannerLocked(false);
+ setShowScanner(true);
+ return;
+ }
+
+ Alert.alert("Camera access needed", "Allow camera access to scan a backend pairing QR code.");
+ }, [cameraPermission?.granted, requestCameraPermission]);
+
+ const closeScanner = useCallback(() => {
+ setShowScanner(false);
+ setScannerLocked(false);
+ }, []);
+
+ const handleQrScan = useCallback(
+ ({ data }: { readonly data: string }) => {
+ if (scannerLocked) {
+ return;
+ }
+
+ setScannerLocked(true);
+
+ try {
+ const pairingUrl = extractPairingUrlFromQrPayload(data);
+ const { host, code } = parsePairingUrl(pairingUrl);
+ setHostInput(host);
+ setCodeInput(code);
+ onChangeConnectionPairingUrl(pairingUrl);
+ setShowScanner(false);
+ } catch (error) {
+ Alert.alert(
+ "Invalid QR code",
+ error instanceof Error ? error.message : "Scanned QR code was not recognized.",
+ );
+ } finally {
+ setTimeout(() => {
+ setScannerLocked(false);
+ }, 600);
+ }
+ },
+ [onChangeConnectionPairingUrl, scannerLocked],
+ );
+
+ const handleSubmit = useCallback(async () => {
+ setIsSubmitting(true);
+
+ try {
+ const pairingUrl = buildPairingUrl(hostInput, codeInput);
+ onChangeConnectionPairingUrl(pairingUrl);
+ await onConnectPress(pairingUrl);
+ dismissRoute(router);
+ } catch {
+ setIsSubmitting(false);
+ }
+ }, [codeInput, hostInput, onChangeConnectionPairingUrl, onConnectPress, router]);
+
+ return (
+
+ (
+ {
+ if (showScanner) {
+ closeScanner();
+ } else {
+ void openScanner();
+ }
+ }}
+ >
+
+
+ ),
+ }}
+ />
+
+
+
+ {showScanner ? (
+ cameraPermission?.granted ? (
+
+
+
+ ) : (
+
+
+ Camera permission is required to scan a QR code.
+
+ {
+ void openScanner();
+ }}
+ />
+
+ )
+ ) : (
+
+
+
+ Host
+
+
+
+
+
+
+ Pairing code
+
+
+
+
+ {connectionError ? : null}
+
+ {
+ void handleSubmit();
+ }}
+ />
+
+ )}
+
+
+
+ );
+}
diff --git a/apps/mobile/src/features/connection/connectionTone.ts b/apps/mobile/src/features/connection/connectionTone.ts
new file mode 100644
index 00000000000..5e17b469de2
--- /dev/null
+++ b/apps/mobile/src/features/connection/connectionTone.ts
@@ -0,0 +1,37 @@
+import type { StatusTone } from "../../components/StatusPill";
+import type { RemoteClientConnectionState } from "../../lib/connection";
+
+export function connectionTone(state: RemoteClientConnectionState): StatusTone {
+ switch (state) {
+ case "ready":
+ return {
+ label: "Connected",
+ pillClassName: "bg-emerald-500/12 dark:bg-emerald-500/16",
+ textClassName: "text-emerald-700 dark:text-emerald-300",
+ };
+ case "reconnecting":
+ return {
+ label: "Reconnecting",
+ pillClassName: "bg-amber-500/12 dark:bg-amber-500/16",
+ textClassName: "text-amber-700 dark:text-amber-300",
+ };
+ case "connecting":
+ return {
+ label: "Connecting",
+ pillClassName: "bg-sky-500/12 dark:bg-sky-500/16",
+ textClassName: "text-sky-700 dark:text-sky-300",
+ };
+ case "disconnected":
+ return {
+ label: "Disconnected",
+ pillClassName: "bg-rose-500/12 dark:bg-rose-500/16",
+ textClassName: "text-rose-700 dark:text-rose-300",
+ };
+ case "idle":
+ return {
+ label: "Idle",
+ pillClassName: "bg-neutral-500/10 dark:bg-neutral-500/16",
+ textClassName: "text-neutral-600 dark:text-neutral-300",
+ };
+ }
+}
diff --git a/apps/mobile/src/features/connection/pairing.test.ts b/apps/mobile/src/features/connection/pairing.test.ts
new file mode 100644
index 00000000000..d1b45f03ac7
--- /dev/null
+++ b/apps/mobile/src/features/connection/pairing.test.ts
@@ -0,0 +1,25 @@
+import { describe, expect, it } from "vitest";
+
+import { extractPairingUrlFromQrPayload } from "./pairing";
+
+describe("extractPairingUrlFromQrPayload", () => {
+ it("trims raw pairing urls from qr payloads", () => {
+ expect(
+ extractPairingUrlFromQrPayload(" https://remote.example.com/pair#token=pairing-token "),
+ ).toBe("https://remote.example.com/pair#token=pairing-token");
+ });
+
+ it("unwraps mobile deep links that carry an encoded pairing url", () => {
+ expect(
+ extractPairingUrlFromQrPayload(
+ "t3code://pair?pairingUrl=https%3A%2F%2Fremote.example.com%2Fpair%23token%3Dpairing-token",
+ ),
+ ).toBe("https://remote.example.com/pair#token=pairing-token");
+ });
+
+ it("rejects empty qr payloads", () => {
+ expect(() => extractPairingUrlFromQrPayload(" ")).toThrow(
+ "Scanned QR code did not contain a pairing URL.",
+ );
+ });
+});
diff --git a/apps/mobile/src/features/connection/pairing.ts b/apps/mobile/src/features/connection/pairing.ts
new file mode 100644
index 00000000000..2b4fa273d8c
--- /dev/null
+++ b/apps/mobile/src/features/connection/pairing.ts
@@ -0,0 +1,57 @@
+const MOBILE_PAIRING_URL_PARAM = "pairingUrl";
+
+export function buildPairingUrl(host: string, code: string): string {
+ const h = host.trim();
+ const c = code.trim();
+ if (!h) return "";
+ if (!c) return h;
+
+ try {
+ const url = new URL(h.includes("://") ? h : `https://${h}`);
+ url.hash = new URLSearchParams([["token", c]]).toString();
+ return url.toString();
+ } catch {
+ return `${h}#token=${c}`;
+ }
+}
+
+export function parsePairingUrl(url: string): { host: string; code: string } {
+ const trimmed = url.trim();
+ if (!trimmed) return { host: "", code: "" };
+
+ try {
+ const parsed = new URL(trimmed);
+ const hashParams = new URLSearchParams(parsed.hash.slice(1));
+ const hashToken = hashParams.get("token");
+ const queryToken = parsed.searchParams.get("token");
+ const code = hashToken || queryToken || "";
+
+ parsed.hash = "";
+ parsed.search = "";
+ parsed.pathname = "/";
+ return { host: parsed.toString().replace(/\/$/, ""), code };
+ } catch {
+ return { host: trimmed, code: "" };
+ }
+}
+
+export function extractPairingUrlFromQrPayload(payload: string): string {
+ const trimmed = payload.trim();
+ if (!trimmed) {
+ throw new Error("Scanned QR code did not contain a pairing URL.");
+ }
+
+ try {
+ const url = new URL(trimmed);
+ if (url.protocol === "t3code:") {
+ const pairingUrl = url.searchParams.get(MOBILE_PAIRING_URL_PARAM)?.trim() ?? "";
+ if (pairingUrl.length > 0) {
+ return pairingUrl;
+ }
+ }
+ } catch {
+ // Treat non-URL payloads as raw pairing-url text so the normal input validation can decide.
+ }
+
+ return trimmed;
+}
diff --git a/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.ts b/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.ts
new file mode 100644
index 00000000000..72abcc8c956
--- /dev/null
+++ b/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.ts
@@ -0,0 +1,405 @@
+import { createHighlighterCore, type HighlighterCore } from "@shikijs/core";
+import { createJavaScriptRegexEngine } from "@shikijs/engine-javascript";
+import bashLanguage from "@shikijs/langs/bash";
+import diffLanguage from "@shikijs/langs/diff";
+import javascriptLanguage from "@shikijs/langs/javascript";
+import jsonLanguage from "@shikijs/langs/json";
+import jsxLanguage from "@shikijs/langs/jsx";
+import tsxLanguage from "@shikijs/langs/tsx";
+import typescriptLanguage from "@shikijs/langs/typescript";
+import yamlLanguage from "@shikijs/langs/yaml";
+
+import type { NativeReviewDiffFile, NativeReviewDiffLanguage } from "./nativeReviewDiffTypes";
+import type { NativeReviewDiffRow, NativeReviewDiffToken } from "./nativeReviewDiffSurface";
+
+export type NativeReviewDiffHighlightScheme = "light" | "dark";
+export type NativeReviewDiffHighlightEngine = "native" | "javascript";
+
+export interface NativeReviewDiffHighlighterHandle {
+ readonly engine: NativeReviewDiffHighlightEngine;
+ readonly tokenize: (
+ code: string,
+ options: { readonly lang: NativeReviewDiffLanguage; readonly theme: string },
+ ) => ReadonlyArray>;
+}
+
+interface NativeReviewDiffLineRow extends NativeReviewDiffRow {
+ readonly kind: "line";
+ readonly fileId: string;
+ readonly content: string;
+}
+
+export interface NativeReviewDiffTokenChunk {
+ readonly chunkIndex: number;
+ readonly fileId: string;
+ readonly filePath: string;
+ readonly language: NativeReviewDiffLanguage;
+ readonly lineCount: number;
+ readonly durationMs: number;
+ readonly tokensByRowId: Record>;
+}
+
+export interface StreamNativeReviewDiffTokenInput {
+ readonly rows: ReadonlyArray;
+ readonly files: ReadonlyArray;
+ readonly scheme: NativeReviewDiffHighlightScheme;
+ readonly engine?: NativeReviewDiffHighlightEngine;
+ readonly chunkSize?: number;
+ readonly signal?: AbortSignal;
+ readonly onChunk: (chunk: NativeReviewDiffTokenChunk) => void;
+}
+
+export interface HighlightNativeReviewDiffVisibleRowsInput {
+ readonly rows: ReadonlyArray;
+ readonly files: ReadonlyArray;
+ readonly scheme: NativeReviewDiffHighlightScheme;
+ readonly engine?: NativeReviewDiffHighlightEngine;
+ readonly firstRowIndex: number;
+ readonly lastRowIndex: number;
+ readonly overscanRows?: number;
+ readonly maxRows?: number;
+ readonly alreadyHighlightedRowIds?: ReadonlySet;
+ readonly signal?: AbortSignal;
+}
+
+const NATIVE_REVIEW_DIFF_HIGHLIGHT_CHUNK_SIZE = 500;
+const NATIVE_REVIEW_DIFF_VISIBLE_OVERSCAN_ROWS = 160;
+const NATIVE_REVIEW_DIFF_VISIBLE_MAX_ROWS = 360;
+
+const NATIVE_REVIEW_DIFF_THEME_NAME_BY_SCHEME = {
+ dark: "t3-pierre-dark",
+ light: "t3-pierre-light",
+} as const;
+
+const PIERRE_LIGHT_SHIKI_THEME = {
+ name: NATIVE_REVIEW_DIFF_THEME_NAME_BY_SCHEME.light,
+ type: "light" as const,
+ fg: "#070707",
+ bg: "#ffffff",
+ settings: [
+ { settings: { foreground: "#070707", background: "#ffffff" } },
+ { scope: "comment, punctuation.definition.comment", settings: { foreground: "#84848A" } },
+ {
+ scope: "keyword, storage, storage.type, keyword.operator.expression",
+ settings: { foreground: "#FC2B73" },
+ },
+ {
+ scope: "entity.name.function, support.function, meta.function-call",
+ settings: { foreground: "#7B43F8" },
+ },
+ {
+ scope: "entity.name.type, support.type, support.class",
+ settings: { foreground: "#C635E4" },
+ },
+ {
+ scope: "string, constant.character, punctuation.definition.string",
+ settings: { foreground: "#199F43" },
+ },
+ {
+ scope: "constant.numeric, constant.language, constant.other",
+ settings: { foreground: "#1CA1C7" },
+ },
+ {
+ scope: "variable.parameter, variable.other.readwrite, meta.object-literal.key",
+ settings: { foreground: "#D47628" },
+ },
+ {
+ scope: "entity.name.tag, support.class.component",
+ settings: { foreground: "#199F43" },
+ },
+ {
+ scope: "punctuation, meta.brace, meta.delimiter",
+ settings: { foreground: "#79797F" },
+ },
+ { scope: "invalid", settings: { foreground: "#D52C36" } },
+ ],
+};
+
+const PIERRE_DARK_SHIKI_THEME = {
+ name: NATIVE_REVIEW_DIFF_THEME_NAME_BY_SCHEME.dark,
+ type: "dark" as const,
+ fg: "#adadb1",
+ bg: "#0a0a0a",
+ settings: [
+ { settings: { foreground: "#adadb1", background: "#0a0a0a" } },
+ { scope: "comment, punctuation.definition.comment", settings: { foreground: "#84848A" } },
+ {
+ scope: "keyword, storage, storage.type, keyword.operator.expression",
+ settings: { foreground: "#FF678D" },
+ },
+ {
+ scope: "entity.name.function, support.function, meta.function-call",
+ settings: { foreground: "#9D6AFB" },
+ },
+ {
+ scope: "entity.name.type, support.type, support.class",
+ settings: { foreground: "#D568EA" },
+ },
+ {
+ scope: "string, constant.character, punctuation.definition.string",
+ settings: { foreground: "#5ECC71" },
+ },
+ {
+ scope: "constant.numeric, constant.language, constant.other",
+ settings: { foreground: "#68CDF2" },
+ },
+ {
+ scope: "variable.parameter, variable.other.readwrite, meta.object-literal.key",
+ settings: { foreground: "#FFA359" },
+ },
+ {
+ scope: "entity.name.tag, support.class.component",
+ settings: { foreground: "#5ECC71" },
+ },
+ {
+ scope: "punctuation, meta.brace, meta.delimiter",
+ settings: { foreground: "#79797F" },
+ },
+ { scope: "invalid", settings: { foreground: "#FF6762" } },
+ ],
+};
+
+const NATIVE_REVIEW_DIFF_SHIKI_THEMES = [
+ PIERRE_LIGHT_SHIKI_THEME,
+ PIERRE_DARK_SHIKI_THEME,
+] satisfies Parameters[0]["themes"];
+
+const NATIVE_REVIEW_DIFF_LANGUAGES = [
+ bashLanguage,
+ diffLanguage,
+ javascriptLanguage,
+ jsonLanguage,
+ jsxLanguage,
+ tsxLanguage,
+ typescriptLanguage,
+ yamlLanguage,
+] satisfies Parameters[0]["langs"];
+
+let nativeHighlighterPromise: Promise | null = null;
+let javascriptHighlighterPromise: Promise | null = null;
+
+function waitForNextFrame(): Promise {
+ return new Promise((resolve) => setTimeout(resolve, 0));
+}
+
+function normalizeTokens(
+ tokenLines: ReadonlyArray>,
+): ReadonlyArray> {
+ return tokenLines.map((line) =>
+ line.map((token) => ({
+ content: token.content,
+ color: token.color ?? null,
+ fontStyle: token.fontStyle ?? null,
+ })),
+ );
+}
+
+async function createNativeReviewDiffHighlighter(): Promise {
+ const nativeEngineModule = await import("react-native-shiki-engine");
+ if (!nativeEngineModule.isNativeEngineAvailable()) {
+ throw new Error("Native Shiki engine is not available in this build.");
+ }
+
+ const highlighter = await createHighlighterCore({
+ langs: NATIVE_REVIEW_DIFF_LANGUAGES,
+ themes: NATIVE_REVIEW_DIFF_SHIKI_THEMES,
+ engine: nativeEngineModule.createNativeEngine(),
+ });
+
+ return {
+ engine: "native",
+ tokenize: (code, options) => normalizeTokens(highlighter.codeToTokensBase(code, options)),
+ };
+}
+
+async function createJavascriptReviewDiffHighlighter(): Promise {
+ const highlighter: HighlighterCore = await createHighlighterCore({
+ langs: NATIVE_REVIEW_DIFF_LANGUAGES,
+ themes: NATIVE_REVIEW_DIFF_SHIKI_THEMES,
+ engine: createJavaScriptRegexEngine(),
+ });
+
+ return {
+ engine: "javascript",
+ tokenize: (code, options) => normalizeTokens(highlighter.codeToTokensBase(code, options)),
+ };
+}
+
+export async function getNativeReviewDiffHighlighter(
+ engine: NativeReviewDiffHighlightEngine = "native",
+): Promise {
+ if (engine === "javascript") {
+ javascriptHighlighterPromise ??= createJavascriptReviewDiffHighlighter();
+ return javascriptHighlighterPromise;
+ }
+
+ nativeHighlighterPromise ??= createNativeReviewDiffHighlighter().catch((error: unknown) => {
+ console.warn("[debug-native-diff] native highlighter unavailable", {
+ error: error instanceof Error ? error.message : String(error),
+ });
+ javascriptHighlighterPromise ??= createJavascriptReviewDiffHighlighter();
+ return javascriptHighlighterPromise;
+ });
+ return nativeHighlighterPromise;
+}
+
+function isHighlightableLineRow(row: NativeReviewDiffRow): row is NativeReviewDiffLineRow {
+ return row.kind === "line" && typeof row.fileId === "string" && typeof row.content === "string";
+}
+
+function groupLineRowsByFileId(rows: ReadonlyArray) {
+ const rowsByFileId = new Map();
+ for (const row of rows) {
+ if (!isHighlightableLineRow(row)) {
+ continue;
+ }
+
+ const fileRows = rowsByFileId.get(row.fileId) ?? [];
+ fileRows.push(row);
+ rowsByFileId.set(row.fileId, fileRows);
+ }
+ return rowsByFileId;
+}
+
+function createFileMap(files: ReadonlyArray) {
+ return new Map(files.map((file) => [file.id, file]));
+}
+
+function clampRowIndex(index: number, rows: ReadonlyArray) {
+ if (rows.length === 0) {
+ return 0;
+ }
+
+ return Math.min(rows.length - 1, Math.max(0, Math.floor(index)));
+}
+
+function makePlainTokenFallback(
+ row: NativeReviewDiffLineRow,
+): ReadonlyArray {
+ return [{ content: row.content || " ", color: null, fontStyle: null }];
+}
+
+export async function highlightNativeReviewDiffVisibleRows(
+ input: HighlightNativeReviewDiffVisibleRowsInput,
+): Promise<{
+ readonly engine: NativeReviewDiffHighlightEngine;
+ readonly tokensByRowId: Record>;
+ readonly rowCount: number;
+ readonly durationMs: number;
+}> {
+ const highlighter = await getNativeReviewDiffHighlighter(input.engine ?? "native");
+ if (input.signal?.aborted) {
+ return { engine: highlighter.engine, tokensByRowId: {}, rowCount: 0, durationMs: 0 };
+ }
+
+ const startedAt = performance.now();
+ const theme = NATIVE_REVIEW_DIFF_THEME_NAME_BY_SCHEME[input.scheme];
+ const fileMap = createFileMap(input.files);
+ const overscanRows = input.overscanRows ?? NATIVE_REVIEW_DIFF_VISIBLE_OVERSCAN_ROWS;
+ const maxRows = input.maxRows ?? NATIVE_REVIEW_DIFF_VISIBLE_MAX_ROWS;
+ const startIndex = clampRowIndex(input.firstRowIndex - overscanRows, input.rows);
+ const endIndex = clampRowIndex(input.lastRowIndex + overscanRows, input.rows);
+ const selectedRows: NativeReviewDiffLineRow[] = [];
+
+ for (
+ let rowIndex = startIndex;
+ rowIndex <= endIndex && selectedRows.length < maxRows;
+ rowIndex += 1
+ ) {
+ const row = input.rows[rowIndex];
+ if (
+ row &&
+ isHighlightableLineRow(row) &&
+ !input.alreadyHighlightedRowIds?.has(row.id) &&
+ fileMap.has(row.fileId)
+ ) {
+ selectedRows.push(row);
+ }
+ }
+
+ const tokensByRowId: Record> = {};
+ let segmentRows: NativeReviewDiffLineRow[] = [];
+ let segmentFile: NativeReviewDiffFile | undefined;
+
+ const flushSegment = () => {
+ if (!segmentFile || segmentRows.length === 0 || input.signal?.aborted) {
+ segmentRows = [];
+ segmentFile = undefined;
+ return;
+ }
+
+ const code = segmentRows.map((row) => row.content).join("\n");
+ const tokenLines = highlighter.tokenize(code, { lang: segmentFile.language, theme });
+ segmentRows.forEach((row, rowIndex) => {
+ tokensByRowId[row.id] = tokenLines[rowIndex] ?? makePlainTokenFallback(row);
+ });
+ segmentRows = [];
+ segmentFile = undefined;
+ };
+
+ for (const row of selectedRows) {
+ const file = fileMap.get(row.fileId);
+ if (!file) {
+ continue;
+ }
+
+ if (segmentFile && segmentFile.id !== file.id) {
+ flushSegment();
+ }
+
+ segmentFile = file;
+ segmentRows.push(row);
+ }
+ flushSegment();
+
+ return {
+ engine: highlighter.engine,
+ tokensByRowId,
+ rowCount: selectedRows.length,
+ durationMs: Math.round(performance.now() - startedAt),
+ };
+}
+
+export async function streamNativeReviewDiffTokens(
+ input: StreamNativeReviewDiffTokenInput,
+): Promise {
+ const highlighter = await getNativeReviewDiffHighlighter(input.engine ?? "native");
+ const rowsByFileId = groupLineRowsByFileId(input.rows);
+ const theme = NATIVE_REVIEW_DIFF_THEME_NAME_BY_SCHEME[input.scheme];
+ const chunkSize = input.chunkSize ?? NATIVE_REVIEW_DIFF_HIGHLIGHT_CHUNK_SIZE;
+ let chunkIndex = 0;
+
+ for (const file of input.files) {
+ const fileRows = rowsByFileId.get(file.id) ?? [];
+ for (let startIndex = 0; startIndex < fileRows.length; startIndex += chunkSize) {
+ if (input.signal?.aborted) {
+ return highlighter.engine;
+ }
+
+ const startedAt = performance.now();
+ const chunkRows = fileRows.slice(startIndex, startIndex + chunkSize);
+ const code = chunkRows.map((row) => row.content).join("\n");
+ const tokenLines = highlighter.tokenize(code, { lang: file.language, theme });
+ const tokensByRowId: Record> = {};
+
+ chunkRows.forEach((row, rowIndex) => {
+ tokensByRowId[row.id] = tokenLines[rowIndex] ?? makePlainTokenFallback(row);
+ });
+
+ input.onChunk({
+ chunkIndex,
+ fileId: file.id,
+ filePath: file.path,
+ language: file.language,
+ lineCount: chunkRows.length,
+ durationMs: Math.round(performance.now() - startedAt),
+ tokensByRowId,
+ });
+
+ chunkIndex += 1;
+ await waitForNextFrame();
+ }
+ }
+
+ return highlighter.engine;
+}
diff --git a/apps/mobile/src/features/diffs/nativeReviewDiffSurface.test.ts b/apps/mobile/src/features/diffs/nativeReviewDiffSurface.test.ts
new file mode 100644
index 00000000000..d4cb5c1a75e
--- /dev/null
+++ b/apps/mobile/src/features/diffs/nativeReviewDiffSurface.test.ts
@@ -0,0 +1,69 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+const expoModulesCoreMocks = vi.hoisted(() => ({
+ requireNativeViewManager: vi.fn(),
+}));
+const nativeView = () => null;
+const originalExpo = globalThis.expo;
+
+function setExpoViewConfigAvailable() {
+ globalThis.expo = {
+ getViewConfig: vi.fn().mockReturnValue({ validAttributes: {}, directEventTypes: {} }),
+ } as unknown as typeof globalThis.expo;
+}
+
+vi.mock("expo-modules-core", () => ({
+ requireNativeViewManager: expoModulesCoreMocks.requireNativeViewManager,
+}));
+
+describe("resolveNativeReviewDiffView", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.resetModules();
+ globalThis.expo = undefined as unknown as typeof globalThis.expo;
+ });
+
+ afterEach(() => {
+ globalThis.expo = originalExpo;
+ });
+
+ it("returns null when the native review diff view config is unavailable", async () => {
+ const { resolveNativeReviewDiffView } = await import("./nativeReviewDiffSurface");
+ expect(resolveNativeReviewDiffView()).toBeNull();
+ expect(expoModulesCoreMocks.requireNativeViewManager).not.toHaveBeenCalled();
+ });
+
+ it("returns the native review diff view when the view config is installed", async () => {
+ setExpoViewConfigAvailable();
+ expoModulesCoreMocks.requireNativeViewManager.mockReturnValue(nativeView);
+ const { resolveNativeReviewDiffView } = await import("./nativeReviewDiffSurface");
+ expect(resolveNativeReviewDiffView()).toBe(nativeView);
+ expect(expoModulesCoreMocks.requireNativeViewManager).toHaveBeenCalledWith(
+ "T3ReviewDiffSurface",
+ );
+ });
+
+ it("does not fall back to stale legacy native review diff view names", async () => {
+ globalThis.expo = {
+ getViewConfig: vi.fn().mockImplementation((moduleName: string) => {
+ if (moduleName === "T3ReviewDiffView") {
+ return { validAttributes: {}, directEventTypes: {} };
+ }
+ return null;
+ }),
+ } as unknown as typeof globalThis.expo;
+ expoModulesCoreMocks.requireNativeViewManager.mockReturnValue(nativeView);
+ const { resolveNativeReviewDiffView } = await import("./nativeReviewDiffSurface");
+ expect(resolveNativeReviewDiffView()).toBeNull();
+ expect(expoModulesCoreMocks.requireNativeViewManager).not.toHaveBeenCalled();
+ });
+
+ it("returns null when the view manager cannot be required", async () => {
+ setExpoViewConfigAvailable();
+ expoModulesCoreMocks.requireNativeViewManager.mockImplementation(() => {
+ throw new Error("boom");
+ });
+ const { resolveNativeReviewDiffView } = await import("./nativeReviewDiffSurface");
+ expect(resolveNativeReviewDiffView()).toBeNull();
+ });
+});
diff --git a/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts b/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts
new file mode 100644
index 00000000000..e7b77db0362
--- /dev/null
+++ b/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts
@@ -0,0 +1,155 @@
+import type { ComponentType } from "react";
+import type { NativeSyntheticEvent, ViewProps } from "react-native";
+import { requireNativeViewManager } from "expo-modules-core";
+
+const NATIVE_REVIEW_DIFF_MODULE_NAME = "T3ReviewDiffSurface";
+
+interface ExpoGlobalWithViewConfig {
+ readonly expo?: {
+ getViewConfig?: (moduleName: string, viewName?: string) => unknown;
+ };
+}
+
+export interface NativeReviewDiffRow {
+ readonly kind: "file" | "hunk" | "line" | "notice" | "comment";
+ readonly id: string;
+ readonly fileId?: string;
+ readonly filePath?: string;
+ readonly previousPath?: string | null;
+ readonly changeType?:
+ | "modified"
+ | "new"
+ | "deleted"
+ | "renamed"
+ | "rename-pure"
+ | "rename-changed";
+ readonly additions?: number;
+ readonly deletions?: number;
+ readonly text?: string;
+ readonly content?: string;
+ readonly change?: "context" | "add" | "delete";
+ readonly oldLineNumber?: number | null;
+ readonly newLineNumber?: number | null;
+ readonly wordDiffRanges?: ReadonlyArray;
+ readonly commentText?: string;
+ readonly commentRangeLabel?: string;
+ readonly commentSectionTitle?: string;
+}
+
+export interface NativeReviewDiffWordDiffRange {
+ readonly start: number;
+ readonly end: number;
+}
+
+export interface NativeReviewDiffToken {
+ readonly content: string;
+ readonly color: string | null;
+ readonly fontStyle: number | null;
+}
+
+export interface NativeReviewDiffTheme {
+ readonly background: string;
+ readonly text: string;
+ readonly mutedText: string;
+ readonly headerBackground: string;
+ readonly border: string;
+ readonly hunkBackground: string;
+ readonly hunkText: string;
+ readonly addBackground: string;
+ readonly deleteBackground: string;
+ readonly addBar: string;
+ readonly deleteBar: string;
+ readonly addText: string;
+ readonly deleteText: string;
+}
+
+export interface NativeReviewDiffStyle {
+ readonly rowHeight?: number;
+ readonly contentWidth?: number;
+ readonly changeBarWidth?: number;
+ readonly gutterWidth?: number;
+ readonly codePadding?: number;
+ readonly textVerticalInset?: number;
+ readonly fileHeaderHeight?: number;
+ readonly fileHeaderHorizontalMargin?: number;
+ readonly fileHeaderVerticalMargin?: number;
+ readonly fileHeaderCornerRadius?: number;
+ readonly fileHeaderHorizontalPadding?: number;
+ readonly fileHeaderPathRightPadding?: number;
+ readonly fileHeaderCountColumnWidth?: number;
+ readonly fileHeaderCountGap?: number;
+ readonly codeFontSize?: number;
+ readonly codeFontWeight?: string;
+ readonly lineNumberFontSize?: number;
+ readonly lineNumberFontWeight?: string;
+ readonly hunkFontSize?: number;
+ readonly hunkFontWeight?: string;
+ readonly fileHeaderFontSize?: number;
+ readonly fileHeaderFontWeight?: string;
+ readonly fileHeaderMetaFontSize?: number;
+ readonly fileHeaderMetaFontWeight?: string;
+ readonly fileHeaderSubtextFontSize?: number;
+ readonly fileHeaderSubtextFontWeight?: string;
+ readonly fileHeaderStatusFontSize?: number;
+ readonly fileHeaderStatusFontWeight?: string;
+ readonly emptyStateFontSize?: number;
+ readonly emptyStateFontWeight?: string;
+}
+
+export interface NativeReviewDiffViewProps extends ViewProps {
+ readonly rowsJson: string;
+ readonly tokensJson?: string;
+ readonly tokensPatchJson?: string;
+ readonly tokensResetKey?: string;
+ readonly collapsedFileIdsJson?: string;
+ readonly viewedFileIdsJson?: string;
+ readonly selectedRowIdsJson?: string;
+ readonly collapsedCommentIdsJson?: string;
+ readonly appearanceScheme: "light" | "dark";
+ readonly themeJson: string;
+ readonly styleJson?: string;
+ readonly rowHeight: number;
+ readonly contentWidth: number;
+ readonly onDebug?: (event: NativeSyntheticEvent>) => void;
+ readonly onToggleFile?: (event: NativeSyntheticEvent<{ readonly fileId?: string }>) => void;
+ readonly onToggleViewedFile?: (event: NativeSyntheticEvent<{ readonly fileId?: string }>) => void;
+ readonly onPressLine?: (
+ event: NativeSyntheticEvent<{
+ readonly rowId?: string;
+ readonly fileId?: string;
+ readonly gesture?: "tap" | "longPress";
+ readonly oldLineNumber?: number;
+ readonly newLineNumber?: number;
+ readonly change?: "context" | "add" | "delete";
+ }>,
+ ) => void;
+ readonly onToggleComment?: (event: NativeSyntheticEvent<{ readonly commentId?: string }>) => void;
+}
+
+let cachedNativeReviewDiffView: ComponentType | undefined;
+
+function getExpoViewConfig(moduleName: string) {
+ return (globalThis as typeof globalThis & ExpoGlobalWithViewConfig).expo?.getViewConfig?.(
+ moduleName,
+ );
+}
+
+export function resolveNativeReviewDiffView(): ComponentType | null {
+ if (cachedNativeReviewDiffView) {
+ return cachedNativeReviewDiffView;
+ }
+
+ if (getExpoViewConfig(NATIVE_REVIEW_DIFF_MODULE_NAME) == null) {
+ return null;
+ }
+
+ try {
+ cachedNativeReviewDiffView = requireNativeViewManager(
+ NATIVE_REVIEW_DIFF_MODULE_NAME,
+ );
+ } catch {
+ return null;
+ }
+
+ return cachedNativeReviewDiffView ?? null;
+}
diff --git a/apps/mobile/src/features/diffs/nativeReviewDiffTypes.ts b/apps/mobile/src/features/diffs/nativeReviewDiffTypes.ts
new file mode 100644
index 00000000000..fe34352cfa1
--- /dev/null
+++ b/apps/mobile/src/features/diffs/nativeReviewDiffTypes.ts
@@ -0,0 +1,17 @@
+export type NativeReviewDiffLanguage =
+ | "bash"
+ | "diff"
+ | "javascript"
+ | "json"
+ | "jsx"
+ | "tsx"
+ | "typescript"
+ | "yaml";
+
+export interface NativeReviewDiffFile {
+ readonly id: string;
+ readonly path: string;
+ readonly language: NativeReviewDiffLanguage;
+ readonly additions: number;
+ readonly deletions: number;
+}
diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx
new file mode 100644
index 00000000000..90bc5969440
--- /dev/null
+++ b/apps/mobile/src/features/home/HomeScreen.tsx
@@ -0,0 +1,346 @@
+import type {
+ EnvironmentScopedProjectShell,
+ EnvironmentScopedThreadShell,
+ VcsStatusState,
+} from "@t3tools/client-runtime";
+import { SymbolView } from "expo-symbols";
+import { useCallback, useMemo, useState } from "react";
+import { Pressable, ScrollView, View } from "react-native";
+import { useThemeColor } from "../../lib/useThemeColor";
+
+import { AppText as Text } from "../../components/AppText";
+import { EmptyState } from "../../components/EmptyState";
+import { ProjectFavicon } from "../../components/ProjectFavicon";
+import type { SavedRemoteConnection } from "../../lib/connection";
+import { scopedProjectKey } from "../../lib/scopedEntities";
+import { relativeTime } from "../../lib/time";
+import { useVcsStatus } from "../../state/use-vcs-status";
+import { threadStatusTone } from "../threads/threadPresentation";
+
+/* ─── Types ──────────────────────────────────────────────────────────── */
+
+interface HomeScreenProps {
+ readonly projects: ReadonlyArray;
+ readonly threads: ReadonlyArray;
+ readonly savedConnectionsById: Readonly>;
+ readonly searchQuery: string;
+ readonly onSelectThread: (thread: EnvironmentScopedThreadShell) => void;
+}
+
+interface ProjectGroup {
+ readonly key: string;
+ readonly project: EnvironmentScopedProjectShell;
+ readonly threads: ReadonlyArray;
+}
+
+/* ─── Status indicator colors ────────────────────────────────────────── */
+
+function statusColors(thread: EnvironmentScopedThreadShell): { bg: string; fg: string } {
+ switch (thread.session?.status) {
+ case "running":
+ return { bg: "rgba(249,115,22,0.14)", fg: "#f97316" };
+ case "ready":
+ return { bg: "rgba(34,197,94,0.14)", fg: "#22c55e" };
+ case "starting":
+ return { bg: "rgba(59,130,246,0.14)", fg: "#3b82f6" };
+ case "error":
+ return { bg: "rgba(239,68,68,0.14)", fg: "#ef4444" };
+ default:
+ return { bg: "rgba(163,163,163,0.10)", fg: "#a3a3a3" };
+ }
+}
+
+const COLLAPSED_THREAD_LIMIT = 6;
+
+/* ─── Project group header ───────────────────────────────────────────── */
+
+function ProjectGroupLabel(props: {
+ readonly project: EnvironmentScopedProjectShell;
+ readonly totalThreadCount: number;
+ readonly httpBaseUrl: string | null;
+ readonly bearerToken: string | null;
+ readonly isExpanded: boolean;
+ readonly onToggleExpand: () => void;
+}) {
+ const hiddenCount = props.totalThreadCount - COLLAPSED_THREAD_LIMIT;
+
+ return (
+
+
+
+ {props.project.title}
+
+
+ {hiddenCount > 0 ? (
+
+
+ {props.isExpanded ? "Show less" : `${hiddenCount} more`}
+
+
+ ) : null}
+
+ );
+}
+
+/* ─── Git summary line ──────────────────────────────────────────────── */
+
+function gitSummaryParts(gitStatus: VcsStatusState): ReadonlyArray {
+ if (!gitStatus.data) return [];
+ const { data } = gitStatus;
+ const parts: string[] = [];
+ if (data.hasWorkingTreeChanges) {
+ parts.push(`${data.workingTree.files.length} changed`);
+ }
+ if (data.aheadCount > 0) parts.push(`${data.aheadCount} ahead`);
+ if (data.behindCount > 0) parts.push(`${data.behindCount} behind`);
+ if (data.pr?.state === "open") parts.push(`PR #${data.pr.number}`);
+ return parts;
+}
+
+/* ─── Thread row ─────────────────────────────────────────────────────── */
+
+function ThreadRow(props: {
+ readonly thread: EnvironmentScopedThreadShell;
+ readonly projectCwd: string | null;
+ readonly onPress: () => void;
+ readonly isLast: boolean;
+}) {
+ const separatorColor = useThemeColor("--color-separator");
+ const { bg, fg } = statusColors(props.thread);
+ const tone = threadStatusTone(props.thread);
+ const timestamp = relativeTime(props.thread.updatedAt ?? props.thread.createdAt);
+ const branch = props.thread.branch;
+
+ // Subscribe to live git status — only when thread has a branch set.
+ // Threads sharing the same cwd share one WS subscription via ref-counting.
+ const cwd = branch ? (props.thread.worktreePath ?? props.projectCwd) : null;
+ const gitStatus = useVcsStatus({
+ environmentId: cwd ? props.thread.environmentId : null,
+ cwd,
+ });
+ const gitParts = gitSummaryParts(gitStatus);
+
+ return (
+ ({ opacity: pressed ? 0.7 : 1 })}>
+
+ {/* Git status indicator */}
+
+
+
+
+ {/* Content */}
+
+ {/* Title + Status + Timestamp */}
+
+
+ {props.thread.title}
+
+
+
+
+ {tone.label}
+
+
+
+ {timestamp}
+
+
+
+
+ {/* Branch + git info */}
+ {branch ? (
+
+
+
+ {branch}
+
+ {gitParts.length > 0 ? (
+
+ {" · " + gitParts.join(" · ")}
+
+ ) : null}
+
+ ) : null}
+
+
+
+ );
+}
+
+/* ─── Main screen ────────────────────────────────────────────────────── */
+
+export function HomeScreen(props: HomeScreenProps) {
+ const [expandedProjects, setExpandedProjects] = useState>(() => new Set());
+
+ const toggleExpanded = useCallback((key: string) => {
+ setExpandedProjects((prev) => {
+ const next = new Set(prev);
+ if (next.has(key)) next.delete(key);
+ else next.add(key);
+ return next;
+ });
+ }, []);
+
+ /* Build project title lookup for search */
+ const projectTitleByKey = useMemo(() => {
+ const map = new Map();
+ for (const p of props.projects) {
+ map.set(scopedProjectKey(p.environmentId, p.id), p.title);
+ }
+ return map;
+ }, [props.projects]);
+
+ /* Filter threads by search query */
+ const filteredThreads = useMemo(() => {
+ const q = props.searchQuery.trim().toLowerCase();
+ if (!q) return props.threads;
+ return props.threads.filter((t) => {
+ if (t.title.toLowerCase().includes(q)) return true;
+ const key = scopedProjectKey(t.environmentId, t.projectId);
+ return projectTitleByKey.get(key)?.toLowerCase().includes(q) ?? false;
+ });
+ }, [props.threads, props.searchQuery, projectTitleByKey]);
+
+ /* Group filtered threads by project */
+ const projectGroups = useMemo>(() => {
+ const byProject = new Map();
+ for (const thread of filteredThreads) {
+ const key = scopedProjectKey(thread.environmentId, thread.projectId);
+ const existing = byProject.get(key);
+ if (existing) existing.push(thread);
+ else byProject.set(key, [thread]);
+ }
+
+ const groups: ProjectGroup[] = [];
+ for (const project of props.projects) {
+ const key = scopedProjectKey(project.environmentId, project.id);
+ const threads = byProject.get(key);
+ if (threads && threads.length > 0) {
+ groups.push({ key, project, threads });
+ }
+ }
+
+ groups.sort((a, b) => {
+ const aTime = new Date(a.threads[0]!.updatedAt ?? a.threads[0]!.createdAt).getTime();
+ const bTime = new Date(b.threads[0]!.updatedAt ?? b.threads[0]!.createdAt).getTime();
+ return bTime - aTime;
+ });
+
+ return groups;
+ }, [props.projects, filteredThreads]);
+
+ /* Empty states */
+ const hasAnyThreads = props.threads.length > 0;
+ const hasResults = filteredThreads.length > 0;
+
+ return (
+
+ {!hasAnyThreads ? (
+
+ ) : !hasResults ? (
+
+ ) : (
+ projectGroups.map((group) => {
+ const connection = props.savedConnectionsById[group.project.environmentId];
+ const isExpanded = expandedProjects.has(group.key);
+ const visibleThreads = isExpanded
+ ? group.threads
+ : group.threads.slice(0, COLLAPSED_THREAD_LIMIT);
+
+ return (
+
+ toggleExpanded(group.key)}
+ />
+
+ {visibleThreads.map((thread, i) => (
+ props.onSelectThread(thread)}
+ isLast={i === visibleThreads.length - 1}
+ />
+ ))}
+
+
+ );
+ })
+ )}
+
+ );
+}
diff --git a/apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx b/apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx
new file mode 100644
index 00000000000..a2a5ad8a745
--- /dev/null
+++ b/apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx
@@ -0,0 +1,318 @@
+import { useLocalSearchParams, useRouter } from "expo-router";
+import { SymbolView } from "expo-symbols";
+import { TextInputWrapper } from "expo-paste-input";
+import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
+import { useEffect, useMemo, useState } from "react";
+import { Pressable, ScrollView, View, useColorScheme, useWindowDimensions } from "react-native";
+import { KeyboardStickyView } from "react-native-keyboard-controller";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import ImageViewing from "react-native-image-viewing";
+
+import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
+import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip";
+import { ControlPill } from "../../components/ControlPill";
+import { cn } from "../../lib/cn";
+import type { DraftComposerImageAttachment } from "../../lib/composerImages";
+import { convertPastedImagesToAttachments, pickComposerImages } from "../../lib/composerImages";
+import { useThemeColor } from "../../lib/useThemeColor";
+import { useNativePaste } from "../../lib/useNativePaste";
+import { setPendingConnectionError } from "../../state/use-remote-environment-registry";
+import { appendReviewCommentToDraft } from "../../state/use-thread-composer-state";
+import {
+ clearReviewCommentTarget,
+ formatReviewCommentContext,
+ getReviewUnifiedLineNumber,
+ getSelectedReviewCommentLines,
+ useReviewCommentTarget,
+} from "./reviewCommentSelection";
+import {
+ changeTone,
+ DiffTokenText,
+ REVIEW_DIFF_LINE_HEIGHT,
+ REVIEW_MONO_FONT_FAMILY,
+ ReviewChangeBar,
+} from "./reviewDiffRendering";
+import {
+ highlightReviewSelectedLines,
+ type ReviewDiffTheme,
+ type ReviewHighlightedToken,
+} from "./shikiReviewHighlighter";
+
+const REVIEW_COMMENT_PREVIEW_MAX_LINES = 5;
+
+export function ReviewCommentComposerSheet() {
+ const router = useRouter();
+ const insets = useSafeAreaInsets();
+ const { width } = useWindowDimensions();
+ const colorScheme = useColorScheme();
+ const iconTint = String(useThemeColor("--color-icon"));
+ const target = useReviewCommentTarget();
+ const { environmentId, threadId } = useLocalSearchParams<{
+ environmentId: EnvironmentId;
+ threadId: ThreadId;
+ }>();
+ const [commentText, setCommentText] = useState("");
+ const [highlightedLinesById, setHighlightedLinesById] = useState<
+ Record>
+ >({});
+ const [attachments, setAttachments] = useState>([]);
+ const [previewImageUri, setPreviewImageUri] = useState(null);
+
+ const selectedLines = useMemo(
+ () => (target ? getSelectedReviewCommentLines(target) : []),
+ [target],
+ );
+ const firstLine = selectedLines[0] ?? null;
+ const lastLine = selectedLines[selectedLines.length - 1] ?? null;
+ const firstNumber = firstLine ? getReviewUnifiedLineNumber(firstLine) : null;
+ const lastNumber = lastLine ? getReviewUnifiedLineNumber(lastLine) : null;
+ const selectedTheme = (colorScheme === "dark" ? "dark" : "light") satisfies ReviewDiffTheme;
+ const canSubmit =
+ commentText.trim().length > 0 && target !== null && !!environmentId && !!threadId;
+ const selectionLabel =
+ selectedLines.length === 1
+ ? firstNumber !== null
+ ? `Line ${firstNumber}`
+ : "File comment"
+ : firstNumber !== null && lastNumber !== null
+ ? `Lines ${firstNumber}-${lastNumber}`
+ : `${selectedLines.length} lines selected`;
+ const previewHeight = Math.max(
+ Math.min(selectedLines.length, REVIEW_COMMENT_PREVIEW_MAX_LINES) * REVIEW_DIFF_LINE_HEIGHT,
+ REVIEW_DIFF_LINE_HEIGHT,
+ );
+ const previewViewportWidth = Math.max(width - 40, 280);
+ const handleNativePaste = useNativePaste((uris) => {
+ void (async () => {
+ try {
+ const images = await convertPastedImagesToAttachments({
+ uris,
+ existingCount: attachments.length,
+ });
+ if (images.length > 0) {
+ setAttachments((current) => [...current, ...images]);
+ }
+ } catch (error) {
+ console.error("[review comment] error converting pasted images", error);
+ }
+ })();
+ });
+
+ useEffect(() => {
+ if (!target || selectedLines.length === 0) {
+ setHighlightedLinesById({});
+ return;
+ }
+
+ let cancelled = false;
+ void highlightReviewSelectedLines({
+ filePath: target.filePath,
+ lines: selectedLines,
+ theme: selectedTheme,
+ })
+ .then((next) => {
+ if (!cancelled) {
+ setHighlightedLinesById(next);
+ }
+ })
+ .catch(() => {
+ if (!cancelled) {
+ setHighlightedLinesById({});
+ }
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, [selectedLines, selectedTheme, target]);
+
+ async function handlePickImages(): Promise {
+ const result = await pickComposerImages({ existingCount: attachments.length });
+ if (result.images.length > 0) {
+ setAttachments((current) => [...current, ...result.images]);
+ }
+ if (result.error) {
+ setPendingConnectionError(result.error);
+ }
+ }
+
+ return (
+
+
+
+ {
+ clearReviewCommentTarget();
+ router.dismiss();
+ }}
+ >
+
+
+
+ Add Comment
+
+
+
+
+ {!target ? (
+
+ No selection
+
+ Select a diff line or range first.
+
+
+ ) : (
+
+
+
+ {selectionLabel}
+
+
+ {target.filePath}
+
+
+
+
+
+ REVIEW_COMMENT_PREVIEW_MAX_LINES}
+ nestedScrollEnabled
+ keyboardShouldPersistTaps="always"
+ showsVerticalScrollIndicator={
+ selectedLines.length > REVIEW_COMMENT_PREVIEW_MAX_LINES
+ }
+ style={{ height: previewHeight }}
+ >
+
+ {selectedLines.map((line) => {
+ const lineNumber = getReviewUnifiedLineNumber(line);
+
+ return (
+
+
+
+ {lineNumber ?? ""}
+
+
+
+
+
+ );
+ })}
+
+
+
+
+
+
+ Comment
+
+
+
+
+
+
+ {attachments.length > 0 ? (
+
+ {
+ setAttachments((current) =>
+ current.filter((image) => image.id !== imageId),
+ );
+ }}
+ />
+
+ ) : null}
+
+
+
+ )}
+
+ {target ? (
+
+
+ void handlePickImages()} />
+
+ {
+ if (!target || !environmentId || !threadId || commentText.trim().length === 0) {
+ return;
+ }
+
+ appendReviewCommentToDraft({
+ environmentId,
+ threadId,
+ text: formatReviewCommentContext(target, commentText),
+ attachments,
+ });
+ setAttachments([]);
+ clearReviewCommentTarget();
+ router.dismiss();
+ }}
+ />
+
+
+ ) : null}
+ setPreviewImageUri(null)}
+ swipeToCloseEnabled
+ doubleTapToZoomEnabled
+ />
+
+ );
+}
diff --git a/apps/mobile/src/features/review/ReviewHighlighterProvider.tsx b/apps/mobile/src/features/review/ReviewHighlighterProvider.tsx
new file mode 100644
index 00000000000..150584aedea
--- /dev/null
+++ b/apps/mobile/src/features/review/ReviewHighlighterProvider.tsx
@@ -0,0 +1,24 @@
+import { createContext, type ReactNode, useContext, useMemo } from "react";
+
+import { type ReviewHighlighterState, useReviewHighlighterState } from "./reviewHighlighterState";
+
+const ReviewHighlighterContext = createContext({
+ engine: null,
+ error: null,
+ status: "idle",
+});
+
+export function ReviewHighlighterProvider(props: { readonly children: ReactNode }) {
+ const value = useReviewHighlighterState();
+ const contextValue = useMemo(() => value, [value]);
+
+ return (
+
+ {props.children}
+
+ );
+}
+
+export function useReviewHighlighterStatus(): ReviewHighlighterState {
+ return useContext(ReviewHighlighterContext);
+}
diff --git a/apps/mobile/src/features/review/ReviewSheet.tsx b/apps/mobile/src/features/review/ReviewSheet.tsx
new file mode 100644
index 00000000000..74238bfe4ef
--- /dev/null
+++ b/apps/mobile/src/features/review/ReviewSheet.tsx
@@ -0,0 +1,435 @@
+import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
+import { useLocalSearchParams } from "expo-router";
+import Stack from "expo-router/stack";
+import { SymbolView } from "expo-symbols";
+import { memo, type ReactElement, useCallback, useMemo } from "react";
+import {
+ ActivityIndicator,
+ Pressable,
+ ScrollView,
+ type NativeSyntheticEvent,
+ Text as NativeText,
+ StyleSheet,
+ useColorScheme,
+ View,
+} from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+
+import { AppText as Text } from "../../components/AppText";
+import { useThemeColor } from "../../lib/useThemeColor";
+import { useThreadDraftForThread } from "../../state/use-thread-composer-state";
+import { useReviewCacheForThread } from "./reviewState";
+import { resolveNativeReviewDiffView } from "../diffs/nativeReviewDiffSurface";
+import {
+ NATIVE_REVIEW_DIFF_CONTENT_WIDTH,
+ NATIVE_REVIEW_DIFF_ROW_HEIGHT,
+} from "./nativeReviewDiffAdapter";
+import { useReviewDiffData } from "./useReviewDiffData";
+import { useReviewFileVisibility } from "./reviewFileVisibility";
+import { useReviewSections } from "./useReviewSections";
+import { useNativeReviewDiffBridge } from "./useNativeReviewDiffBridge";
+import { useReviewCommentSelectionController } from "./useReviewCommentSelectionController";
+
+const IOS_NAV_BAR_HEIGHT = 44;
+const REVIEW_HEADER_SPACING = 0;
+
+const ReviewNotice = memo(function ReviewNotice(props: { readonly notice: string }) {
+ return (
+
+
+ Partial diff
+
+
+ {props.notice}
+
+
+ );
+});
+
+function ReviewSelectionActionBar(props: {
+ readonly bottomInset: number;
+ readonly title: string | null;
+ readonly onOpenComment: (() => void) | null;
+ readonly onClear: () => void;
+}) {
+ if (!props.title) {
+ return null;
+ }
+
+ const content = (
+ <>
+
+ {props.title}
+ >
+ );
+
+ return (
+
+ {props.onOpenComment ? (
+
+ {content}
+
+ ) : (
+
+ {content}
+
+ )}
+
+
+
+
+
+ );
+}
+
+export function ReviewSheet() {
+ const insets = useSafeAreaInsets();
+ const colorScheme = useColorScheme();
+ const headerForeground = String(useThemeColor("--color-foreground"));
+ const headerMuted = String(useThemeColor("--color-foreground-muted"));
+ const headerIcon = String(useThemeColor("--color-icon"));
+ const { environmentId, threadId } = useLocalSearchParams<{
+ environmentId: EnvironmentId;
+ threadId: ThreadId;
+ }>();
+ const { draftMessage } = useThreadDraftForThread({ environmentId, threadId });
+ const reviewCache = useReviewCacheForThread({ environmentId, threadId });
+ const selectedTheme = colorScheme === "dark" ? "dark" : "light";
+ const topContentInset = insets.top + IOS_NAV_BAR_HEIGHT;
+ const {
+ error,
+ loadingGitDiffs,
+ loadingTurnIds,
+ reviewSections,
+ selectedSection,
+ refreshSelectedSection,
+ selectSection,
+ } = useReviewSections({ environmentId, threadId, reviewCache });
+ const { headerDiffSummary, nativeReviewDiffData, parsedDiff, pendingReviewCommentCount } =
+ useReviewDiffData({
+ threadKey: reviewCache.threadKey,
+ selectedSection,
+ draftMessage,
+ });
+ const NativeReviewDiffView = resolveNativeReviewDiffView()!;
+ const reviewFiles = parsedDiff.kind === "files" ? parsedDiff.files : [];
+ const fileVisibility = useReviewFileVisibility({
+ threadKey: reviewCache.threadKey,
+ sectionId: selectedSection?.id ?? null,
+ files: reviewFiles,
+ cachedExpandedFileIds: selectedSection?.id
+ ? reviewCache.expandedFileIdsBySection[selectedSection.id]
+ : undefined,
+ cachedViewedFileIds: selectedSection?.id
+ ? reviewCache.viewedFileIdsBySection[selectedSection.id]
+ : undefined,
+ });
+ const { collapsedFileIds, toggleExpandedFile, toggleViewedFile, viewedFileIds } = fileVisibility;
+ const commentSelection = useReviewCommentSelectionController({
+ environmentId,
+ threadId,
+ selectedSection,
+ nativeReviewDiffData,
+ });
+ const nativeBridge = useNativeReviewDiffBridge({
+ threadKey: reviewCache.threadKey,
+ sectionId: selectedSection?.id ?? null,
+ diff: selectedSection?.diff,
+ data: nativeReviewDiffData,
+ scheme: selectedTheme,
+ collapsedFileIds,
+ viewedFileIds,
+ selectedRowIds: commentSelection.selectedRowIds,
+ canHighlight: parsedDiff.kind === "files",
+ });
+
+ const handleNativeToggleFile = useCallback(
+ (event: NativeSyntheticEvent<{ readonly fileId?: string }>) => {
+ const { fileId } = event.nativeEvent;
+ if (fileId) {
+ toggleExpandedFile(fileId);
+ }
+ },
+ [toggleExpandedFile],
+ );
+
+ const handleNativeToggleViewedFile = useCallback(
+ (event: NativeSyntheticEvent<{ readonly fileId?: string }>) => {
+ const { fileId } = event.nativeEvent;
+ if (fileId) {
+ toggleViewedFile(fileId);
+ }
+ },
+ [toggleViewedFile],
+ );
+
+ const parsedDiffNotice =
+ parsedDiff.kind === "files" || parsedDiff.kind === "raw" ? parsedDiff.notice : null;
+
+ const listHeader = useMemo(() => {
+ const children: ReactElement[] = [];
+
+ if (error) {
+ children.push(
+
+ Review unavailable
+ {error}
+ ,
+ );
+ }
+
+ if (parsedDiffNotice) {
+ children.push();
+ }
+
+ if (children.length === 0) {
+ return null;
+ }
+
+ return <>{children}>;
+ }, [error, parsedDiffNotice]);
+
+ return (
+ <>
+ (
+
+
+ Files Changed
+
+
+ {headerDiffSummary.additions && headerDiffSummary.deletions ? (
+ <>
+
+ {headerDiffSummary.additions}
+
+
+ {headerDiffSummary.deletions}
+
+ {pendingReviewCommentCount > 0 ? (
+
+ {pendingReviewCommentCount} pending
+
+ ) : null}
+ >
+ ) : (
+
+
+ {selectedSection?.title ?? "Review changes"}
+
+ {pendingReviewCommentCount > 0 ? (
+
+ {pendingReviewCommentCount} pending
+
+ ) : null}
+
+ )}
+
+
+ ),
+ }}
+ />
+
+
+
+ {reviewSections.map((section) => (
+ selectSection(section.id)}
+ subtitle={section.subtitle ?? undefined}
+ >
+ {section.title}
+
+ ))}
+ void refreshSelectedSection()}
+ subtitle="Reload current diff"
+ >
+ Refresh
+
+
+
+
+
+ {selectedSection && parsedDiff.kind === "files" ? (
+
+ {listHeader}
+
+
+
+
+ ) : (
+
+ {listHeader}
+ {!selectedSection ? (
+
+ No review diffs
+
+ This thread has no ready turn diffs and the worktree diff is empty.
+
+
+ ) : selectedSection.isLoading && selectedSection.diff === null ? (
+
+
+ Loading diff…
+
+ ) : parsedDiff.kind === "empty" ? (
+
+ No changes
+
+ {selectedSection.subtitle ?? "This diff is empty."}
+
+
+ ) : parsedDiff.kind === "raw" ? (
+
+
+ {parsedDiff.reason}
+
+
+
+ {parsedDiff.text}
+
+
+
+ ) : null}
+
+ )}
+
+
+ >
+ );
+}
diff --git a/apps/mobile/src/features/review/diffParser.ts b/apps/mobile/src/features/review/diffParser.ts
new file mode 100644
index 00000000000..76e8872f8ab
--- /dev/null
+++ b/apps/mobile/src/features/review/diffParser.ts
@@ -0,0 +1,158 @@
+export type ParsedDiffLineType = "context" | "add" | "delete" | "meta" | "hunk";
+
+export interface ParsedDiffLine {
+ readonly id: string;
+ readonly type: ParsedDiffLineType;
+ readonly oldLine: number | null;
+ readonly newLine: number | null;
+ readonly content: string;
+}
+
+export interface ParsedDiffFile {
+ readonly id: string;
+ readonly oldPath: string | null;
+ readonly newPath: string | null;
+ readonly lines: ReadonlyArray;
+}
+
+function parseHunkStart(
+ line: string,
+): { readonly oldLine: number; readonly newLine: number } | null {
+ const match = line.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
+ if (!match) {
+ return null;
+ }
+
+ return {
+ oldLine: Number.parseInt(match[1] ?? "0", 10),
+ newLine: Number.parseInt(match[2] ?? "0", 10),
+ };
+}
+
+function parseDiffPath(line: string, prefix: "--- " | "+++ "): string | null {
+ if (!line.startsWith(prefix)) {
+ return null;
+ }
+ const raw = line.slice(prefix.length).trim();
+ if (raw === "/dev/null") {
+ return null;
+ }
+ return raw.replace(/^[ab]\//, "");
+}
+
+export function parseUnifiedDiff(diff: string): ReadonlyArray {
+ const files: ParsedDiffFile[] = [];
+ let current: {
+ oldPath: string | null;
+ newPath: string | null;
+ lines: ParsedDiffLine[];
+ } | null = null;
+ let oldLine: number | null = null;
+ let newLine: number | null = null;
+
+ const pushCurrent = () => {
+ if (!current) {
+ return;
+ }
+ files.push({
+ id: `${current.oldPath ?? "null"}:${current.newPath ?? "null"}:${files.length}`,
+ oldPath: current.oldPath,
+ newPath: current.newPath,
+ lines: current.lines,
+ });
+ };
+
+ for (const rawLine of diff.replace(/\r\n/g, "\n").split("\n")) {
+ if (rawLine.startsWith("diff --git ")) {
+ pushCurrent();
+ const match = rawLine.match(/^diff --git a\/(.+) b\/(.+)$/);
+ current = {
+ oldPath: match?.[1] ?? null,
+ newPath: match?.[2] ?? null,
+ lines: [],
+ };
+ oldLine = null;
+ newLine = null;
+ continue;
+ }
+
+ if (!current) {
+ if (rawLine.trim().length === 0) {
+ continue;
+ }
+ current = { oldPath: null, newPath: null, lines: [] };
+ }
+
+ const oldPath = parseDiffPath(rawLine, "--- ");
+ if (oldPath !== null || rawLine === "--- /dev/null") {
+ current.oldPath = oldPath;
+ continue;
+ }
+
+ const newPath = parseDiffPath(rawLine, "+++ ");
+ if (newPath !== null || rawLine === "+++ /dev/null") {
+ current.newPath = newPath;
+ continue;
+ }
+
+ const hunk = parseHunkStart(rawLine);
+ if (hunk) {
+ oldLine = hunk.oldLine;
+ newLine = hunk.newLine;
+ current.lines.push({
+ id: `${current.lines.length}:hunk`,
+ type: "hunk",
+ oldLine: null,
+ newLine: null,
+ content: rawLine,
+ });
+ continue;
+ }
+
+ if (oldLine === null || newLine === null) {
+ current.lines.push({
+ id: `${current.lines.length}:meta`,
+ type: "meta",
+ oldLine: null,
+ newLine: null,
+ content: rawLine,
+ });
+ continue;
+ }
+
+ const marker = rawLine[0];
+ const content = rawLine.length > 0 ? rawLine.slice(1) : "";
+ if (marker === "+") {
+ current.lines.push({
+ id: `${current.lines.length}:add:${newLine}`,
+ type: "add",
+ oldLine: null,
+ newLine,
+ content,
+ });
+ newLine += 1;
+ } else if (marker === "-") {
+ current.lines.push({
+ id: `${current.lines.length}:delete:${oldLine}`,
+ type: "delete",
+ oldLine,
+ newLine: null,
+ content,
+ });
+ oldLine += 1;
+ } else {
+ current.lines.push({
+ id: `${current.lines.length}:context:${oldLine}:${newLine}`,
+ type: "context",
+ oldLine,
+ newLine,
+ content: marker === " " ? content : rawLine,
+ });
+ oldLine += 1;
+ newLine += 1;
+ }
+ }
+
+ pushCurrent();
+ return files.filter((file) => file.lines.length > 0 || file.oldPath || file.newPath);
+}
diff --git a/apps/mobile/src/features/review/nativeReviewDiffAdapter.test.ts b/apps/mobile/src/features/review/nativeReviewDiffAdapter.test.ts
new file mode 100644
index 00000000000..b2d5cb5f71e
--- /dev/null
+++ b/apps/mobile/src/features/review/nativeReviewDiffAdapter.test.ts
@@ -0,0 +1,129 @@
+import { describe, expect, it } from "vitest";
+
+import { buildNativeReviewDiffData } from "./nativeReviewDiffAdapter";
+import { buildReviewParsedDiff } from "./reviewModel";
+
+describe("buildNativeReviewDiffData", () => {
+ it("maps real parsed file diffs into native rows with headers, hunks, lines, and notices", () => {
+ const parsed = buildReviewParsedDiff(
+ [
+ "diff --git a/apps/demo/src/main.ts b/apps/demo/src/main.ts",
+ "index 1111111..2222222 100644",
+ "--- a/apps/demo/src/main.ts",
+ "+++ b/apps/demo/src/main.ts",
+ "@@ -1,2 +1,2 @@",
+ "-const retryLimit = 2;",
+ "+const retryLimit = 4;",
+ " console.log(retryLimit);",
+ "diff --git a/apps/demo/src/old.ts b/apps/demo/src/new.ts",
+ "similarity index 100%",
+ "rename from apps/demo/src/old.ts",
+ "rename to apps/demo/src/new.ts",
+ "diff --git a/apps/demo/assets/review-logo.png b/apps/demo/assets/review-logo.png",
+ "new file mode 100644",
+ "index 0000000..1111111",
+ "Binary files /dev/null and b/apps/demo/assets/review-logo.png differ",
+ ].join("\n"),
+ "native-adapter-test",
+ );
+
+ const data = buildNativeReviewDiffData({
+ parsedDiff: parsed,
+ comments: [
+ {
+ id: "comment-1",
+ sectionId: "dirty",
+ sectionTitle: "Working tree",
+ filePath: "apps/demo/src/main.ts",
+ startIndex: 1,
+ endIndex: 1,
+ rangeLabel: "+2",
+ text: "Please keep this configurable.",
+ diff: "",
+ },
+ ],
+ });
+
+ expect(data.additions).toBe(1);
+ expect(data.deletions).toBe(1);
+ expect(data.files).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ path: "apps/demo/src/main.ts",
+ language: "typescript",
+ additions: 1,
+ deletions: 1,
+ }),
+ expect.objectContaining({
+ path: "apps/demo/src/new.ts",
+ language: "typescript",
+ additions: 0,
+ deletions: 0,
+ }),
+ ]),
+ );
+ expect(data.rows).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ kind: "file",
+ filePath: "apps/demo/src/main.ts",
+ changeType: "modified",
+ }),
+ expect.objectContaining({
+ kind: "hunk",
+ text: "@@ -1,2 +1,2 @@",
+ }),
+ expect.objectContaining({
+ kind: "line",
+ change: "delete",
+ content: "const retryLimit = 2;",
+ wordDiffRanges: [{ start: 19, end: 20 }],
+ }),
+ expect.objectContaining({
+ kind: "line",
+ change: "add",
+ content: "const retryLimit = 4;",
+ wordDiffRanges: [{ start: 19, end: 20 }],
+ }),
+ expect.objectContaining({
+ kind: "comment",
+ id: "comment-1",
+ filePath: "apps/demo/src/main.ts",
+ commentText: "Please keep this configurable.",
+ commentRangeLabel: "+2",
+ }),
+ expect.objectContaining({
+ kind: "file",
+ filePath: "apps/demo/src/new.ts",
+ previousPath: "apps/demo/src/old.ts",
+ changeType: "rename-pure",
+ }),
+ expect.objectContaining({
+ kind: "notice",
+ text: "This file was renamed without modifications.",
+ }),
+ expect.objectContaining({
+ kind: "notice",
+ text: "Unsupported format. Diff contents are not available.",
+ }),
+ ]),
+ );
+
+ const changedLine = data.rows.find(
+ (row) =>
+ row.kind === "line" && row.change === "add" && row.content === "const retryLimit = 4;",
+ );
+ expect(changedLine?.id).toBeTruthy();
+ const changedTarget = data.commentTargetsByRowId.get(changedLine?.id ?? "");
+ expect(changedTarget).toMatchObject({
+ filePath: "apps/demo/src/main.ts",
+ lineIndex: 1,
+ lines: expect.arrayContaining([
+ expect.objectContaining({ content: "const retryLimit = 2;" }),
+ expect.objectContaining({ content: "const retryLimit = 4;" }),
+ ]),
+ });
+ const changedCommentLine = changedTarget?.lines[changedTarget.lineIndex];
+ expect(data.rowIdByCommentLineId.get(changedCommentLine?.id ?? "")).toBe(changedLine?.id);
+ });
+});
diff --git a/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts b/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts
new file mode 100644
index 00000000000..66093fa3fd0
--- /dev/null
+++ b/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts
@@ -0,0 +1,426 @@
+import type { NativeReviewDiffRow, NativeReviewDiffTheme } from "../diffs/nativeReviewDiffSurface";
+import type {
+ NativeReviewDiffFile,
+ NativeReviewDiffLanguage,
+} from "../diffs/nativeReviewDiffTypes";
+import { getPierreTerminalTheme, type TerminalAppearanceScheme } from "../terminal/terminalTheme";
+import { computeWordAltDiffRanges } from "./reviewWordDiffs";
+import {
+ getReviewFilePreviewState,
+ type ReviewParsedDiff,
+ type ReviewRenderableFile,
+ type ReviewRenderableLineRow,
+} from "./reviewModel";
+import type { ReviewInlineComment } from "./reviewCommentSelection";
+
+const NATIVE_REVIEW_MAX_WORD_DIFF_RANGE_COUNT = 4;
+const NATIVE_REVIEW_MAX_WORD_DIFF_COVERAGE = 0.45;
+
+export const NATIVE_REVIEW_DIFF_ROW_HEIGHT = 20;
+export const NATIVE_REVIEW_DIFF_CONTENT_WIDTH = 2_800;
+
+export const NATIVE_REVIEW_DIFF_STYLE = {
+ rowHeight: NATIVE_REVIEW_DIFF_ROW_HEIGHT,
+ contentWidth: NATIVE_REVIEW_DIFF_CONTENT_WIDTH,
+ changeBarWidth: 4,
+ gutterWidth: 46,
+ codePadding: 7,
+ textVerticalInset: 2,
+ fileHeaderHeight: 56,
+ fileHeaderHorizontalMargin: 8,
+ fileHeaderVerticalMargin: 6,
+ fileHeaderCornerRadius: 10,
+ fileHeaderHorizontalPadding: 10,
+ fileHeaderPathRightPadding: 118,
+ fileHeaderCountColumnWidth: 38,
+ fileHeaderCountGap: 5,
+ codeFontSize: 11,
+ codeFontWeight: "regular",
+ lineNumberFontSize: 10,
+ lineNumberFontWeight: "regular",
+ hunkFontSize: 11,
+ hunkFontWeight: "medium",
+ fileHeaderFontSize: 11,
+ fileHeaderFontWeight: "semibold",
+ fileHeaderMetaFontSize: 10,
+ fileHeaderMetaFontWeight: "semibold",
+ fileHeaderSubtextFontSize: 11,
+ fileHeaderSubtextFontWeight: "medium",
+ fileHeaderStatusFontSize: 9,
+ fileHeaderStatusFontWeight: "bold",
+ emptyStateFontSize: 12,
+ emptyStateFontWeight: "medium",
+} as const;
+
+export interface NativeReviewDiffData {
+ readonly rows: ReadonlyArray;
+ readonly files: ReadonlyArray;
+ readonly commentTargetsByRowId: ReadonlyMap;
+ readonly rowIdByCommentLineId: ReadonlyMap;
+ readonly additions: number;
+ readonly deletions: number;
+}
+
+export interface NativeReviewDiffCommentTarget {
+ readonly filePath: string;
+ readonly lines: ReadonlyArray;
+ readonly lineIndex: number;
+}
+
+export interface BuildNativeReviewDiffDataInput {
+ readonly parsedDiff: ReviewParsedDiff;
+ readonly comments?: ReadonlyArray;
+}
+
+export function createNativeReviewDiffTheme(
+ scheme: TerminalAppearanceScheme,
+): NativeReviewDiffTheme {
+ const terminalTheme = getPierreTerminalTheme(scheme);
+ const [, terminalRed, , , terminalBlue] = terminalTheme.palette;
+
+ if (scheme === "dark") {
+ return {
+ background: terminalTheme.background,
+ text: terminalTheme.foreground,
+ mutedText: terminalTheme.mutedForeground,
+ headerBackground: terminalTheme.background,
+ border: terminalTheme.border,
+ hunkBackground: "#071f28",
+ hunkText: terminalBlue ?? "#009fff",
+ addBackground: "#0d2f28",
+ deleteBackground: "#391415",
+ addBar: "#00cab1",
+ deleteBar: terminalRed ?? "#ff2e3f",
+ addText: "#5ECC71",
+ deleteText: "#FF6762",
+ };
+ }
+
+ return {
+ background: "#ffffff",
+ text: "#070707",
+ mutedText: terminalTheme.mutedForeground,
+ headerBackground: "#ffffff",
+ border: terminalTheme.border,
+ hunkBackground: "#e0f2ff",
+ hunkText: terminalBlue ?? "#009fff",
+ addBackground: "#e5f8f5",
+ deleteBackground: "#ffe6e7",
+ addBar: "#00cab1",
+ deleteBar: terminalRed ?? "#ff2e3f",
+ addText: "#199F43",
+ deleteText: "#D52C36",
+ };
+}
+
+function mapChangeType(file: ReviewRenderableFile): NativeReviewDiffRow["changeType"] {
+ switch (file.changeType) {
+ case "change":
+ return "modified";
+ case "new":
+ case "deleted":
+ case "rename-pure":
+ case "rename-changed":
+ return file.changeType;
+ default:
+ return "modified";
+ }
+}
+
+function getLanguageForPath(
+ filePath: string,
+ languageHint: string | null,
+): NativeReviewDiffLanguage {
+ const hinted = languageHint?.toLowerCase();
+ if (hinted === "typescript" || hinted === "tsx" || hinted === "javascript" || hinted === "jsx") {
+ return hinted;
+ }
+ if (hinted === "json" || hinted === "yaml" || hinted === "bash" || hinted === "diff") {
+ return hinted;
+ }
+
+ const normalizedPath = filePath.toLowerCase();
+ if (normalizedPath.endsWith(".tsx")) return "tsx";
+ if (normalizedPath.endsWith(".ts")) return "typescript";
+ if (normalizedPath.endsWith(".jsx")) return "jsx";
+ if (normalizedPath.endsWith(".js") || normalizedPath.endsWith(".cjs")) return "javascript";
+ if (normalizedPath.endsWith(".json") || normalizedPath.endsWith(".jsonc")) return "json";
+ if (normalizedPath.endsWith(".yml") || normalizedPath.endsWith(".yaml")) return "yaml";
+ if (
+ normalizedPath.endsWith(".sh") ||
+ normalizedPath.includes("/bin/") ||
+ normalizedPath.includes("shell")
+ ) {
+ return "bash";
+ }
+ return "diff";
+}
+
+function createNoticeRow(fileId: string, suffix: string, text: string): NativeReviewDiffRow {
+ return {
+ kind: "notice",
+ id: `${fileId}:notice:${suffix}`,
+ fileId,
+ text,
+ };
+}
+
+function noticeRowsForFile(file: ReviewRenderableFile): ReadonlyArray {
+ if (file.rows.length > 0) {
+ return [];
+ }
+
+ const previewState = getReviewFilePreviewState(file);
+ if (previewState.kind === "suppressed" && previewState.reason === "non-text") {
+ return [
+ createNoticeRow(file.id, "non-text", "Unsupported format. Diff contents are not available."),
+ ];
+ }
+
+ if (file.changeType === "rename-pure") {
+ return [createNoticeRow(file.id, "rename", "This file was renamed without modifications.")];
+ }
+
+ return [];
+}
+
+function trimWordDiffRanges(
+ content: string,
+ ranges: NonNullable,
+): NonNullable {
+ return ranges.flatMap((range) => {
+ let start = Math.max(0, range.start);
+ let end = Math.min(content.length, range.end);
+
+ while (start < end && /\s/.test(content[start] ?? "")) {
+ start += 1;
+ }
+ while (end > start && /\s/.test(content[end - 1] ?? "")) {
+ end -= 1;
+ }
+
+ return end > start ? [{ start, end }] : [];
+ });
+}
+
+function nonWhitespaceLength(value: string) {
+ return value.replace(/\s/g, "").length;
+}
+
+function shouldUseWordDiffRanges(
+ content: string,
+ ranges: NonNullable,
+) {
+ if (ranges.length === 0 || ranges.length > NATIVE_REVIEW_MAX_WORD_DIFF_RANGE_COUNT) {
+ return false;
+ }
+
+ const meaningfulLength = nonWhitespaceLength(content);
+ if (meaningfulLength === 0) {
+ return false;
+ }
+
+ const highlightedLength = ranges.reduce(
+ (total, range) => total + nonWhitespaceLength(content.slice(range.start, range.end)),
+ 0,
+ );
+ return highlightedLength / meaningfulLength <= NATIVE_REVIEW_MAX_WORD_DIFF_COVERAGE;
+}
+
+function addNativeWordDiffRanges(
+ rows: ReadonlyArray,
+): ReadonlyArray {
+ const nextRows = [...rows];
+ let index = 0;
+
+ while (index < nextRows.length) {
+ const deletedRowIndexes: number[] = [];
+ const addedRowIndexes: number[] = [];
+ const fileId = nextRows[index]?.fileId;
+
+ while (
+ nextRows[index]?.kind === "line" &&
+ nextRows[index]?.change === "delete" &&
+ nextRows[index]?.fileId === fileId
+ ) {
+ deletedRowIndexes.push(index);
+ index += 1;
+ }
+
+ while (
+ nextRows[index]?.kind === "line" &&
+ nextRows[index]?.change === "add" &&
+ nextRows[index]?.fileId === fileId
+ ) {
+ addedRowIndexes.push(index);
+ index += 1;
+ }
+
+ const pairedCount = Math.min(deletedRowIndexes.length, addedRowIndexes.length);
+ for (let pairIndex = 0; pairIndex < pairedCount; pairIndex += 1) {
+ const deletedRowIndex = deletedRowIndexes[pairIndex];
+ const addedRowIndex = addedRowIndexes[pairIndex];
+ const deletedRow = nextRows[deletedRowIndex];
+ const addedRow = nextRows[addedRowIndex];
+ if (!deletedRow?.content || !addedRow?.content) {
+ continue;
+ }
+
+ const ranges = computeWordAltDiffRanges({
+ deletionLine: deletedRow.content,
+ additionLine: addedRow.content,
+ });
+ const deletionRanges = trimWordDiffRanges(deletedRow.content, ranges.deletion);
+ const additionRanges = trimWordDiffRanges(addedRow.content, ranges.addition);
+
+ if (shouldUseWordDiffRanges(deletedRow.content, deletionRanges)) {
+ nextRows[deletedRowIndex] = { ...deletedRow, wordDiffRanges: deletionRanges };
+ }
+ if (shouldUseWordDiffRanges(addedRow.content, additionRanges)) {
+ nextRows[addedRowIndex] = { ...addedRow, wordDiffRanges: additionRanges };
+ }
+ }
+
+ if (deletedRowIndexes.length === 0 && addedRowIndexes.length === 0) {
+ index += 1;
+ }
+ }
+
+ return nextRows;
+}
+
+function mapLineRow(
+ file: ReviewRenderableFile,
+ row: ReviewRenderableLineRow,
+ rowIndex: number,
+): NativeReviewDiffRow {
+ return {
+ kind: "line",
+ id: `${file.id}:line:${rowIndex}:${row.id}`,
+ fileId: file.id,
+ content: row.content,
+ change: row.change,
+ oldLineNumber: row.oldLineNumber,
+ newLineNumber: row.newLineNumber,
+ };
+}
+
+function mapFileRows(
+ file: ReviewRenderableFile,
+ comments: ReadonlyArray,
+ commentTargetsByRowId: Map,
+ rowIdByCommentLineId: Map,
+): ReadonlyArray {
+ const rows: NativeReviewDiffRow[] = [
+ {
+ kind: "file",
+ id: `${file.id}:header`,
+ fileId: file.id,
+ filePath: file.path,
+ previousPath: file.previousPath,
+ changeType: mapChangeType(file),
+ additions: file.additions,
+ deletions: file.deletions,
+ },
+ ];
+
+ const lineRows = file.rows.filter((row): row is ReviewRenderableLineRow => row.kind === "line");
+ const commentsByEndIndex = new Map();
+ comments.forEach((comment) => {
+ if (comment.filePath !== file.path) {
+ return;
+ }
+ const endIndex = Math.min(comment.endIndex, lineRows.length - 1);
+ if (endIndex < 0) {
+ return;
+ }
+ const existing = commentsByEndIndex.get(endIndex);
+ if (existing) {
+ existing.push(comment);
+ return;
+ }
+ commentsByEndIndex.set(endIndex, [comment]);
+ });
+ let lineIndex = 0;
+ file.rows.forEach((row, rowIndex) => {
+ if (row.kind === "hunk") {
+ rows.push({
+ kind: "hunk",
+ id: `${file.id}:hunk:${rowIndex}:${row.id}`,
+ fileId: file.id,
+ text: row.context ? `${row.header} ${row.context}` : row.header,
+ });
+ return;
+ }
+
+ const nativeRow = mapLineRow(file, row, rowIndex);
+ rows.push(nativeRow);
+ rowIdByCommentLineId.set(row.id, nativeRow.id);
+ commentTargetsByRowId.set(nativeRow.id, {
+ filePath: file.path,
+ lines: lineRows,
+ lineIndex,
+ });
+ const commentsForLine = commentsByEndIndex.get(lineIndex) ?? [];
+ for (const comment of commentsForLine) {
+ rows.push({
+ kind: "comment",
+ id: comment.id,
+ fileId: file.id,
+ filePath: file.path,
+ commentText: comment.text,
+ commentRangeLabel: comment.rangeLabel,
+ commentSectionTitle: comment.sectionTitle,
+ });
+ }
+ lineIndex += 1;
+ });
+
+ rows.push(...noticeRowsForFile(file));
+ return rows;
+}
+
+export function buildNativeReviewDiffData(
+ input: BuildNativeReviewDiffDataInput,
+): NativeReviewDiffData;
+export function buildNativeReviewDiffData(parsedDiff: ReviewParsedDiff): NativeReviewDiffData;
+export function buildNativeReviewDiffData(
+ input: ReviewParsedDiff | BuildNativeReviewDiffDataInput,
+): NativeReviewDiffData {
+ const parsedDiff = "parsedDiff" in input ? input.parsedDiff : input;
+ const comments = "parsedDiff" in input ? (input.comments ?? []) : [];
+ if (parsedDiff.kind !== "files") {
+ return {
+ rows: [],
+ files: [],
+ commentTargetsByRowId: new Map(),
+ rowIdByCommentLineId: new Map(),
+ additions: 0,
+ deletions: 0,
+ };
+ }
+
+ const files = parsedDiff.files.map((file) => ({
+ id: file.id,
+ path: file.path,
+ language: getLanguageForPath(file.path, file.languageHint),
+ additions: file.additions,
+ deletions: file.deletions,
+ }));
+ const commentTargetsByRowId = new Map();
+ const rowIdByCommentLineId = new Map();
+ const rows = addNativeWordDiffRanges(
+ parsedDiff.files.flatMap((file) =>
+ mapFileRows(file, comments, commentTargetsByRowId, rowIdByCommentLineId),
+ ),
+ );
+
+ return {
+ rows,
+ files,
+ commentTargetsByRowId,
+ rowIdByCommentLineId,
+ additions: parsedDiff.additions,
+ deletions: parsedDiff.deletions,
+ };
+}
diff --git a/apps/mobile/src/features/review/reviewCommentSelection.test.ts b/apps/mobile/src/features/review/reviewCommentSelection.test.ts
new file mode 100644
index 00000000000..a9bcde48baa
--- /dev/null
+++ b/apps/mobile/src/features/review/reviewCommentSelection.test.ts
@@ -0,0 +1,81 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ countReviewCommentContexts,
+ formatReviewCommentContext,
+ parseReviewCommentMessageSegments,
+ parseReviewInlineComments,
+ type ReviewCommentTarget,
+} from "./reviewCommentSelection";
+
+function makeTarget(): ReviewCommentTarget {
+ return {
+ sectionId: "section-1",
+ sectionTitle: "Working tree",
+ filePath: "apps/demo/src/main.ts",
+ startIndex: 0,
+ endIndex: 1,
+ lines: [
+ {
+ kind: "line",
+ id: "line-1",
+ change: "delete",
+ oldLineNumber: 7,
+ newLineNumber: null,
+ content: "const retryLimit = 2;",
+ additionTokenIndex: null,
+ deletionTokenIndex: 0,
+ comparison: null,
+ },
+ {
+ kind: "line",
+ id: "line-2",
+ change: "add",
+ oldLineNumber: null,
+ newLineNumber: 7,
+ content: "const retryLimit = 4;",
+ additionTokenIndex: 0,
+ deletionTokenIndex: null,
+ comparison: null,
+ },
+ ],
+ };
+}
+
+describe("review comment serialization", () => {
+ it("preserves enough metadata for inline diff rendering", () => {
+ const serialized = formatReviewCommentContext(makeTarget(), "Please keep this configurable.");
+
+ expect(countReviewCommentContexts(serialized)).toBe(1);
+ expect(parseReviewInlineComments(serialized)).toEqual([
+ expect.objectContaining({
+ sectionId: "section-1",
+ sectionTitle: "Working tree",
+ filePath: "apps/demo/src/main.ts",
+ startIndex: 0,
+ endIndex: 1,
+ text: "Please keep this configurable.",
+ diff: expect.stringContaining("-const retryLimit = 2;"),
+ }),
+ ]);
+ });
+
+ it("splits chat text into review comment segments", () => {
+ const serialized = `Before\n${formatReviewCommentContext(makeTarget(), "Please keep this configurable.")}\nAfter`;
+ const segments = parseReviewCommentMessageSegments(serialized);
+
+ expect(segments).toHaveLength(3);
+ expect(segments[0]).toEqual(expect.objectContaining({ kind: "text", text: "Before\n" }));
+ expect(segments[1]).toEqual(
+ expect.objectContaining({
+ kind: "review-comment",
+ comment: expect.objectContaining({
+ filePath: "apps/demo/src/main.ts",
+ text: "Please keep this configurable.",
+ diff: expect.stringContaining("+const retryLimit = 4;"),
+ }),
+ }),
+ );
+ expect(segments[2]).toEqual(expect.objectContaining({ kind: "text", text: "\nAfter" }));
+ });
+});
diff --git a/apps/mobile/src/features/review/reviewCommentSelection.ts b/apps/mobile/src/features/review/reviewCommentSelection.ts
new file mode 100644
index 00000000000..09e1927d179
--- /dev/null
+++ b/apps/mobile/src/features/review/reviewCommentSelection.ts
@@ -0,0 +1,317 @@
+import { useSyncExternalStore } from "react";
+
+import type { ReviewRenderableLineRow } from "./reviewModel";
+
+export interface ReviewCommentTarget {
+ readonly sectionId: string;
+ readonly sectionTitle: string;
+ readonly filePath: string;
+ readonly lines: ReadonlyArray;
+ readonly startIndex: number;
+ readonly endIndex: number;
+}
+
+export interface ReviewInlineComment {
+ readonly id: string;
+ readonly sectionId: string;
+ readonly sectionTitle: string;
+ readonly filePath: string;
+ readonly startIndex: number;
+ readonly endIndex: number;
+ readonly rangeLabel: string;
+ readonly text: string;
+ readonly diff: string;
+}
+
+export type ReviewCommentMessageSegment =
+ | {
+ readonly kind: "text";
+ readonly id: string;
+ readonly text: string;
+ }
+ | {
+ readonly kind: "review-comment";
+ readonly comment: ReviewInlineComment;
+ };
+
+let currentTarget: ReviewCommentTarget | null = null;
+const listeners = new Set<() => void>();
+const REVIEW_COMMENT_BLOCK_PATTERN = /]*)>\s*([\s\S]*?)<\/review_comment>/g;
+const REVIEW_COMMENT_ATTRIBUTE_PATTERN = /([a-zA-Z][a-zA-Z0-9_-]*)="([^"]*)"/g;
+
+function emitChange() {
+ listeners.forEach((listener) => listener());
+}
+
+export function subscribeReviewCommentTarget(listener: () => void): () => void {
+ listeners.add(listener);
+ return () => {
+ listeners.delete(listener);
+ };
+}
+
+export function getReviewCommentTarget(): ReviewCommentTarget | null {
+ return currentTarget;
+}
+
+export function setReviewCommentTarget(target: ReviewCommentTarget | null) {
+ currentTarget = target;
+ emitChange();
+}
+
+export function clearReviewCommentTarget() {
+ currentTarget = null;
+ emitChange();
+}
+
+export function useReviewCommentTarget(): ReviewCommentTarget | null {
+ return useSyncExternalStore(
+ subscribeReviewCommentTarget,
+ getReviewCommentTarget,
+ getReviewCommentTarget,
+ );
+}
+
+export function getSelectedReviewCommentLines(
+ target: ReviewCommentTarget,
+): ReadonlyArray {
+ return target.lines.slice(target.startIndex, target.endIndex + 1);
+}
+
+export function getReviewUnifiedLineNumber(line: ReviewRenderableLineRow): number | null {
+ return line.newLineNumber ?? line.oldLineNumber;
+}
+
+export function formatReviewLineLabel(line: ReviewRenderableLineRow): string {
+ if (line.newLineNumber !== null) {
+ return `new line ${line.newLineNumber}`;
+ }
+ if (line.oldLineNumber !== null) {
+ return `old line ${line.oldLineNumber}`;
+ }
+ return "file";
+}
+
+export function getReviewChangeMarker(change: ReviewRenderableLineRow["change"]): string {
+ if (change === "add") return "+";
+ if (change === "delete") return "-";
+ return " ";
+}
+
+export function buildReviewCommentTarget(
+ target: Pick,
+ anchorIndex: number,
+ lineIndex: number,
+): ReviewCommentTarget {
+ return {
+ sectionId: target.sectionId,
+ sectionTitle: target.sectionTitle,
+ filePath: target.filePath,
+ lines: target.lines,
+ startIndex: Math.min(anchorIndex, lineIndex),
+ endIndex: Math.max(anchorIndex, lineIndex),
+ };
+}
+
+export function formatReviewSelectedRangeLabel(target: ReviewCommentTarget): string {
+ const lines = getSelectedReviewCommentLines(target);
+ const firstLine = lines[0]!;
+ const lastLine = lines[lines.length - 1]!;
+ const firstNumber = getReviewUnifiedLineNumber(firstLine);
+ const lastNumber = getReviewUnifiedLineNumber(lastLine);
+
+ if (firstNumber === null || lastNumber === null) {
+ return lines.length === 1 ? "line" : `${lines.length} lines`;
+ }
+
+ const firstMarker = getReviewChangeMarker(firstLine.change).trim();
+ const consistentMarker =
+ lines.every((line) => line.change === firstLine.change) && firstMarker.length > 0
+ ? getReviewChangeMarker(firstLine.change)
+ : "";
+
+ if (firstNumber === lastNumber) {
+ return `${consistentMarker}${firstNumber}`;
+ }
+
+ return `${consistentMarker}${firstNumber} to ${consistentMarker}${lastNumber}`;
+}
+
+function getDiffHunkRange(
+ selectedLines: ReadonlyArray,
+ key: "oldLineNumber" | "newLineNumber",
+): {
+ readonly start: number;
+ readonly count: number;
+} {
+ const numberedLines = selectedLines.filter((line) => line[key] !== null);
+ if (numberedLines.length === 0) {
+ return { start: 0, count: 0 };
+ }
+
+ return {
+ start: numberedLines[0]![key] ?? 0,
+ count: numberedLines.length,
+ };
+}
+
+function formatReviewSelectedDiff(target: ReviewCommentTarget): string {
+ const selectedLines = getSelectedReviewCommentLines(target);
+ const oldRange = getDiffHunkRange(selectedLines, "oldLineNumber");
+ const newRange = getDiffHunkRange(selectedLines, "newLineNumber");
+ const diffBody = selectedLines
+ .map((line) => `${getReviewChangeMarker(line.change)}${line.content}`)
+ .join("\n");
+
+ return [
+ `@@ -${oldRange.start},${oldRange.count} +${newRange.start},${newRange.count} @@`,
+ diffBody.length > 0 ? diffBody : " ",
+ ].join("\n");
+}
+
+function escapeReviewCommentAttribute(value: string): string {
+ return value.replace(/&/g, "&").replace(/"/g, """).replace(/ {
+ const attributes: Record = {};
+ for (const match of rawAttributes.matchAll(REVIEW_COMMENT_ATTRIBUTE_PATTERN)) {
+ attributes[match[1]!] = unescapeReviewCommentAttribute(match[2] ?? "");
+ }
+ return attributes;
+}
+
+function readNonNegativeInteger(value: string | undefined): number | null {
+ if (value === undefined || !/^\d+$/.test(value)) {
+ return null;
+ }
+ return Number(value);
+}
+
+function extractReviewCommentText(rawBody: string): string {
+ const fenceIndex = rawBody.indexOf("```diff");
+ const commentBody = fenceIndex >= 0 ? rawBody.slice(0, fenceIndex) : rawBody;
+ return commentBody.trim();
+}
+
+function extractReviewCommentDiff(rawBody: string): string {
+ const match = rawBody.match(/```diff\s*\n([\s\S]*?)\n```/);
+ return match?.[1]?.trim() ?? "";
+}
+
+function parseReviewInlineComment(
+ rawAttributes: string,
+ rawBody: string,
+ index: number,
+): ReviewInlineComment | null {
+ const attributes = readReviewCommentAttributes(rawAttributes);
+ const startIndex = readNonNegativeInteger(attributes.startIndex);
+ const endIndex = readNonNegativeInteger(attributes.endIndex);
+ const filePath = attributes.filePath?.trim();
+ const sectionId = attributes.sectionId?.trim();
+ if (!filePath || !sectionId || startIndex === null || endIndex === null) {
+ return null;
+ }
+
+ return {
+ id: `review-comment:${index}:${sectionId}:${filePath}:${startIndex}:${endIndex}`,
+ sectionId,
+ sectionTitle: attributes.sectionTitle?.trim() || "Review",
+ filePath,
+ startIndex: Math.min(startIndex, endIndex),
+ endIndex: Math.max(startIndex, endIndex),
+ rangeLabel: attributes.rangeLabel?.trim() || "line",
+ text: extractReviewCommentText(rawBody),
+ diff: extractReviewCommentDiff(rawBody),
+ };
+}
+
+export function formatReviewCommentContext(target: ReviewCommentTarget, comment: string): string {
+ const rangeLabel = formatReviewSelectedRangeLabel(target);
+ return [
+ [
+ "",
+ ].join(""),
+ comment.trim(),
+ "```diff",
+ formatReviewSelectedDiff(target),
+ "```",
+ "",
+ ].join("\n");
+}
+
+export function countReviewCommentContexts(value: string): number {
+ return Array.from(value.matchAll(/ {
+ const comments: ReviewInlineComment[] = [];
+ for (const [index, match] of Array.from(value.matchAll(REVIEW_COMMENT_BLOCK_PATTERN)).entries()) {
+ const comment = parseReviewInlineComment(match[1] ?? "", match[2] ?? "", index);
+ if (!comment) {
+ continue;
+ }
+
+ comments.push(comment);
+ }
+ return comments;
+}
+
+export function parseReviewCommentMessageSegments(
+ value: string,
+): ReadonlyArray {
+ const segments: ReviewCommentMessageSegment[] = [];
+ let cursor = 0;
+ let parsedCommentIndex = 0;
+
+ for (const match of value.matchAll(REVIEW_COMMENT_BLOCK_PATTERN)) {
+ const matchIndex = match.index ?? 0;
+ const beforeText = value.slice(cursor, matchIndex);
+ if (beforeText.length > 0) {
+ segments.push({
+ kind: "text",
+ id: `review-comment-text:${cursor}`,
+ text: beforeText,
+ });
+ }
+
+ const comment = parseReviewInlineComment(match[1] ?? "", match[2] ?? "", parsedCommentIndex);
+ if (comment) {
+ segments.push({ kind: "review-comment", comment });
+ parsedCommentIndex += 1;
+ } else {
+ segments.push({
+ kind: "text",
+ id: `review-comment-invalid:${matchIndex}`,
+ text: match[0],
+ });
+ }
+
+ cursor = matchIndex + match[0].length;
+ }
+
+ const rest = value.slice(cursor);
+ if (rest.length > 0) {
+ segments.push({
+ kind: "text",
+ id: `review-comment-text:${cursor}`,
+ text: rest,
+ });
+ }
+
+ return segments;
+}
diff --git a/apps/mobile/src/features/review/reviewDiffPreviewState.ts b/apps/mobile/src/features/review/reviewDiffPreviewState.ts
new file mode 100644
index 00000000000..d0f85cd6d89
--- /dev/null
+++ b/apps/mobile/src/features/review/reviewDiffPreviewState.ts
@@ -0,0 +1,110 @@
+import { useAtomValue } from "@effect/atom-react";
+import type { EnvironmentId, ReviewDiffPreviewResult } from "@t3tools/contracts";
+import * as Cause from "effect/Cause";
+import * as Effect from "effect/Effect";
+import * as Option from "effect/Option";
+import { AsyncResult, Atom } from "effect/unstable/reactivity";
+import { useCallback, useMemo } from "react";
+
+import { appAtomRegistry } from "../../state/atom-registry";
+import { getEnvironmentClient } from "../../state/environment-session-registry";
+
+const REVIEW_DIFF_PREVIEW_STALE_TIME_MS = 5_000;
+const REVIEW_DIFF_PREVIEW_IDLE_TTL_MS = 5 * 60_000;
+const REVIEW_DIFF_PREVIEW_KEY_SEPARATOR = "\u001f";
+
+export interface ReviewDiffPreviewState {
+ readonly data: ReviewDiffPreviewResult | null;
+ readonly error: string | null;
+ readonly isPending: boolean;
+ readonly refresh: () => void;
+}
+
+function makeReviewDiffPreviewKey(input: {
+ readonly environmentId: EnvironmentId;
+ readonly cwd: string;
+}): string {
+ return `${input.environmentId}${REVIEW_DIFF_PREVIEW_KEY_SEPARATOR}${input.cwd}`;
+}
+
+function parseReviewDiffPreviewKey(key: string): {
+ readonly environmentId: EnvironmentId;
+ readonly cwd: string;
+} {
+ const [environmentId, cwd = ""] = key.split(REVIEW_DIFF_PREVIEW_KEY_SEPARATOR);
+ return {
+ environmentId: environmentId as EnvironmentId,
+ cwd,
+ };
+}
+
+const reviewDiffPreviewAtom = Atom.family((key: string) =>
+ Atom.make(
+ Effect.promise(async (): Promise => {
+ const target = parseReviewDiffPreviewKey(key);
+ const client = getEnvironmentClient(target.environmentId);
+ if (!client) {
+ throw new Error("Remote connection is not ready.");
+ }
+ return client.review.getDiffPreview({ cwd: target.cwd });
+ }),
+ ).pipe(
+ Atom.swr({
+ staleTime: REVIEW_DIFF_PREVIEW_STALE_TIME_MS,
+ revalidateOnMount: true,
+ }),
+ Atom.setIdleTTL(REVIEW_DIFF_PREVIEW_IDLE_TTL_MS),
+ Atom.withLabel(`mobile:review:diff-preview:${key}`),
+ ),
+);
+
+const EMPTY_REVIEW_DIFF_PREVIEW_RESULT_ATOM = Atom.make(
+ AsyncResult.initial(false),
+).pipe(Atom.keepAlive, Atom.withLabel("mobile:review:diff-preview:null"));
+
+function readReviewDiffPreviewError(
+ result: AsyncResult.AsyncResult,
+): string | null {
+ if (result._tag !== "Failure") {
+ return null;
+ }
+
+ const error = Cause.squash(result.cause);
+ return error instanceof Error ? error.message : "Failed to load review diffs.";
+}
+
+export function useReviewDiffPreview(input: {
+ readonly environmentId?: EnvironmentId;
+ readonly cwd: string | null;
+}): ReviewDiffPreviewState {
+ const key = useMemo(() => {
+ if (!input.environmentId || !input.cwd) {
+ return null;
+ }
+ return makeReviewDiffPreviewKey({ environmentId: input.environmentId, cwd: input.cwd });
+ }, [input.cwd, input.environmentId]);
+
+ const atom = key ? reviewDiffPreviewAtom(key) : null;
+ const result = useAtomValue(atom ?? EMPTY_REVIEW_DIFF_PREVIEW_RESULT_ATOM);
+ const refresh = useCallback(() => {
+ if (atom) {
+ appAtomRegistry.refresh(atom);
+ }
+ }, [atom]);
+
+ if (!atom) {
+ return {
+ data: null,
+ error: null,
+ isPending: false,
+ refresh,
+ };
+ }
+
+ return {
+ data: Option.getOrNull(AsyncResult.value(result)),
+ error: readReviewDiffPreviewError(result),
+ isPending: result.waiting,
+ refresh,
+ };
+}
diff --git a/apps/mobile/src/features/review/reviewDiffRendering.tsx b/apps/mobile/src/features/review/reviewDiffRendering.tsx
new file mode 100644
index 00000000000..3f2ae01609e
--- /dev/null
+++ b/apps/mobile/src/features/review/reviewDiffRendering.tsx
@@ -0,0 +1,128 @@
+import { Platform, Text as NativeText, View } from "react-native";
+
+import { cn } from "../../lib/cn";
+
+import type { ReviewRenderableLineRow } from "./reviewModel";
+import type { ReviewHighlightedToken } from "./shikiReviewHighlighter";
+
+export const REVIEW_MONO_FONT_FAMILY = Platform.select({
+ ios: "ui-monospace",
+ android: "monospace",
+ default: "monospace",
+});
+
+export const REVIEW_DIFF_LINE_HEIGHT = 26;
+const REVIEW_DELETE_STRIPE_COUNT = REVIEW_DIFF_LINE_HEIGHT / 2;
+
+export function renderVisibleWhitespace(value: string): string {
+ const expandedTabs = value.replace(/\t/g, " ");
+ return expandedTabs.replace(/^( +)/, (leading) => leading.replaceAll(" ", "\u00A0"));
+}
+
+export function changeTone(change: ReviewRenderableLineRow["change"]): string {
+ if (change === "add") return "bg-emerald-500/10";
+ if (change === "delete") return "bg-rose-500/10";
+ return "bg-card";
+}
+
+export function changeBarTone(change: ReviewRenderableLineRow["change"]): string {
+ if (change === "add") return "bg-emerald-400";
+ if (change === "delete") return "bg-rose-400";
+ return "bg-border/50";
+}
+
+function diffHighlightColor(change: ReviewRenderableLineRow["change"]): string | undefined {
+ if (change === "add") return "rgba(16, 185, 129, 0.24)";
+ if (change === "delete") return "rgba(244, 63, 94, 0.24)";
+ return undefined;
+}
+
+export function ReviewChangeBar(props: { readonly change: ReviewRenderableLineRow["change"] }) {
+ if (props.change === "delete") {
+ return (
+
+
+ {Array.from({ length: REVIEW_DELETE_STRIPE_COUNT }, (_, index) => (
+
+
+
+
+ ))}
+
+
+ );
+ }
+
+ return (
+
+
+
+ );
+}
+
+export function DiffTokenText(props: {
+ readonly tokens: ReadonlyArray | null;
+ readonly fallback: string;
+ readonly change?: ReviewRenderableLineRow["change"];
+ readonly className?: string;
+}) {
+ if (!props.tokens || props.tokens.length === 0) {
+ return (
+
+ {renderVisibleWhitespace(props.fallback || " ")}
+
+ );
+ }
+
+ return (
+
+ {(() => {
+ let offset = 0;
+
+ return props.tokens.map((token) => {
+ const start = offset;
+ offset += token.content.length;
+
+ const fontWeight =
+ token.fontStyle !== null && (token.fontStyle & 2) === 2
+ ? ("700" as const)
+ : ("500" as const);
+ const fontStyle =
+ token.fontStyle !== null && (token.fontStyle & 1) === 1
+ ? ("italic" as const)
+ : ("normal" as const);
+
+ return (
+
+ {token.content.length > 0 ? renderVisibleWhitespace(token.content) : " "}
+
+ );
+ });
+ })()}
+
+ );
+}
diff --git a/apps/mobile/src/features/review/reviewFileVisibility.test.ts b/apps/mobile/src/features/review/reviewFileVisibility.test.ts
new file mode 100644
index 00000000000..4e7cb1c2002
--- /dev/null
+++ b/apps/mobile/src/features/review/reviewFileVisibility.test.ts
@@ -0,0 +1,50 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ getDefaultReviewExpandedFileIds,
+ getValidExplicitReviewFileIds,
+ getValidReviewFileIds,
+ removeReviewFileId,
+ toggleReviewFileId,
+} from "./reviewFileVisibility";
+import type { ReviewRenderableFile } from "./reviewModel";
+
+function makeFile(id: string): ReviewRenderableFile {
+ return {
+ id,
+ cacheKey: id,
+ path: id,
+ previousPath: null,
+ changeType: "change",
+ additions: 0,
+ deletions: 0,
+ languageHint: null,
+ additionLines: [],
+ deletionLines: [],
+ rows: [],
+ };
+}
+
+describe("review file visibility", () => {
+ const files = [makeFile("a.ts"), makeFile("b.ts")];
+
+ it("defaults expanded files to every renderable file", () => {
+ expect(getDefaultReviewExpandedFileIds(files)).toEqual(["a.ts", "b.ts"]);
+ expect(getValidReviewFileIds(files, undefined)).toEqual(["a.ts", "b.ts"]);
+ });
+
+ it("filters stale cached file ids", () => {
+ expect(getValidReviewFileIds(files, ["missing.ts", "b.ts"])).toEqual(["b.ts"]);
+ expect(getValidExplicitReviewFileIds(files, undefined)).toEqual([]);
+ expect(getValidExplicitReviewFileIds(files, ["a.ts", "missing.ts"])).toEqual(["a.ts"]);
+ });
+
+ it("toggles and removes ids without mutating the original array", () => {
+ const original = ["a.ts"];
+
+ expect(toggleReviewFileId(original, "b.ts")).toEqual(["a.ts", "b.ts"]);
+ expect(toggleReviewFileId(original, "a.ts")).toEqual([]);
+ expect(removeReviewFileId(original, "a.ts")).toEqual([]);
+ expect(original).toEqual(["a.ts"]);
+ });
+});
diff --git a/apps/mobile/src/features/review/reviewFileVisibility.ts b/apps/mobile/src/features/review/reviewFileVisibility.ts
new file mode 100644
index 00000000000..53f2d7f5f95
--- /dev/null
+++ b/apps/mobile/src/features/review/reviewFileVisibility.ts
@@ -0,0 +1,116 @@
+import { useCallback, useMemo } from "react";
+
+import { updateReviewExpandedFileIds, updateReviewViewedFileIds } from "./reviewState";
+import type { ReviewRenderableFile } from "./reviewModel";
+
+export function getDefaultReviewExpandedFileIds(
+ files: ReadonlyArray,
+): ReadonlyArray {
+ return files.map((file) => file.id);
+}
+
+export function getValidReviewFileIds(
+ files: ReadonlyArray,
+ fileIds: ReadonlyArray | undefined,
+): ReadonlyArray {
+ if (fileIds === undefined) {
+ return getDefaultReviewExpandedFileIds(files);
+ }
+
+ const fileIdSet = new Set(files.map((file) => file.id));
+ return fileIds.filter((id) => fileIdSet.has(id));
+}
+
+export function getValidExplicitReviewFileIds(
+ files: ReadonlyArray,
+ fileIds: ReadonlyArray | undefined,
+): ReadonlyArray {
+ if (fileIds === undefined) {
+ return [];
+ }
+
+ return getValidReviewFileIds(files, fileIds);
+}
+
+export function toggleReviewFileId(
+ fileIds: ReadonlyArray,
+ fileId: string,
+): ReadonlyArray {
+ return fileIds.includes(fileId) ? fileIds.filter((id) => id !== fileId) : [...fileIds, fileId];
+}
+
+export function removeReviewFileId(
+ fileIds: ReadonlyArray,
+ fileId: string,
+): ReadonlyArray {
+ return fileIds.includes(fileId) ? fileIds.filter((id) => id !== fileId) : fileIds;
+}
+
+export function useReviewFileVisibility(input: {
+ readonly threadKey: string | null;
+ readonly sectionId: string | null;
+ readonly files: ReadonlyArray;
+ readonly cachedExpandedFileIds: ReadonlyArray | undefined;
+ readonly cachedViewedFileIds: ReadonlyArray | undefined;
+}) {
+ const { cachedExpandedFileIds, cachedViewedFileIds, files, sectionId, threadKey } = input;
+
+ const expandedFileIds = useMemo(
+ () => getValidReviewFileIds(files, cachedExpandedFileIds),
+ [cachedExpandedFileIds, files],
+ );
+ const viewedFileIds = useMemo(
+ () => getValidExplicitReviewFileIds(files, cachedViewedFileIds),
+ [cachedViewedFileIds, files],
+ );
+ const collapsedFileIds = useMemo(() => {
+ const expandedFileIdSet = new Set(expandedFileIds);
+ return files.reduce((fileIds, file) => {
+ if (!expandedFileIdSet.has(file.id)) {
+ fileIds.push(file.id);
+ }
+ return fileIds;
+ }, []);
+ }, [expandedFileIds, files]);
+
+ const toggleExpandedFile = useCallback(
+ (fileId: string) => {
+ if (!threadKey || !sectionId) {
+ return;
+ }
+
+ updateReviewExpandedFileIds(threadKey, sectionId, (existing) =>
+ toggleReviewFileId(getValidReviewFileIds(files, existing), fileId),
+ );
+ },
+ [files, sectionId, threadKey],
+ );
+
+ const toggleViewedFile = useCallback(
+ (fileId: string) => {
+ if (!threadKey || !sectionId) {
+ return;
+ }
+
+ const shouldCollapse = !viewedFileIds.includes(fileId);
+ updateReviewViewedFileIds(threadKey, sectionId, (existing) =>
+ toggleReviewFileId(getValidExplicitReviewFileIds(files, existing), fileId),
+ );
+
+ if (shouldCollapse) {
+ updateReviewExpandedFileIds(threadKey, sectionId, (existing) =>
+ removeReviewFileId(getValidReviewFileIds(files, existing), fileId),
+ );
+ }
+ },
+ [files, sectionId, threadKey, viewedFileIds],
+ );
+
+ return {
+ expandedFileIds,
+ viewedFileIds,
+ collapsedFileIds,
+ toggleExpandedFile,
+ toggleViewedFile,
+ };
+}
diff --git a/apps/mobile/src/features/review/reviewHighlighterEngine.test.ts b/apps/mobile/src/features/review/reviewHighlighterEngine.test.ts
new file mode 100644
index 00000000000..5449fc6bf42
--- /dev/null
+++ b/apps/mobile/src/features/review/reviewHighlighterEngine.test.ts
@@ -0,0 +1,34 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ resolveReviewHighlighterEngine,
+ resolveReviewHighlighterEnginePreference,
+} from "./reviewHighlighterEngine";
+
+describe("resolveReviewHighlighterEnginePreference", () => {
+ it("defaults invalid values to native", () => {
+ expect(resolveReviewHighlighterEnginePreference(undefined)).toBe("native");
+ expect(resolveReviewHighlighterEnginePreference("bogus")).toBe("native");
+ });
+
+ it("accepts supported values", () => {
+ expect(resolveReviewHighlighterEnginePreference("javascript")).toBe("javascript");
+ expect(resolveReviewHighlighterEnginePreference("js")).toBe("javascript");
+ expect(resolveReviewHighlighterEnginePreference("native")).toBe("native");
+ });
+});
+
+describe("resolveReviewHighlighterEngine", () => {
+ it("uses javascript when explicitly requested", () => {
+ expect(resolveReviewHighlighterEngine("javascript", true)).toBe("javascript");
+ expect(resolveReviewHighlighterEngine("javascript", false)).toBe("javascript");
+ });
+
+ it("uses native when available for native preference", () => {
+ expect(resolveReviewHighlighterEngine("native", true)).toBe("native");
+ });
+
+ it("falls back to javascript when native is unavailable", () => {
+ expect(resolveReviewHighlighterEngine("native", false)).toBe("javascript");
+ });
+});
diff --git a/apps/mobile/src/features/review/reviewHighlighterEngine.ts b/apps/mobile/src/features/review/reviewHighlighterEngine.ts
new file mode 100644
index 00000000000..4287685376d
--- /dev/null
+++ b/apps/mobile/src/features/review/reviewHighlighterEngine.ts
@@ -0,0 +1,31 @@
+export type ReviewHighlighterEnginePreference = "javascript" | "native";
+export type ReviewHighlighterEngine = "javascript" | "native";
+
+export function resolveReviewHighlighterEnginePreference(
+ value: string | undefined,
+): ReviewHighlighterEnginePreference {
+ switch (value) {
+ case "js":
+ case "javascript":
+ return "javascript";
+ case "native":
+ return "native";
+ default:
+ return "native";
+ }
+}
+
+export function resolveReviewHighlighterEngine(
+ preference: ReviewHighlighterEnginePreference,
+ nativeAvailable: boolean,
+): ReviewHighlighterEngine {
+ if (preference === "javascript") {
+ return "javascript";
+ }
+
+ if (nativeAvailable) {
+ return "native";
+ }
+
+ return "javascript";
+}
diff --git a/apps/mobile/src/features/review/reviewHighlighterState.test.ts b/apps/mobile/src/features/review/reviewHighlighterState.test.ts
new file mode 100644
index 00000000000..90c71350fd0
--- /dev/null
+++ b/apps/mobile/src/features/review/reviewHighlighterState.test.ts
@@ -0,0 +1,75 @@
+import { assert, beforeEach, it } from "vitest";
+import { AtomRegistry } from "effect/unstable/reactivity";
+
+import {
+ createReviewHighlighterManager,
+ IDLE_REVIEW_HIGHLIGHTER_STATE,
+} from "./reviewHighlighterState";
+
+let registry = AtomRegistry.make();
+
+beforeEach(() => {
+ registry.dispose();
+ registry = AtomRegistry.make();
+});
+
+function flushAsyncWork(): Promise {
+ return Promise.resolve().then(() => undefined);
+}
+
+it("initializes review highlighter state once", async () => {
+ let prepareCalls = 0;
+ let languageCalls = 0;
+ let engineCalls = 0;
+ const manager = createReviewHighlighterManager({
+ getRegistry: () => registry,
+ loader: {
+ prepare: async () => {
+ prepareCalls += 1;
+ },
+ prepareLanguages: async () => {
+ languageCalls += 1;
+ },
+ getEngine: async () => {
+ engineCalls += 1;
+ return "javascript";
+ },
+ },
+ });
+
+ assert.deepStrictEqual(manager.getSnapshot(), IDLE_REVIEW_HIGHLIGHTER_STATE);
+
+ await Promise.all([manager.initialize(), manager.initialize()]);
+ await manager.initialize();
+
+ assert.strictEqual(prepareCalls, 1);
+ assert.strictEqual(languageCalls, 1);
+ assert.strictEqual(engineCalls, 1);
+ assert.deepStrictEqual(manager.getSnapshot(), {
+ engine: "javascript",
+ error: null,
+ status: "ready",
+ });
+});
+
+it("stores initialization failures in atom state", async () => {
+ const manager = createReviewHighlighterManager({
+ getRegistry: () => registry,
+ loader: {
+ prepare: async () => {
+ throw new Error("load failed");
+ },
+ prepareLanguages: async () => undefined,
+ getEngine: async () => "javascript",
+ },
+ });
+
+ void manager.initialize();
+ await flushAsyncWork();
+
+ assert.deepStrictEqual(manager.getSnapshot(), {
+ engine: null,
+ error: "load failed",
+ status: "error",
+ });
+});
diff --git a/apps/mobile/src/features/review/reviewHighlighterState.ts b/apps/mobile/src/features/review/reviewHighlighterState.ts
new file mode 100644
index 00000000000..2622ecbe050
--- /dev/null
+++ b/apps/mobile/src/features/review/reviewHighlighterState.ts
@@ -0,0 +1,154 @@
+import { useAtomValue } from "@effect/atom-react";
+import { Atom, type AtomRegistry } from "effect/unstable/reactivity";
+import { useEffect } from "react";
+
+import { appAtomRegistry } from "../../state/atom-registry";
+import {
+ getActiveReviewHighlighterEngine,
+ prepareReviewHighlighter,
+ prepareReviewHighlighterLanguages,
+ type ReviewHighlighterEngine,
+} from "./shikiReviewHighlighter";
+
+export type ReviewHighlighterStatus = "idle" | "initializing" | "ready" | "error";
+
+export interface ReviewHighlighterState {
+ readonly engine: ReviewHighlighterEngine | null;
+ readonly error: string | null;
+ readonly status: ReviewHighlighterStatus;
+}
+
+export interface ReviewHighlighterLoader {
+ readonly prepare: () => Promise;
+ readonly prepareLanguages: (languages: ReadonlyArray) => Promise;
+ readonly getEngine: () => Promise;
+}
+
+const REVIEW_INITIAL_LANGUAGES = [
+ "typescript",
+ "tsx",
+ "javascript",
+ "jsx",
+ "json",
+ "yaml",
+ "bash",
+] as const;
+
+export const IDLE_REVIEW_HIGHLIGHTER_STATE = Object.freeze({
+ engine: null,
+ error: null,
+ status: "idle",
+});
+
+const INITIALIZING_REVIEW_HIGHLIGHTER_STATE = Object.freeze({
+ engine: null,
+ error: null,
+ status: "initializing",
+});
+
+export const reviewHighlighterStateAtom = Atom.make(IDLE_REVIEW_HIGHLIGHTER_STATE).pipe(
+ Atom.keepAlive,
+ Atom.withLabel("mobile:review-highlighter"),
+);
+
+function isReviewHighlighterProviderDebugLoggingEnabled(): boolean {
+ return typeof __DEV__ !== "undefined" ? __DEV__ : false;
+}
+
+function logReviewHighlighterProviderDiagnostic(
+ message: string,
+ details?: Record,
+): void {
+ if (!isReviewHighlighterProviderDebugLoggingEnabled()) {
+ return;
+ }
+
+ if (details) {
+ console.log(`[review-highlighter-provider] ${message}`, details);
+ return;
+ }
+
+ console.log(`[review-highlighter-provider] ${message}`);
+}
+
+export function createReviewHighlighterManager(config: {
+ readonly getRegistry: () => AtomRegistry.AtomRegistry;
+ readonly loader: ReviewHighlighterLoader;
+ readonly languages?: ReadonlyArray;
+}) {
+ let started = false;
+ let inFlight: Promise | null = null;
+
+ function getSnapshot(): ReviewHighlighterState {
+ return config.getRegistry().get(reviewHighlighterStateAtom);
+ }
+
+ function setState(state: ReviewHighlighterState): void {
+ config.getRegistry().set(reviewHighlighterStateAtom, state);
+ }
+
+ function initialize(): Promise {
+ if (inFlight) {
+ return inFlight;
+ }
+
+ if (started && getSnapshot().status === "ready") {
+ return Promise.resolve();
+ }
+
+ started = true;
+ setState(INITIALIZING_REVIEW_HIGHLIGHTER_STATE);
+
+ inFlight = (async () => {
+ const startedAt = performance.now();
+ try {
+ await config.loader.prepare();
+ await config.loader.prepareLanguages(config.languages ?? REVIEW_INITIAL_LANGUAGES);
+ const engine = await config.loader.getEngine();
+ const durationMs = Math.round(performance.now() - startedAt);
+ logReviewHighlighterProviderDiagnostic("initialized", {
+ durationMs,
+ engine,
+ });
+ setState({ engine, error: null, status: "ready" });
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ logReviewHighlighterProviderDiagnostic("initialization failed", { error: message });
+ setState({ engine: null, error: message, status: "error" });
+ } finally {
+ inFlight = null;
+ }
+ })();
+
+ return inFlight;
+ }
+
+ function reset(): void {
+ started = false;
+ inFlight = null;
+ setState(IDLE_REVIEW_HIGHLIGHTER_STATE);
+ }
+
+ return {
+ getSnapshot,
+ initialize,
+ reset,
+ };
+}
+
+const reviewHighlighterManager = createReviewHighlighterManager({
+ getRegistry: () => appAtomRegistry,
+ loader: {
+ prepare: prepareReviewHighlighter,
+ prepareLanguages: prepareReviewHighlighterLanguages,
+ getEngine: getActiveReviewHighlighterEngine,
+ },
+});
+
+export function useReviewHighlighterState(): ReviewHighlighterState {
+ useEffect(() => {
+ void reviewHighlighterManager.initialize();
+ }, []);
+
+ return useAtomValue(reviewHighlighterStateAtom);
+}
diff --git a/apps/mobile/src/features/review/reviewModel.test.ts b/apps/mobile/src/features/review/reviewModel.test.ts
new file mode 100644
index 00000000000..958e3cd1689
--- /dev/null
+++ b/apps/mobile/src/features/review/reviewModel.test.ts
@@ -0,0 +1,331 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ MessageId,
+ TurnId,
+ type OrchestrationCheckpointSummary,
+ type ReviewDiffPreviewSource,
+} from "@t3tools/contracts";
+
+import {
+ buildReviewListItems,
+ buildReviewParsedDiff,
+ buildReviewSectionItems,
+ getDefaultReviewSectionId,
+ getReviewFilePreviewState,
+ getReviewSectionIdForCheckpoint,
+ type ReviewRenderableFile,
+} from "./reviewModel";
+
+function makeCheckpoint(
+ input: Partial &
+ Pick,
+): OrchestrationCheckpointSummary {
+ return {
+ checkpointRef: `refs/t3/checkpoints/thread/${input.checkpointTurnCount}` as any,
+ status: "ready",
+ files: [],
+ assistantMessageId: MessageId.make(`msg-${input.checkpointTurnCount}`),
+ ...input,
+ };
+}
+
+function makeRenderableFile(
+ input: Partial & Pick,
+): ReviewRenderableFile {
+ return {
+ id: input.path,
+ cacheKey: input.path,
+ previousPath: null,
+ changeType: "new",
+ additions: 0,
+ deletions: 0,
+ languageHint: null,
+ additionLines: [],
+ deletionLines: [],
+ rows: [],
+ ...input,
+ };
+}
+
+describe("buildReviewSectionItems", () => {
+ it("keeps one chip per checkpoint and appends git sources", () => {
+ const checkpoints = [
+ makeCheckpoint({
+ turnId: TurnId.make("turn-1"),
+ checkpointTurnCount: 1,
+ completedAt: "2026-04-01T00:00:00.000Z",
+ }),
+ makeCheckpoint({
+ turnId: TurnId.make("turn-2"),
+ checkpointTurnCount: 2,
+ completedAt: "2026-04-02T00:00:00.000Z",
+ }),
+ ];
+ const gitSections: ReviewDiffPreviewSource[] = [
+ {
+ id: "working-tree",
+ kind: "working-tree",
+ title: "Dirty worktree",
+ baseRef: "HEAD",
+ headRef: null,
+ diff: "diff --git a/a.ts b/a.ts",
+ diffHash: "hash-dirty",
+ truncated: false,
+ },
+ {
+ id: "branch-range",
+ kind: "branch-range",
+ title: "Against main",
+ baseRef: "main",
+ headRef: "feature",
+ diff: "diff --git a/a.ts b/a.ts",
+ diffHash: "hash-base",
+ truncated: false,
+ },
+ ];
+
+ const loadedTurnId = getReviewSectionIdForCheckpoint(checkpoints[0]);
+ const items = buildReviewSectionItems({
+ checkpoints,
+ gitSections,
+ turnDiffById: {
+ [loadedTurnId]: "diff --git a/loaded.ts b/loaded.ts",
+ },
+ loadingTurnIds: {
+ [getReviewSectionIdForCheckpoint(checkpoints[1])]: true,
+ },
+ });
+
+ expect(items.map((item) => item.id)).toEqual([
+ "turn:2",
+ "turn:1",
+ "git:working-tree",
+ "git:branch-range",
+ ]);
+ expect(items[0]).toMatchObject({ isLoading: true, diff: null });
+ expect(items[1]).toMatchObject({
+ isLoading: false,
+ diff: expect.stringContaining("loaded.ts"),
+ });
+ expect(getDefaultReviewSectionId(items)).toBe("turn:2");
+ });
+});
+
+describe("buildReviewParsedDiff", () => {
+ it("builds renderable rows from a unified patch", () => {
+ const parsed = buildReviewParsedDiff(
+ [
+ "diff --git a/apps/mobile/src/a.ts b/apps/mobile/src/a.ts",
+ "index 1111111..2222222 100644",
+ "--- a/apps/mobile/src/a.ts",
+ "+++ b/apps/mobile/src/a.ts",
+ "@@ -1,2 +1,3 @@",
+ "-const before = 1;",
+ "+const after = 2;",
+ "+console.log(after);",
+ " return true;",
+ ].join("\n"),
+ "unit",
+ );
+
+ expect(parsed.kind).toBe("files");
+ if (parsed.kind !== "files") {
+ return;
+ }
+
+ expect(parsed.fileCount).toBe(1);
+ expect(parsed.additions).toBe(2);
+ expect(parsed.deletions).toBe(1);
+ expect(parsed.files[0]).toMatchObject({
+ path: "apps/mobile/src/a.ts",
+ additions: 2,
+ deletions: 1,
+ });
+ expect(parsed.files[0]?.rows).toEqual([
+ expect.objectContaining({ kind: "hunk", header: "@@ -1,2 +1,3 @@" }),
+ expect.objectContaining({
+ kind: "line",
+ change: "delete",
+ oldLineNumber: 1,
+ newLineNumber: null,
+ content: "const before = 1;",
+ comparison: { change: "add", tokenIndex: 0 },
+ }),
+ expect.objectContaining({
+ kind: "line",
+ change: "add",
+ oldLineNumber: null,
+ newLineNumber: 1,
+ content: "const after = 2;",
+ comparison: { change: "delete", tokenIndex: 0 },
+ }),
+ expect.objectContaining({
+ kind: "line",
+ change: "add",
+ oldLineNumber: null,
+ newLineNumber: 2,
+ content: "console.log(after);",
+ comparison: null,
+ }),
+ expect.objectContaining({
+ kind: "line",
+ change: "context",
+ oldLineNumber: 2,
+ newLineNumber: 3,
+ content: "return true;",
+ comparison: null,
+ }),
+ ]);
+ });
+
+ it("treats truncated patches as partial diffs instead of failing", () => {
+ const parsed = buildReviewParsedDiff(
+ [
+ "diff --git a/apps/mobile/src/a.ts b/apps/mobile/src/a.ts",
+ "index 1111111..2222222 100644",
+ "--- a/apps/mobile/src/a.ts",
+ "+++ b/apps/mobile/src/a.ts",
+ "@@ -1 +1,2 @@",
+ " const before = 1;",
+ "+const after = 2;",
+ "",
+ "[truncated]",
+ ].join("\n"),
+ "unit-truncated",
+ );
+
+ expect(parsed.kind).toBe("files");
+ if (parsed.kind !== "files") {
+ return;
+ }
+
+ expect(parsed.notice).toContain("server size cap");
+ expect(parsed.fileCount).toBe(1);
+ expect(parsed.files[0]?.rows[0]).toMatchObject({
+ kind: "hunk",
+ header: "@@ -1,1 +1,2 @@",
+ });
+ });
+
+ it("suppresses preview for non-text file formats", () => {
+ const preview = getReviewFilePreviewState(
+ makeRenderableFile({
+ path: "apps/mobile/assets/icon.png",
+ }),
+ );
+
+ expect(preview).toMatchObject({
+ kind: "suppressed",
+ reason: "non-text",
+ title: "Non-text file",
+ actionLabel: null,
+ });
+ });
+
+ it("suppresses large diffs until explicitly requested", () => {
+ const preview = getReviewFilePreviewState(
+ makeRenderableFile({
+ path: "apps/mobile/src/big.ts",
+ rows: Array.from({ length: 401 }, (_, index) => ({
+ kind: "line" as const,
+ id: `line-${index}`,
+ change: "add" as const,
+ oldLineNumber: null,
+ newLineNumber: index + 1,
+ content: `const line${index} = ${index};`,
+ additionTokenIndex: index,
+ deletionTokenIndex: null,
+ comparison: null,
+ })),
+ }),
+ );
+
+ expect(preview).toMatchObject({
+ kind: "suppressed",
+ reason: "large",
+ title: "Large diff",
+ actionLabel: "Load diff",
+ });
+ });
+
+ it("flattens expanded file rows into virtualized review items", () => {
+ const file = makeRenderableFile({
+ path: "apps/mobile/src/a.ts",
+ rows: [
+ {
+ kind: "hunk",
+ id: "hunk-1",
+ header: "@@ -1,1 +1,2 @@",
+ context: null,
+ },
+ {
+ kind: "line",
+ id: "line-1",
+ change: "add",
+ oldLineNumber: null,
+ newLineNumber: 1,
+ content: "const after = 2;",
+ additionTokenIndex: 0,
+ deletionTokenIndex: null,
+ comparison: null,
+ },
+ ],
+ });
+
+ const items = buildReviewListItems({
+ files: [file],
+ expandedFileIds: [file.id],
+ revealedLargeFileIds: [],
+ });
+
+ expect(items).toEqual([
+ expect.objectContaining({ kind: "file-header", fileId: file.id, expanded: true }),
+ expect.objectContaining({
+ kind: "hunk",
+ fileId: file.id,
+ file,
+ row: file.rows[0],
+ }),
+ expect.objectContaining({
+ kind: "line",
+ fileId: file.id,
+ file,
+ row: file.rows[1],
+ lineIndex: 0,
+ }),
+ ]);
+ });
+
+ it("keeps large diffs collapsed into a placeholder item until revealed", () => {
+ const file = makeRenderableFile({
+ path: "apps/mobile/src/big.ts",
+ rows: Array.from({ length: 401 }, (_, index) => ({
+ kind: "line" as const,
+ id: `line-${index}`,
+ change: "add" as const,
+ oldLineNumber: null,
+ newLineNumber: index + 1,
+ content: `const line${index} = ${index};`,
+ additionTokenIndex: index,
+ deletionTokenIndex: null,
+ comparison: null,
+ })),
+ });
+
+ const items = buildReviewListItems({
+ files: [file],
+ expandedFileIds: [file.id],
+ revealedLargeFileIds: [],
+ });
+
+ expect(items).toEqual([
+ expect.objectContaining({ kind: "file-header", fileId: file.id, expanded: true }),
+ expect.objectContaining({
+ kind: "file-suppressed",
+ fileId: file.id,
+ actionLabel: "Load diff",
+ }),
+ ]);
+ });
+});
diff --git a/apps/mobile/src/features/review/reviewModel.ts b/apps/mobile/src/features/review/reviewModel.ts
new file mode 100644
index 00000000000..26aee94d139
--- /dev/null
+++ b/apps/mobile/src/features/review/reviewModel.ts
@@ -0,0 +1,613 @@
+import type { ChangeTypes, FileDiffMetadata } from "@pierre/diffs/types";
+import { parsePatchFiles } from "@pierre/diffs/utils/parsePatchFiles";
+import type { OrchestrationCheckpointSummary, ReviewDiffPreviewSource } from "@t3tools/contracts";
+import * as Arr from "effect/Array";
+import * as Order from "effect/Order";
+
+export type ReviewSectionKind = "turn" | "working-tree" | "branch-range";
+
+export interface ReviewSectionItem {
+ readonly id: string;
+ readonly kind: ReviewSectionKind;
+ readonly title: string;
+ readonly subtitle: string | null;
+ readonly diff: string | null;
+ readonly isLoading: boolean;
+}
+
+export interface ReviewRenderableHunkRow {
+ readonly kind: "hunk";
+ readonly id: string;
+ readonly header: string;
+ readonly context: string | null;
+}
+
+export interface ReviewRenderableLineRow {
+ readonly kind: "line";
+ readonly id: string;
+ readonly change: "context" | "add" | "delete";
+ readonly oldLineNumber: number | null;
+ readonly newLineNumber: number | null;
+ readonly content: string;
+ readonly additionTokenIndex: number | null;
+ readonly deletionTokenIndex: number | null;
+ readonly comparison: {
+ readonly change: "add" | "delete";
+ readonly tokenIndex: number;
+ } | null;
+}
+
+export type ReviewRenderableRow = ReviewRenderableHunkRow | ReviewRenderableLineRow;
+
+export interface ReviewRenderableFile {
+ readonly id: string;
+ readonly cacheKey: string;
+ readonly path: string;
+ readonly previousPath: string | null;
+ readonly changeType: ChangeTypes;
+ readonly additions: number;
+ readonly deletions: number;
+ readonly languageHint: string | null;
+ readonly additionLines: ReadonlyArray;
+ readonly deletionLines: ReadonlyArray;
+ readonly rows: ReadonlyArray;
+}
+
+export interface ReviewFileHeaderListItem {
+ readonly kind: "file-header";
+ readonly id: string;
+ readonly fileId: string;
+ readonly file: ReviewRenderableFile;
+ readonly expanded: boolean;
+}
+
+export interface ReviewFileSuppressedListItem {
+ readonly kind: "file-suppressed";
+ readonly id: string;
+ readonly fileId: string;
+ readonly message: string;
+ readonly actionLabel: string | null;
+}
+
+export interface ReviewHunkListItem {
+ readonly kind: "hunk";
+ readonly id: string;
+ readonly fileId: string;
+ readonly file: ReviewRenderableFile;
+ readonly row: ReviewRenderableHunkRow;
+}
+
+export interface ReviewLineListItem {
+ readonly kind: "line";
+ readonly id: string;
+ readonly fileId: string;
+ readonly file: ReviewRenderableFile;
+ readonly row: ReviewRenderableLineRow;
+ readonly lineIndex: number;
+}
+
+export type ReviewListItem =
+ | ReviewFileHeaderListItem
+ | ReviewFileSuppressedListItem
+ | ReviewHunkListItem
+ | ReviewLineListItem;
+
+export type ReviewFilePreviewState =
+ | {
+ readonly kind: "render";
+ }
+ | {
+ readonly kind: "suppressed";
+ readonly reason: "non-text" | "large";
+ readonly title: string;
+ readonly message: string;
+ readonly actionLabel: string | null;
+ };
+
+export type ReviewParsedDiff =
+ | {
+ readonly kind: "empty";
+ }
+ | {
+ readonly kind: "raw";
+ readonly text: string;
+ readonly reason: string;
+ readonly notice: string | null;
+ }
+ | {
+ readonly kind: "files";
+ readonly files: ReadonlyArray;
+ readonly fileCount: number;
+ readonly additions: number;
+ readonly deletions: number;
+ readonly notice: string | null;
+ };
+
+function checkpointTitle(checkpoint: OrchestrationCheckpointSummary): string {
+ return `Turn ${checkpoint.checkpointTurnCount}`;
+}
+
+function checkpointSubtitle(checkpoint: OrchestrationCheckpointSummary): string {
+ const fileCount = checkpoint.files.length;
+ if (checkpoint.status !== "ready") {
+ return `Diff ${checkpoint.status}`;
+ }
+ return `${fileCount} file${fileCount === 1 ? "" : "s"} changed`;
+}
+
+function compareCheckpointTurnCountDescending(
+ left: OrchestrationCheckpointSummary,
+ right: OrchestrationCheckpointSummary,
+): -1 | 0 | 1 {
+ if (left.checkpointTurnCount === right.checkpointTurnCount) {
+ return 0;
+ }
+
+ return left.checkpointTurnCount > right.checkpointTurnCount ? -1 : 1;
+}
+
+const readyCheckpointOrder = Order.make(
+ compareCheckpointTurnCountDescending,
+);
+
+function gitSubtitle(section: ReviewDiffPreviewSource): string | null {
+ if (section.kind === "working-tree") {
+ return "Tracked, staged, and untracked worktree changes";
+ }
+ if (section.baseRef) {
+ return `${section.baseRef} ... ${section.headRef ?? "HEAD"}`;
+ }
+ return "Base branch unavailable";
+}
+
+function stripGitPrefix(pathValue: string | undefined): string | null {
+ if (!pathValue) {
+ return null;
+ }
+ if (pathValue.startsWith("a/") || pathValue.startsWith("b/")) {
+ return pathValue.slice(2);
+ }
+ return pathValue;
+}
+
+function stripTrailingNewline(value: string): string {
+ return value.endsWith("\n") ? value.slice(0, -1) : value;
+}
+
+function splitTruncationMarker(diff: string): {
+ readonly text: string;
+ readonly truncated: boolean;
+} {
+ const trimmed = diff.trimEnd();
+ if (!trimmed.endsWith("[truncated]")) {
+ return { text: trimmed, truncated: false };
+ }
+
+ return {
+ text: trimmed.replace(/\n*\[truncated\]\s*$/, "").trimEnd(),
+ truncated: true,
+ };
+}
+
+function runDiffParserSilently(callback: () => T): T {
+ const originalConsoleError = console.error;
+ console.error = () => undefined;
+ try {
+ return callback();
+ } finally {
+ console.error = originalConsoleError;
+ }
+}
+
+const FNV_OFFSET_BASIS_32 = 0x811c9dc5;
+const FNV_PRIME_32 = 0x01000193;
+const SECONDARY_HASH_SEED = 0x9e3779b9;
+const SECONDARY_HASH_MULTIPLIER = 0x85ebca6b;
+const LARGE_DIFF_LINE_THRESHOLD = 400;
+const LARGE_DIFF_CHARACTER_THRESHOLD = 24_000;
+const NON_TEXT_FILE_EXTENSIONS = new Set([
+ "png",
+ "jpg",
+ "jpeg",
+ "gif",
+ "webp",
+ "bmp",
+ "ico",
+ "icns",
+ "avif",
+ "heic",
+ "tif",
+ "tiff",
+ "mp3",
+ "wav",
+ "flac",
+ "ogg",
+ "m4a",
+ "aac",
+ "mp4",
+ "mov",
+ "avi",
+ "mkv",
+ "webm",
+ "pdf",
+ "zip",
+ "gz",
+ "tgz",
+ "bz2",
+ "7z",
+ "rar",
+ "woff",
+ "woff2",
+ "ttf",
+ "otf",
+ "eot",
+ "wasm",
+ "exe",
+ "dll",
+ "so",
+ "dylib",
+]);
+
+function fnv1a32(input: string, seed: number, multiplier: number): number {
+ let hash = seed >>> 0;
+ for (let index = 0; index < input.length; index += 1) {
+ hash ^= input.charCodeAt(index);
+ hash = Math.imul(hash, multiplier) >>> 0;
+ }
+ return hash >>> 0;
+}
+
+function buildPatchCacheKey(patch: string, scope: string): string {
+ const normalizedPatch = patch.trim();
+ const primary = fnv1a32(normalizedPatch, FNV_OFFSET_BASIS_32, FNV_PRIME_32).toString(36);
+ const secondary = fnv1a32(
+ normalizedPatch,
+ SECONDARY_HASH_SEED,
+ SECONDARY_HASH_MULTIPLIER,
+ ).toString(36);
+ return `${scope}:${normalizedPatch.length}:${primary}:${secondary}`;
+}
+
+function getFileExtension(path: string): string | null {
+ const match = /\.([a-z0-9]+)$/i.exec(path);
+ return match?.[1]?.toLowerCase() ?? null;
+}
+
+function countReviewRenderableLineRows(file: ReviewRenderableFile): number {
+ return file.rows.reduce((total, row) => total + (row.kind === "line" ? 1 : 0), 0);
+}
+
+function countReviewRenderableCharacters(file: ReviewRenderableFile): number {
+ return file.rows.reduce(
+ (total, row) => total + (row.kind === "line" ? row.content.length : row.header.length),
+ 0,
+ );
+}
+
+export function getReviewFilePreviewState(file: ReviewRenderableFile): ReviewFilePreviewState {
+ const extension = getFileExtension(file.path);
+ if (extension && NON_TEXT_FILE_EXTENSIONS.has(extension)) {
+ return {
+ kind: "suppressed",
+ reason: "non-text",
+ title: "Non-text file",
+ message: "Diff preview is not available for this file format.",
+ actionLabel: null,
+ };
+ }
+
+ const lineCount = countReviewRenderableLineRows(file);
+ const characterCount = countReviewRenderableCharacters(file);
+ if (lineCount > LARGE_DIFF_LINE_THRESHOLD || characterCount > LARGE_DIFF_CHARACTER_THRESHOLD) {
+ return {
+ kind: "suppressed",
+ reason: "large",
+ title: "Large diff",
+ message: "Large diffs are not rendered by default.",
+ actionLabel: "Load diff",
+ };
+ }
+
+ return { kind: "render" };
+}
+
+// The flattened review list item model is inspired by pierre/diffs' iterator-first
+// virtualization architecture, adapted here for React Native virtualization.
+// Original project: https://github.com/pingdotgg/pierre/tree/main/packages/diffs
+// Reference files:
+// - src/utils/iterateOverDiff.ts
+// - src/components/VirtualizedFileDiff.ts
+export function buildReviewListItems(input: {
+ readonly files: ReadonlyArray;
+ readonly expandedFileIds: ReadonlyArray;
+ readonly revealedLargeFileIds: ReadonlyArray;
+}): ReadonlyArray {
+ const expandedFileIds = new Set(input.expandedFileIds);
+ const revealedLargeFileIds = new Set(input.revealedLargeFileIds);
+ const items: ReviewListItem[] = [];
+
+ input.files.forEach((file) => {
+ const expanded = expandedFileIds.has(file.id);
+ items.push({
+ kind: "file-header",
+ id: `${file.id}:header`,
+ fileId: file.id,
+ file,
+ expanded,
+ });
+
+ if (!expanded) {
+ return;
+ }
+
+ const previewState = getReviewFilePreviewState(file);
+ if (previewState.kind === "suppressed") {
+ if (previewState.reason !== "large" || !revealedLargeFileIds.has(file.id)) {
+ items.push({
+ kind: "file-suppressed",
+ id: `${file.id}:suppressed`,
+ fileId: file.id,
+ message: previewState.message,
+ actionLabel: previewState.actionLabel,
+ });
+ return;
+ }
+ }
+
+ let lineIndex = 0;
+ file.rows.forEach((row, rowIndex) => {
+ if (row.kind === "hunk") {
+ items.push({
+ kind: "hunk",
+ id: `${file.id}:row:${rowIndex}:${row.id}`,
+ fileId: file.id,
+ file,
+ row,
+ });
+ return;
+ }
+
+ items.push({
+ kind: "line",
+ id: `${file.id}:row:${rowIndex}:${row.id}`,
+ fileId: file.id,
+ file,
+ row,
+ lineIndex,
+ });
+ lineIndex += 1;
+ });
+ });
+
+ return items;
+}
+
+function fallbackHunkHeader(hunk: FileDiffMetadata["hunks"][number]): string {
+ return `@@ -${hunk.deletionStart},${hunk.deletionCount} +${hunk.additionStart},${hunk.additionCount} @@`;
+}
+
+function buildRenderableRows(file: FileDiffMetadata): ReadonlyArray {
+ const rows: ReviewRenderableRow[] = [];
+ let rowIndex = 0;
+
+ file.hunks.forEach((hunk, hunkIndex) => {
+ rows.push({
+ kind: "hunk",
+ id: `${file.cacheKey ?? file.name}:hunk:${hunkIndex}`,
+ header: fallbackHunkHeader(hunk),
+ context: hunk.hunkContext ? stripTrailingNewline(hunk.hunkContext) : null,
+ });
+
+ let deletionLineNumber = hunk.deletionStart;
+ let additionLineNumber = hunk.additionStart;
+ let deletionTokenIndex = hunk.deletionLineIndex;
+ let additionTokenIndex = hunk.additionLineIndex;
+
+ hunk.hunkContent.forEach((segment) => {
+ if (segment.type === "context") {
+ for (let index = 0; index < segment.lines; index += 1) {
+ rows.push({
+ kind: "line",
+ id: `${file.cacheKey ?? file.name}:row:${rowIndex++}`,
+ change: "context",
+ oldLineNumber: deletionLineNumber,
+ newLineNumber: additionLineNumber,
+ content: stripTrailingNewline(
+ file.additionLines[additionTokenIndex] ??
+ file.deletionLines[deletionTokenIndex] ??
+ "",
+ ),
+ additionTokenIndex,
+ deletionTokenIndex,
+ comparison: null,
+ });
+ deletionLineNumber += 1;
+ additionLineNumber += 1;
+ deletionTokenIndex += 1;
+ additionTokenIndex += 1;
+ }
+ return;
+ }
+
+ const pairedLineCount = Math.min(segment.deletions, segment.additions);
+ const deletionTokenIndexStart = deletionTokenIndex;
+ const additionTokenIndexStart = additionTokenIndex;
+
+ for (let index = 0; index < segment.deletions; index += 1) {
+ rows.push({
+ kind: "line",
+ id: `${file.cacheKey ?? file.name}:row:${rowIndex++}`,
+ change: "delete",
+ oldLineNumber: deletionLineNumber,
+ newLineNumber: null,
+ content: stripTrailingNewline(file.deletionLines[deletionTokenIndex] ?? ""),
+ additionTokenIndex: null,
+ deletionTokenIndex,
+ comparison:
+ index < pairedLineCount
+ ? {
+ change: "add",
+ tokenIndex: additionTokenIndexStart + index,
+ }
+ : null,
+ });
+ deletionLineNumber += 1;
+ deletionTokenIndex += 1;
+ }
+
+ for (let index = 0; index < segment.additions; index += 1) {
+ rows.push({
+ kind: "line",
+ id: `${file.cacheKey ?? file.name}:row:${rowIndex++}`,
+ change: "add",
+ oldLineNumber: null,
+ newLineNumber: additionLineNumber,
+ content: stripTrailingNewline(file.additionLines[additionTokenIndex] ?? ""),
+ additionTokenIndex,
+ deletionTokenIndex: null,
+ comparison:
+ index < pairedLineCount
+ ? {
+ change: "delete",
+ tokenIndex: deletionTokenIndexStart + index,
+ }
+ : null,
+ });
+ additionLineNumber += 1;
+ additionTokenIndex += 1;
+ }
+ });
+ });
+
+ return rows;
+}
+
+function mapRenderableFile(file: FileDiffMetadata): ReviewRenderableFile {
+ const path = stripGitPrefix(file.name) ?? stripGitPrefix(file.prevName) ?? file.name;
+ const previousPath = stripGitPrefix(file.prevName);
+ const additions = file.hunks.reduce((total, hunk) => total + hunk.additionLines, 0);
+ const deletions = file.hunks.reduce((total, hunk) => total + hunk.deletionLines, 0);
+ const cacheKey = file.cacheKey ?? `${previousPath ?? "none"}:${path}:${file.type}`;
+
+ return {
+ id: cacheKey,
+ cacheKey,
+ path,
+ previousPath,
+ changeType: file.type,
+ additions,
+ deletions,
+ languageHint: file.lang ?? null,
+ additionLines: file.additionLines,
+ deletionLines: file.deletionLines,
+ rows: buildRenderableRows(file),
+ };
+}
+
+export function getReviewSectionIdForCheckpoint(
+ checkpoint: Pick,
+): string {
+ return `turn:${checkpoint.checkpointTurnCount}`;
+}
+
+export function getReadyReviewCheckpoints(
+ checkpoints: ReadonlyArray,
+): ReadonlyArray {
+ return Arr.sort(
+ checkpoints.filter((checkpoint) => checkpoint.status === "ready"),
+ readyCheckpointOrder,
+ );
+}
+
+export function buildReviewSectionItems(input: {
+ readonly checkpoints: ReadonlyArray;
+ readonly gitSections: ReadonlyArray;
+ readonly turnDiffById: Readonly>;
+ readonly loadingTurnIds: Readonly>;
+}): ReadonlyArray {
+ const turnItems = getReadyReviewCheckpoints(input.checkpoints).map(
+ (checkpoint) => {
+ const id = getReviewSectionIdForCheckpoint(checkpoint);
+ return {
+ id,
+ kind: "turn",
+ title: checkpointTitle(checkpoint),
+ subtitle: checkpointSubtitle(checkpoint),
+ diff: input.turnDiffById[id] ?? null,
+ isLoading: input.loadingTurnIds[id] === true,
+ };
+ },
+ );
+
+ const gitItems = input.gitSections.map((section) => ({
+ id: `git:${section.kind}`,
+ kind: section.kind,
+ title: section.title,
+ subtitle: gitSubtitle(section),
+ diff: section.diff,
+ isLoading: false,
+ }));
+
+ return [...turnItems, ...gitItems];
+}
+
+export function getDefaultReviewSectionId(
+ sections: ReadonlyArray,
+): string | null {
+ return sections[0]?.id ?? null;
+}
+
+export function buildReviewParsedDiff(
+ diff: string | null | undefined,
+ cacheScope: string,
+): ReviewParsedDiff {
+ const normalized = diff?.trim();
+ if (!normalized) {
+ return { kind: "empty" };
+ }
+
+ const { text, truncated } = splitTruncationMarker(normalized);
+ if (text.length === 0) {
+ return { kind: "empty" };
+ }
+
+ const notice = truncated
+ ? "Diff output hit the server size cap. Showing the available excerpt."
+ : null;
+
+ try {
+ const parsedPatches = runDiffParserSilently(() =>
+ parsePatchFiles(text, buildPatchCacheKey(text, cacheScope)),
+ );
+ const files = parsedPatches.flatMap((patch) => patch.files).map(mapRenderableFile);
+
+ if (files.length === 0) {
+ return {
+ kind: "raw",
+ text,
+ reason: truncated
+ ? "Diff was truncated before it could be parsed completely. Showing the raw excerpt."
+ : "Unsupported diff format. Showing raw patch.",
+ notice,
+ };
+ }
+
+ return {
+ kind: "files",
+ files,
+ fileCount: files.length,
+ additions: files.reduce((total, file) => total + file.additions, 0),
+ deletions: files.reduce((total, file) => total + file.deletions, 0),
+ notice,
+ };
+ } catch {
+ return {
+ kind: "raw",
+ text,
+ reason: truncated
+ ? "Diff was truncated before it could be parsed completely. Showing the raw excerpt."
+ : "Failed to parse patch. Showing raw patch.",
+ notice,
+ };
+ }
+}
diff --git a/apps/mobile/src/features/review/reviewPerf.ts b/apps/mobile/src/features/review/reviewPerf.ts
new file mode 100644
index 00000000000..2ae1095b1c6
--- /dev/null
+++ b/apps/mobile/src/features/review/reviewPerf.ts
@@ -0,0 +1,83 @@
+interface ReviewPerformanceLike {
+ readonly now?: () => number;
+ readonly mark?: (name: string) => void;
+ readonly measure?: (name: string, startMark: string, endMark: string) => void;
+ readonly clearMarks?: (name?: string) => void;
+ readonly clearMeasures?: (name?: string) => void;
+}
+
+const REVIEW_PERF_PREFIX = "t3.review";
+let reviewPerfSequence = 0;
+
+function getPerformance(): ReviewPerformanceLike | null {
+ const candidate = (globalThis as { readonly performance?: ReviewPerformanceLike }).performance;
+ return candidate ?? null;
+}
+
+export function isReviewPerfEnabled(): boolean {
+ return typeof __DEV__ !== "undefined" ? __DEV__ : false;
+}
+
+export function measureReviewWork(name: string, callback: () => T): T {
+ if (!isReviewPerfEnabled()) {
+ return callback();
+ }
+
+ const perf = getPerformance();
+ const marker = `${REVIEW_PERF_PREFIX}.${name}.${reviewPerfSequence++}`;
+ const startMark = `${marker}.start`;
+ const endMark = `${marker}.end`;
+ const startedAt = perf?.now?.() ?? Date.now();
+
+ perf?.mark?.(startMark);
+ try {
+ return callback();
+ } finally {
+ const durationMs = (perf?.now?.() ?? Date.now()) - startedAt;
+ perf?.mark?.(endMark);
+ perf?.measure?.(`${REVIEW_PERF_PREFIX}.${name}`, startMark, endMark);
+ perf?.clearMarks?.(startMark);
+ perf?.clearMarks?.(endMark);
+ console.log(`[review-perf] ${name}`, { durationMs: Number(durationMs.toFixed(2)) });
+ }
+}
+
+export async function measureReviewAsyncWork(
+ name: string,
+ callback: () => Promise