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, +): Promise { + 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 await 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 function markReviewEvent(name: string, details?: Record): void { + if (!isReviewPerfEnabled()) { + return; + } + + getPerformance()?.mark?.(`${REVIEW_PERF_PREFIX}.${name}`); + if (details) { + console.log(`[review-perf] ${name}`, details); + return; + } + console.log(`[review-perf] ${name}`); +} diff --git a/apps/mobile/src/features/review/reviewState.test.ts b/apps/mobile/src/features/review/reviewState.test.ts new file mode 100644 index 00000000000..fe3aaab515d --- /dev/null +++ b/apps/mobile/src/features/review/reviewState.test.ts @@ -0,0 +1,27 @@ +import { assert, it } from "vitest"; + +import { + getReviewAsyncStateSnapshot, + setReviewAsyncError, + setReviewTurnDiffLoading, +} from "./reviewState"; + +it("stores review async loading and error state in atoms", () => { + const threadKey = `env-local:thread-review-state-${Date.now()}`; + + setReviewTurnDiffLoading(threadKey, "turn-1", true); + setReviewAsyncError(threadKey, "load failed"); + + assert.deepStrictEqual(getReviewAsyncStateSnapshot(threadKey), { + loadingTurnIds: { "turn-1": true }, + error: "load failed", + }); + + setReviewTurnDiffLoading(threadKey, "turn-1", false); + setReviewAsyncError(threadKey, null); + + assert.deepStrictEqual(getReviewAsyncStateSnapshot(threadKey), { + loadingTurnIds: {}, + error: null, + }); +}); diff --git a/apps/mobile/src/features/review/reviewState.ts b/apps/mobile/src/features/review/reviewState.ts new file mode 100644 index 00000000000..3fce8131dd1 --- /dev/null +++ b/apps/mobile/src/features/review/reviewState.ts @@ -0,0 +1,296 @@ +import { useAtomValue } from "@effect/atom-react"; + +import type { EnvironmentId, ReviewDiffPreviewSource, ThreadId } from "@t3tools/contracts"; +import { Atom } from "effect/unstable/reactivity"; + +import { scopedThreadKey } from "../../lib/scopedEntities"; +import { appAtomRegistry } from "../../state/atom-registry"; +import { buildReviewParsedDiff, type ReviewParsedDiff } from "./reviewModel"; + +const EMPTY_GIT_REVIEW_SECTIONS = Object.freeze>([]); +const EMPTY_REVIEW_TURN_DIFFS = Object.freeze>>({}); +const EMPTY_REVIEW_LOADING_TURN_IDS = Object.freeze>>({}); +const EMPTY_REVIEW_ASYNC_STATE = Object.freeze({ + loadingTurnIds: EMPTY_REVIEW_LOADING_TURN_IDS, + error: null, +}); +const EMPTY_REVIEW_SECTION_FILE_IDS = Object.freeze< + Readonly | undefined>> +>({}); +const EMPTY_REVIEW_GIT_SECTIONS_ATOM = Atom.make(EMPTY_GIT_REVIEW_SECTIONS).pipe( + Atom.keepAlive, + Atom.withLabel("mobile:review:git-sections:null"), +); +const EMPTY_REVIEW_TURN_DIFFS_ATOM = Atom.make(EMPTY_REVIEW_TURN_DIFFS).pipe( + Atom.keepAlive, + Atom.withLabel("mobile:review:turn-diffs:null"), +); +const EMPTY_REVIEW_SELECTED_SECTION_ID_ATOM = Atom.make(null).pipe( + Atom.keepAlive, + Atom.withLabel("mobile:review:selected-section-id:null"), +); +const EMPTY_REVIEW_ASYNC_STATE_ATOM = Atom.make(EMPTY_REVIEW_ASYNC_STATE).pipe( + Atom.keepAlive, + Atom.withLabel("mobile:review:async-state:null"), +); +const EMPTY_REVIEW_SECTION_FILE_IDS_ATOM = Atom.make(EMPTY_REVIEW_SECTION_FILE_IDS).pipe( + Atom.keepAlive, + Atom.withLabel("mobile:review:section-file-ids:null"), +); + +const reviewGitSectionsByThreadKeyAtom = Atom.family((threadKey: string) => + Atom.make(EMPTY_GIT_REVIEW_SECTIONS).pipe( + Atom.keepAlive, + Atom.withLabel(`mobile:review:git-sections:${threadKey}`), + ), +); + +const reviewTurnDiffByThreadKeyAtom = Atom.family((threadKey: string) => + Atom.make(EMPTY_REVIEW_TURN_DIFFS).pipe( + Atom.keepAlive, + Atom.withLabel(`mobile:review:turn-diffs:${threadKey}`), + ), +); + +const reviewSelectedSectionIdByThreadKeyAtom = Atom.family((threadKey: string) => + Atom.make(null).pipe( + Atom.keepAlive, + Atom.withLabel(`mobile:review:selected-section-id:${threadKey}`), + ), +); + +const reviewAsyncStateByThreadKeyAtom = Atom.family((threadKey: string) => + Atom.make(EMPTY_REVIEW_ASYNC_STATE).pipe( + Atom.keepAlive, + Atom.withLabel(`mobile:review:async-state:${threadKey}`), + ), +); + +const reviewExpandedFileIdsByThreadKeyAtom = Atom.family((threadKey: string) => + Atom.make(EMPTY_REVIEW_SECTION_FILE_IDS).pipe( + Atom.keepAlive, + Atom.withLabel(`mobile:review:expanded-file-ids:${threadKey}`), + ), +); + +const reviewRevealedLargeFileIdsByThreadKeyAtom = Atom.family((threadKey: string) => + Atom.make(EMPTY_REVIEW_SECTION_FILE_IDS).pipe( + Atom.keepAlive, + Atom.withLabel(`mobile:review:revealed-large-file-ids:${threadKey}`), + ), +); + +const reviewViewedFileIdsByThreadKeyAtom = Atom.family((threadKey: string) => + Atom.make(EMPTY_REVIEW_SECTION_FILE_IDS).pipe( + Atom.keepAlive, + Atom.withLabel(`mobile:review:viewed-file-ids:${threadKey}`), + ), +); + +const reviewParsedDiffBySectionCacheKeyAtom = Atom.family((cacheKey: string) => + Atom.make<{ readonly diff: string | null; readonly parsed: ReviewParsedDiff } | null>(null).pipe( + Atom.keepAlive, + Atom.withLabel(`mobile:review:parsed-diffs:${cacheKey}`), + ), +); + +export interface ReviewCacheForThread { + readonly threadKey: string | null; + readonly gitSections: ReadonlyArray; + readonly turnDiffById: Readonly>; + readonly selectedSectionId: string | null; + readonly asyncState: ReviewAsyncState; + readonly expandedFileIdsBySection: Readonly | undefined>>; + readonly revealedLargeFileIdsBySection: Readonly< + Record | undefined> + >; + readonly viewedFileIdsBySection: Readonly | undefined>>; +} + +export interface ReviewAsyncState { + readonly loadingTurnIds: Readonly>; + readonly error: string | null; +} + +function buildThreadKey(input: { + readonly environmentId?: EnvironmentId; + readonly threadId?: ThreadId; +}): string | null { + return input.environmentId && input.threadId + ? scopedThreadKey(input.environmentId, input.threadId) + : null; +} + +function buildSectionCacheKey(threadKey: string, sectionId: string): string { + return `${threadKey}:${sectionId}`; +} + +export function useReviewCacheForThread(input: { + readonly environmentId?: EnvironmentId; + readonly threadId?: ThreadId; +}): ReviewCacheForThread { + const threadKey = buildThreadKey(input); + const gitSections = useAtomValue( + threadKey ? reviewGitSectionsByThreadKeyAtom(threadKey) : EMPTY_REVIEW_GIT_SECTIONS_ATOM, + ); + const turnDiffById = useAtomValue( + threadKey ? reviewTurnDiffByThreadKeyAtom(threadKey) : EMPTY_REVIEW_TURN_DIFFS_ATOM, + ); + const selectedSectionId = useAtomValue( + threadKey + ? reviewSelectedSectionIdByThreadKeyAtom(threadKey) + : EMPTY_REVIEW_SELECTED_SECTION_ID_ATOM, + ); + const asyncState = useAtomValue( + threadKey ? reviewAsyncStateByThreadKeyAtom(threadKey) : EMPTY_REVIEW_ASYNC_STATE_ATOM, + ); + const expandedFileIdsBySection = useAtomValue( + threadKey + ? reviewExpandedFileIdsByThreadKeyAtom(threadKey) + : EMPTY_REVIEW_SECTION_FILE_IDS_ATOM, + ); + const revealedLargeFileIdsBySection = useAtomValue( + threadKey + ? reviewRevealedLargeFileIdsByThreadKeyAtom(threadKey) + : EMPTY_REVIEW_SECTION_FILE_IDS_ATOM, + ); + const viewedFileIdsBySection = useAtomValue( + threadKey ? reviewViewedFileIdsByThreadKeyAtom(threadKey) : EMPTY_REVIEW_SECTION_FILE_IDS_ATOM, + ); + + return { + threadKey, + gitSections, + turnDiffById, + selectedSectionId, + asyncState, + expandedFileIdsBySection, + revealedLargeFileIdsBySection, + viewedFileIdsBySection, + }; +} + +export function setReviewGitSections( + threadKey: string, + sections: ReadonlyArray, +): void { + appAtomRegistry.set(reviewGitSectionsByThreadKeyAtom(threadKey), sections); +} + +export function setReviewTurnDiff(threadKey: string, sectionId: string, diff: string): void { + const atom = reviewTurnDiffByThreadKeyAtom(threadKey); + const current = appAtomRegistry.get(atom); + appAtomRegistry.set(atom, { + ...current, + [sectionId]: diff, + }); +} + +export function setReviewSelectedSectionId(threadKey: string, sectionId: string | null): void { + appAtomRegistry.set(reviewSelectedSectionIdByThreadKeyAtom(threadKey), sectionId); +} + +function updateReviewAsyncState( + threadKey: string, + update: (current: ReviewAsyncState) => ReviewAsyncState, +): void { + const atom = reviewAsyncStateByThreadKeyAtom(threadKey); + appAtomRegistry.set(atom, update(appAtomRegistry.get(atom))); +} + +export function setReviewTurnDiffLoading( + threadKey: string, + sectionId: string, + isLoading: boolean, +): void { + updateReviewAsyncState(threadKey, (current) => { + const loadingTurnIds = { ...current.loadingTurnIds }; + if (isLoading) { + loadingTurnIds[sectionId] = true; + } else { + delete loadingTurnIds[sectionId]; + } + return { + ...current, + loadingTurnIds, + }; + }); +} + +export function setReviewAsyncError(threadKey: string, error: string | null): void { + updateReviewAsyncState(threadKey, (current) => ({ + ...current, + error, + })); +} + +export function getReviewAsyncStateSnapshot(threadKey: string): ReviewAsyncState { + return appAtomRegistry.get(reviewAsyncStateByThreadKeyAtom(threadKey)); +} + +export function updateReviewExpandedFileIds( + threadKey: string, + sectionId: string, + update: (current: ReadonlyArray | undefined) => ReadonlyArray | undefined, +): void { + const atom = reviewExpandedFileIdsByThreadKeyAtom(threadKey); + const current = appAtomRegistry.get(atom); + const nextValue = update(current[sectionId]); + appAtomRegistry.set(atom, { + ...current, + [sectionId]: nextValue, + }); +} + +export function updateReviewRevealedLargeFileIds( + threadKey: string, + sectionId: string, + update: (current: ReadonlyArray | undefined) => ReadonlyArray | undefined, +): void { + const atom = reviewRevealedLargeFileIdsByThreadKeyAtom(threadKey); + const current = appAtomRegistry.get(atom); + const nextValue = update(current[sectionId]); + appAtomRegistry.set(atom, { + ...current, + [sectionId]: nextValue, + }); +} + +export function updateReviewViewedFileIds( + threadKey: string, + sectionId: string, + update: (current: ReadonlyArray | undefined) => ReadonlyArray | undefined, +): void { + const atom = reviewViewedFileIdsByThreadKeyAtom(threadKey); + const current = appAtomRegistry.get(atom); + const nextValue = update(current[sectionId]); + appAtomRegistry.set(atom, { + ...current, + [sectionId]: nextValue, + }); +} + +export function getCachedReviewParsedDiff(input: { + readonly threadKey: string | null; + readonly sectionId: string | null; + readonly diff: string | null | undefined; +}): ReviewParsedDiff { + if (!input.threadKey || !input.sectionId) { + return buildReviewParsedDiff(input.diff, input.sectionId ?? "mobile-review"); + } + + const cacheKey = buildSectionCacheKey(input.threadKey, input.sectionId); + const normalizedDiff = input.diff?.trim() ?? null; + const atom = reviewParsedDiffBySectionCacheKeyAtom(cacheKey); + const cached = appAtomRegistry.get(atom); + if (cached && cached.diff === normalizedDiff) { + return cached.parsed; + } + + const parsed = buildReviewParsedDiff(input.diff, input.sectionId); + appAtomRegistry.set(atom, { + diff: normalizedDiff, + parsed, + }); + return parsed; +} diff --git a/apps/mobile/src/features/review/reviewWordDiffs.test.ts b/apps/mobile/src/features/review/reviewWordDiffs.test.ts new file mode 100644 index 00000000000..1ef081fc403 --- /dev/null +++ b/apps/mobile/src/features/review/reviewWordDiffs.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; + +import { applyDiffRangesToTokens, computeWordAltDiffRanges } from "./reviewWordDiffs"; + +describe("computeWordAltDiffRanges", () => { + it("joins adjacent word replacements across a single shared separator", () => { + const ranges = computeWordAltDiffRanges({ + deletionLine: "old old", + additionLine: "new new", + }); + + expect(ranges.deletion).toEqual([{ start: 0, end: "old old".length }]); + expect(ranges.addition).toEqual([{ start: 0, end: "new new".length }]); + }); + + it("skips inline word diffs for long lines", () => { + const longDeletion = `const before = "${"a".repeat(1_001)}";`; + const longAddition = `const after = "${"b".repeat(1_001)}";`; + + const ranges = computeWordAltDiffRanges({ + deletionLine: longDeletion, + additionLine: longAddition, + }); + + expect(ranges.deletion).toEqual([]); + expect(ranges.addition).toEqual([]); + }); +}); + +describe("applyDiffRangesToTokens", () => { + it("splits highlighted fragments out of syntax tokens", () => { + const tokens = [ + { + content: "const before = 1;", + color: "#fff", + fontStyle: null, + }, + ]; + const nextTokens = applyDiffRangesToTokens(tokens, [{ start: 6, end: 12 }]); + + expect(nextTokens).toEqual([ + { + content: "const ", + color: "#fff", + fontStyle: null, + diffHighlight: false, + }, + { + content: "before", + color: "#fff", + fontStyle: null, + diffHighlight: true, + }, + { + content: " = 1;", + color: "#fff", + fontStyle: null, + diffHighlight: false, + }, + ]); + }); + + it("skips exhausted ranges when later tokens start after the last range", () => { + const tokens = [ + { + content: "a", + color: "#fff", + fontStyle: null, + }, + { + content: "b", + color: "#fff", + fontStyle: null, + }, + ]; + + const nextTokens = applyDiffRangesToTokens(tokens, [{ start: 0, end: 1 }]); + + expect(nextTokens).toEqual([ + { + content: "a", + color: "#fff", + fontStyle: null, + diffHighlight: true, + }, + { + content: "b", + color: "#fff", + fontStyle: null, + }, + ]); + }); +}); diff --git a/apps/mobile/src/features/review/reviewWordDiffs.ts b/apps/mobile/src/features/review/reviewWordDiffs.ts new file mode 100644 index 00000000000..34ac9bc7a74 --- /dev/null +++ b/apps/mobile/src/features/review/reviewWordDiffs.ts @@ -0,0 +1,266 @@ +import { diffWordsWithSpace } from "diff"; + +import type { ReviewHighlightedToken } from "./shikiReviewHighlighter"; + +interface ReviewDiffOperation { + readonly value: string; + readonly added?: true; + readonly removed?: true; +} + +interface ReviewDiffHighlightRange { + readonly start: number; + readonly end: number; +} + +const REVIEW_MAX_WORD_ALT_LINE_LENGTH = 1_000; + +function cleanLineEnding(value: string): string { + return value.endsWith("\n") ? value.slice(0, -1) : value; +} + +function pushOrJoinSpan(input: { + readonly operation: ReviewDiffOperation; + readonly spans: Array<[0 | 1, string]>; + readonly enableJoin: boolean; + readonly isNeutral?: boolean; + readonly isLastOperation?: boolean; +}): void { + const { operation, spans, enableJoin, isNeutral = false, isLastOperation = false } = input; + const lastSpan = spans.at(-1); + + if (!lastSpan || isLastOperation || !enableJoin) { + spans.push([isNeutral ? 0 : 1, operation.value]); + return; + } + + const lastSpanIsNeutral = lastSpan[0] === 0; + if ( + isNeutral === lastSpanIsNeutral || + (isNeutral && operation.value.length === 1 && !lastSpanIsNeutral) + ) { + lastSpan[1] += operation.value; + return; + } + + spans.push([isNeutral ? 0 : 1, operation.value]); +} + +function spansToRanges( + spans: ReadonlyArray, +): ReadonlyArray { + const ranges: ReviewDiffHighlightRange[] = []; + let offset = 0; + + spans.forEach(([kind, value]) => { + const nextOffset = offset + value.length; + if (kind === 1 && value.length > 0) { + ranges.push({ start: offset, end: nextOffset }); + } + offset = nextOffset; + }); + + return ranges; +} + +function mergeNearbyRanges( + ranges: ReadonlyArray, +): ReadonlyArray { + if (ranges.length <= 1) { + return ranges; + } + + const merged: ReviewDiffHighlightRange[] = []; + + ranges.forEach((range) => { + const previous = merged.at(-1); + if (previous && range.start - previous.end <= 1) { + merged[merged.length - 1] = { start: previous.start, end: range.end }; + return; + } + merged.push({ ...range }); + }); + + return merged; +} + +function appendTokenSegment( + target: ReviewHighlightedToken[], + source: ReviewHighlightedToken, + content: string, + diffHighlight: boolean, +): void { + if (content.length === 0) { + return; + } + + const previous = target.at(-1); + if ( + previous && + previous.color === source.color && + previous.fontStyle === source.fontStyle && + previous.diffHighlight === diffHighlight + ) { + previous.content += content; + return; + } + + target.push({ + content, + color: source.color, + fontStyle: source.fontStyle, + diffHighlight, + }); +} + +export function computeWordAltDiffRanges(input: { + readonly deletionLine: string; + readonly additionLine: string; +}): { + readonly deletion: ReadonlyArray; + readonly addition: ReadonlyArray; +} { + const deletionLine = cleanLineEnding(input.deletionLine); + const additionLine = cleanLineEnding(input.additionLine); + + if (deletionLine.length === 0 && additionLine.length === 0) { + return { deletion: [], addition: [] }; + } + + if ( + deletionLine.length > REVIEW_MAX_WORD_ALT_LINE_LENGTH || + additionLine.length > REVIEW_MAX_WORD_ALT_LINE_LENGTH + ) { + return { deletion: [], addition: [] }; + } + + const operations = diffWordsWithSpace( + deletionLine, + additionLine, + ) as ReadonlyArray; + const deletionSpans: Array<[0 | 1, string]> = []; + const additionSpans: Array<[0 | 1, string]> = []; + const lastOperation = operations.at(-1); + + operations.forEach((operation) => { + const isLastOperation = operation === lastOperation; + if (!operation.added && !operation.removed) { + pushOrJoinSpan({ + operation, + spans: deletionSpans, + enableJoin: true, + isNeutral: true, + isLastOperation, + }); + pushOrJoinSpan({ + operation, + spans: additionSpans, + enableJoin: true, + isNeutral: true, + isLastOperation, + }); + return; + } + + if (operation.removed) { + pushOrJoinSpan({ + operation, + spans: deletionSpans, + enableJoin: true, + isLastOperation, + }); + return; + } + + pushOrJoinSpan({ + operation, + spans: additionSpans, + enableJoin: true, + isLastOperation, + }); + }); + + return { + deletion: mergeNearbyRanges(spansToRanges(deletionSpans)), + addition: mergeNearbyRanges(spansToRanges(additionSpans)), + }; +} + +export function applyDiffRangesToTokens( + tokens: ReadonlyArray, + ranges: ReadonlyArray, +): ReadonlyArray { + if (tokens.length === 0 || ranges.length === 0) { + return tokens; + } + + const nextTokens: ReviewHighlightedToken[] = []; + let tokenOffset = 0; + let rangeIndex = 0; + + tokens.forEach((token) => { + const tokenStart = tokenOffset; + const tokenEnd = tokenStart + token.content.length; + tokenOffset = tokenEnd; + + if (token.content.length === 0) { + nextTokens.push(token); + return; + } + + while (rangeIndex < ranges.length && ranges[rangeIndex]!.end <= tokenStart) { + rangeIndex += 1; + } + + if ((ranges[rangeIndex]?.start ?? Number.POSITIVE_INFINITY) >= tokenEnd) { + nextTokens.push(token.diffHighlight ? { ...token, diffHighlight: false } : token); + return; + } + + let cursor = tokenStart; + let localRangeIndex = rangeIndex; + + while ((ranges[localRangeIndex]?.start ?? Number.POSITIVE_INFINITY) < tokenEnd) { + const range = ranges[localRangeIndex]; + if (!range) { + break; + } + + if (range.start > cursor) { + appendTokenSegment( + nextTokens, + token, + token.content.slice(cursor - tokenStart, range.start - tokenStart), + false, + ); + } + + const highlightedStart = Math.max(cursor, range.start); + const highlightedEnd = Math.min(tokenEnd, range.end); + + if (highlightedEnd > highlightedStart) { + appendTokenSegment( + nextTokens, + token, + token.content.slice(highlightedStart - tokenStart, highlightedEnd - tokenStart), + true, + ); + } + + cursor = highlightedEnd; + if (range.end <= tokenEnd) { + localRangeIndex += 1; + } else { + break; + } + } + + if (cursor < tokenEnd) { + appendTokenSegment(nextTokens, token, token.content.slice(cursor - tokenStart), false); + } + + rangeIndex = localRangeIndex; + }); + + return nextTokens; +} diff --git a/apps/mobile/src/features/review/shikiReviewHighlighter.test.ts b/apps/mobile/src/features/review/shikiReviewHighlighter.test.ts new file mode 100644 index 00000000000..402d00668db --- /dev/null +++ b/apps/mobile/src/features/review/shikiReviewHighlighter.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from "vitest"; + +import type { ReviewRenderableFile } from "./reviewModel"; +import { highlightReviewFile } from "./shikiReviewHighlighter"; + +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("highlightReviewFile", () => { + it("preserves one highlighted token row per diff line even without trailing newlines", async () => { + const file = makeRenderableFile({ + path: "apps/mobile/src/example.txt", + additionLines: [ + 'const items = ["a"];', + 'expect(items).toEqual(["a"]);', + "const next = items.map((item) => item.toUpperCase());", + 'expect(next).toContain("A");', + ], + }); + + const highlighted = await highlightReviewFile(file, "light"); + + expect(highlighted.additionLines).toHaveLength(file.additionLines.length); + expect(highlighted.additionLines[0]?.map((token) => token.content).join("")).toBe( + file.additionLines[0], + ); + expect(highlighted.additionLines[1]?.map((token) => token.content).join("")).toBe( + file.additionLines[1], + ); + expect(highlighted.additionLines[2]?.map((token) => token.content).join("")).toBe( + file.additionLines[2], + ); + expect(highlighted.additionLines[3]?.map((token) => token.content).join("")).toBe( + file.additionLines[3], + ); + }); + + it("adds word-alt diff emphasis for paired deletion and addition lines", async () => { + const file = makeRenderableFile({ + path: "apps/mobile/src/example-inline-diff.txt", + additionLines: ["const after = 2;"], + deletionLines: ["const before = 1;"], + rows: [ + { + kind: "line", + id: "delete-1", + change: "delete", + oldLineNumber: 1, + newLineNumber: null, + content: "const before = 1;", + additionTokenIndex: null, + deletionTokenIndex: 0, + comparison: { change: "add", tokenIndex: 0 }, + }, + { + kind: "line", + id: "add-1", + change: "add", + oldLineNumber: null, + newLineNumber: 1, + content: "const after = 2;", + additionTokenIndex: 0, + deletionTokenIndex: null, + comparison: { change: "delete", tokenIndex: 0 }, + }, + ], + }); + + const highlighted = await highlightReviewFile(file, "light"); + + expect(highlighted.deletionLines[0]?.some((token) => token.diffHighlight === true)).toBe(true); + expect(highlighted.additionLines[0]?.some((token) => token.diffHighlight === true)).toBe(true); + }); + + it("falls back to plain tokens for very long lines", async () => { + const longLine = `const value = "${"a".repeat(1_100)}";`; + const file = makeRenderableFile({ + path: "apps/mobile/src/example-long-line.txt", + additionLines: [longLine], + rows: [ + { + kind: "line", + id: "add-1", + change: "add", + oldLineNumber: null, + newLineNumber: 1, + content: longLine, + additionTokenIndex: 0, + deletionTokenIndex: null, + comparison: null, + }, + ], + }); + + const highlighted = await highlightReviewFile(file, "light"); + + expect(highlighted.additionLines).toHaveLength(1); + expect(highlighted.additionLines[0]).toEqual([ + { + content: longLine, + color: null, + fontStyle: null, + }, + ]); + }); +}); diff --git a/apps/mobile/src/features/review/shikiReviewHighlighter.ts b/apps/mobile/src/features/review/shikiReviewHighlighter.ts new file mode 100644 index 00000000000..17e7ff49678 --- /dev/null +++ b/apps/mobile/src/features/review/shikiReviewHighlighter.ts @@ -0,0 +1,1034 @@ +import { createHighlighterCore, type HighlighterCore } from "@shikijs/core"; +import { createJavaScriptRegexEngine } from "@shikijs/engine-javascript"; +import bashLanguage from "@shikijs/langs/bash"; +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 githubDarkDefault from "@shikijs/themes/github-dark-default"; +import githubLightDefault from "@shikijs/themes/github-light-default"; +import { getFiletypeFromFileName } from "@pierre/diffs/utils/getFiletypeFromFileName"; + +import { + resolveReviewHighlighterEngine, + resolveReviewHighlighterEnginePreference, + type ReviewHighlighterEngine, +} from "./reviewHighlighterEngine"; +import type { ReviewRenderableFile, ReviewRenderableLineRow } from "./reviewModel"; +import { applyDiffRangesToTokens, computeWordAltDiffRanges } from "./reviewWordDiffs"; + +export type ReviewDiffTheme = "light" | "dark"; +export type { ReviewHighlighterEngine }; + +export interface ReviewHighlightedToken { + content: string; + readonly color: string | null; + readonly fontStyle: number | null; + readonly diffHighlight?: boolean; +} + +export interface ReviewHighlightedFile { + readonly additionLines: ReadonlyArray>; + readonly deletionLines: ReadonlyArray>; +} + +export interface ReviewHighlightFileProgress { + readonly highlightedFile: ReviewHighlightedFile; + readonly complete: boolean; + readonly highlightedLineCount: number; +} + +const SHIKI_THEME_NAME_BY_SCHEME = { + light: "github-light-default", + dark: "github-dark-default", +} as const; +const REVIEW_HIGHLIGHTER_ENGINE_ENV_VALUE = + process.env.EXPO_PUBLIC_REVIEW_HIGHLIGHTER_ENGINE ?? + (process.env.NODE_ENV === "test" ? "javascript" : "native"); +const REVIEW_HIGHLIGHTER_ENGINE_PREFERENCE = resolveReviewHighlighterEnginePreference( + REVIEW_HIGHLIGHTER_ENGINE_ENV_VALUE, +); +const REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE = resolveReviewHighlighterBooleanFlag( + process.env.EXPO_PUBLIC_REVIEW_HIGHLIGHTER_DISABLE_CACHE, + false, +); +const REVIEW_HIGHLIGHT_RESULT_CACHE_LIMIT = 8; +const REVIEW_HIGHLIGHT_CHUNK_LINE_THRESHOLD = 8; +const REVIEW_HIGHLIGHT_CHUNK_SIZE = 200; +const REVIEW_TOKENIZE_MAX_LINE_LENGTH = 1_000; +const highlightCache = new Map>(); +const resolvedHighlightCache = new Map(); +const REVIEW_INITIAL_LANGUAGE_MODULES = [ + bashLanguage, + javascriptLanguage, + jsonLanguage, + jsxLanguage, + tsxLanguage, + typescriptLanguage, + yamlLanguage, +] satisfies Parameters[0]["langs"]; +const loadedLanguages = new Set([ + "text", + "bash", + "javascript", + "json", + "jsx", + "tsx", + "typescript", + "yaml", +]); +const languageLoadingPromises = new Map>(); +const languageImports: Partial Promise>> = { + javascript: () => import("@shikijs/langs/javascript"), + typescript: () => import("@shikijs/langs/typescript"), + jsx: () => import("@shikijs/langs/jsx"), + tsx: () => import("@shikijs/langs/tsx"), + python: () => import("@shikijs/langs/python"), + rust: () => import("@shikijs/langs/rust"), + go: () => import("@shikijs/langs/go"), + java: () => import("@shikijs/langs/java"), + kotlin: () => import("@shikijs/langs/kotlin"), + swift: () => import("@shikijs/langs/swift"), + "objective-c": () => import("@shikijs/langs/objective-c"), + c: () => import("@shikijs/langs/c"), + cpp: () => import("@shikijs/langs/cpp"), + csharp: () => import("@shikijs/langs/csharp"), + php: () => import("@shikijs/langs/php"), + ruby: () => import("@shikijs/langs/ruby"), + lua: () => import("@shikijs/langs/lua"), + perl: () => import("@shikijs/langs/perl"), + r: () => import("@shikijs/langs/r"), + dart: () => import("@shikijs/langs/dart"), + scala: () => import("@shikijs/langs/scala"), + elixir: () => import("@shikijs/langs/elixir"), + haskell: () => import("@shikijs/langs/haskell"), + clojure: () => import("@shikijs/langs/clojure"), + ocaml: () => import("@shikijs/langs/ocaml"), + fsharp: () => import("@shikijs/langs/fsharp"), + erlang: () => import("@shikijs/langs/erlang"), + zig: () => import("@shikijs/langs/zig"), + nim: () => import("@shikijs/langs/nim"), + html: () => import("@shikijs/langs/html"), + css: () => import("@shikijs/langs/css"), + scss: () => import("@shikijs/langs/scss"), + less: () => import("@shikijs/langs/less"), + xml: () => import("@shikijs/langs/xml"), + svg: () => import("@shikijs/langs/xml"), + vue: () => import("@shikijs/langs/vue"), + svelte: () => import("@shikijs/langs/svelte"), + astro: () => import("@shikijs/langs/astro"), + json: () => import("@shikijs/langs/json"), + jsonc: () => import("@shikijs/langs/jsonc"), + yaml: () => import("@shikijs/langs/yaml"), + toml: () => import("@shikijs/langs/toml"), + ini: () => import("@shikijs/langs/ini"), + bash: () => import("@shikijs/langs/bash"), + shellscript: () => import("@shikijs/langs/shellscript"), + powershell: () => import("@shikijs/langs/powershell"), + fish: () => import("@shikijs/langs/fish"), + sql: () => import("@shikijs/langs/sql"), + graphql: () => import("@shikijs/langs/graphql"), + prisma: () => import("@shikijs/langs/prisma"), + docker: () => import("@shikijs/langs/docker"), + hcl: () => import("@shikijs/langs/hcl"), + nix: () => import("@shikijs/langs/nix"), + markdown: () => import("@shikijs/langs/markdown"), + mdx: () => import("@shikijs/langs/mdx"), + tex: () => import("@shikijs/langs/tex"), + diff: () => import("@shikijs/langs/diff"), + regex: () => import("@shikijs/langs/regex"), + viml: () => import("@shikijs/langs/viml"), + makefile: () => import("@shikijs/langs/makefile"), + cmake: () => import("@shikijs/langs/cmake"), + groovy: () => import("@shikijs/langs/groovy"), +}; + +const languageAliases: Record = { + js: "javascript", + mjs: "javascript", + cjs: "javascript", + ts: "typescript", + mts: "typescript", + cts: "typescript", + py: "python", + rb: "ruby", + rs: "rust", + sh: "bash", + zsh: "bash", + shell: "shellscript", + yml: "yaml", + md: "markdown", + "c++": "cpp", + "c#": "csharp", + cs: "csharp", + dockerfile: "docker", + vim: "viml", + objc: "objective-c", + objectivec: "objective-c", + "obj-c": "objective-c", + ps1: "powershell", + pwsh: "powershell", + hs: "haskell", + ex: "elixir", + exs: "elixir", + erl: "erlang", + clj: "clojure", + ml: "ocaml", + fs: "fsharp", + tf: "hcl", + make: "makefile", + plain: "text", + plaintext: "text", + txt: "text", +}; +let highlighterPromise: Promise | null = null; +let activeHighlighterEnginePromise: Promise | null = null; + +type LoadedLanguageModule = { + default: Parameters[0]; +}; + +function resolveReviewHighlighterBooleanFlag( + value: string | undefined, + defaultValue: boolean, +): boolean { + switch (value) { + case "1": + case "true": + return true; + case "0": + case "false": + return false; + default: + return defaultValue; + } +} + +function isReviewHighlighterDebugLoggingEnabled(): boolean { + return typeof __DEV__ !== "undefined" ? __DEV__ : false; +} + +function logReviewHighlighterDiagnostic(message: string, details?: Record): void { + if (!isReviewHighlighterDebugLoggingEnabled()) { + return; + } + + if (details) { + console.log(`[review-highlighter] ${message}`, details); + return; + } + + console.log(`[review-highlighter] ${message}`); +} + +function logReviewHighlighterDiagnosticError(message: string, error: unknown): void { + if (!isReviewHighlighterDebugLoggingEnabled()) { + return; + } + + if (error instanceof Error) { + console.error(`[review-highlighter] ${message}`, { + name: error.name, + message: error.message, + stack: error.stack, + }); + return; + } + + console.error(`[review-highlighter] ${message}`, error); +} + +function stripTrailingNewline(value: string): string { + return value.endsWith("\n") ? value.slice(0, -1) : value; +} + +function joinPatchLines(lines: ReadonlyArray): string { + return lines.map(stripTrailingNewline).join("\n"); +} + +function waitForNextFrame(): Promise { + return new Promise((resolve) => { + setTimeout(resolve, 0); + }); +} + +async function getHighlighter(): Promise { + if (!highlighterPromise) { + const configuredHighlighterPromise = (async () => { + let nativeEngineAvailable = false; + + logReviewHighlighterDiagnostic("initializing", { + configuredPreference: REVIEW_HIGHLIGHTER_ENGINE_ENV_VALUE, + preference: REVIEW_HIGHLIGHTER_ENGINE_PREFERENCE, + resultCacheDisabled: REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE, + }); + + const themes = [githubLightDefault, githubDarkDefault]; + + if (REVIEW_HIGHLIGHTER_ENGINE_PREFERENCE !== "javascript") { + try { + const nativeEngineModule = await import("react-native-shiki-engine"); + nativeEngineAvailable = nativeEngineModule.isNativeEngineAvailable(); + logReviewHighlighterDiagnostic("checked native engine availability", { + nativeEngineAvailable, + }); + + if (nativeEngineAvailable) { + logReviewHighlighterDiagnostic("creating native regex engine"); + const highlighter = await createHighlighterCore({ + themes, + langs: REVIEW_INITIAL_LANGUAGE_MODULES, + engine: nativeEngineModule.createNativeEngine(), + }); + logReviewHighlighterDiagnostic("using native engine"); + return { + highlighter, + engine: "native" as const, + }; + } + } catch (error) { + logReviewHighlighterDiagnosticError( + "native engine initialization failed; falling back to javascript", + error, + ); + nativeEngineAvailable = false; + } + } else { + logReviewHighlighterDiagnostic("skipping native engine probe", { + reason: "preference-forced-javascript", + }); + } + + const engine = resolveReviewHighlighterEngine( + REVIEW_HIGHLIGHTER_ENGINE_PREFERENCE, + nativeEngineAvailable, + ); + const highlighter = await createHighlighterCore({ + themes, + langs: REVIEW_INITIAL_LANGUAGE_MODULES, + engine: createJavaScriptRegexEngine(), + }); + logReviewHighlighterDiagnostic("using javascript engine", { + resolvedEngine: engine, + }); + return { + highlighter, + engine, + }; + })(); + + highlighterPromise = configuredHighlighterPromise + .then((result) => result.highlighter) + .catch((error) => { + highlighterPromise = null; + activeHighlighterEnginePromise = null; + throw error; + }); + activeHighlighterEnginePromise = configuredHighlighterPromise + .then((result) => result.engine) + .catch((error) => { + activeHighlighterEnginePromise = null; + throw error; + }); + } + + return highlighterPromise; +} + +export async function getActiveReviewHighlighterEngine(): Promise { + await getHighlighter(); + return activeHighlighterEnginePromise ?? Promise.resolve("javascript"); +} + +export async function prepareReviewHighlighter(): Promise { + await getHighlighter(); +} + +export async function prepareReviewHighlighterLanguages( + languages: ReadonlyArray, +): Promise { + const highlighter = await getHighlighter(); + await Promise.all( + languages.map(async (language) => { + const candidate = resolveLanguageAlias(language); + if (candidate === "text" || !(candidate in languageImports)) { + return; + } + + await loadSingleLanguage(highlighter, candidate); + }), + ); +} + +function resolveLanguageAlias(language: string): string { + const normalized = language.toLowerCase(); + return languageAliases[normalized] ?? normalized; +} + +function resolveLoadedLanguageFromPath( + path: string, + languageHint: string | null = null, +): string | null { + const detectedLanguage = languageHint ?? getFiletypeFromFileName(path); + if (!detectedLanguage) { + return "text"; + } + + const candidate = resolveLanguageAlias(detectedLanguage); + if (candidate === "text" || candidate === "ansi") { + return "text"; + } + + if (!(candidate in languageImports)) { + return "text"; + } + + return loadedLanguages.has(candidate) ? candidate : null; +} + +async function loadSingleLanguage( + highlighter: HighlighterCore, + language: string, +): Promise { + if (loadedLanguages.has(language)) { + return true; + } + + const existingPromise = languageLoadingPromises.get(language); + if (existingPromise) { + return existingPromise; + } + + const importer = languageImports[language]; + if (!importer) { + return false; + } + + const loadingPromise = (async () => { + try { + const languageModule = (await importer()) as LoadedLanguageModule; + await highlighter.loadLanguage(languageModule.default); + loadedLanguages.add(language); + return true; + } catch { + return false; + } finally { + languageLoadingPromises.delete(language); + } + })(); + + languageLoadingPromises.set(language, loadingPromise); + return loadingPromise; +} + +async function resolveLanguageFromPath( + path: string, + languageHint: string | null = null, +): Promise { + const loadedLanguage = resolveLoadedLanguageFromPath(path, languageHint); + if (loadedLanguage) { + return loadedLanguage; + } + + const detectedLanguage = languageHint ?? getFiletypeFromFileName(path); + if (!detectedLanguage) { + return "text"; + } + + const candidate = resolveLanguageAlias(detectedLanguage); + if (candidate === "text" || candidate === "ansi") { + return "text"; + } + + if (!(candidate in languageImports)) { + return "text"; + } + + if (loadedLanguages.has(candidate)) { + return candidate; + } + + const highlighter = await getHighlighter(); + const loaded = await loadSingleLanguage(highlighter, candidate); + if (!loaded) { + return "text"; + } + + return candidate; +} + +async function resolveLanguage(file: ReviewRenderableFile): Promise { + return ( + resolveLoadedLanguageFromPath(file.path, file.languageHint) ?? + (await resolveLanguageFromPath(file.path, file.languageHint)) + ); +} + +function normalizeHighlightedLines( + tokenLines: ReadonlyArray>, +): ReadonlyArray> { + return tokenLines.map((line) => + line.map((token) => ({ + content: token.content, + color: token.color ?? null, + fontStyle: token.fontStyle ?? null, + })), + ); +} + +function makePlainHighlightedLines( + lines: ReadonlyArray, +): ReadonlyArray> { + return lines.map((line) => [ + { + content: stripTrailingNewline(line), + color: null, + fontStyle: null, + }, + ]); +} + +function applyWordAltDiffHighlightsToFile( + file: ReviewRenderableFile, + highlighted: ReviewHighlightedFile, +): ReviewHighlightedFile { + const nextAdditionLines = [...highlighted.additionLines]; + const nextDeletionLines = [...highlighted.deletionLines]; + const processedPairs = new Set(); + let changed = false; + + file.rows.forEach((row) => { + if (row.kind !== "line" || row.change === "context" || !row.comparison) { + return; + } + + const deletionTokenIndex = + row.change === "delete" + ? row.deletionTokenIndex + : row.comparison.change === "delete" + ? row.comparison.tokenIndex + : null; + const additionTokenIndex = + row.change === "add" + ? row.additionTokenIndex + : row.comparison.change === "add" + ? row.comparison.tokenIndex + : null; + + if (deletionTokenIndex === null || additionTokenIndex === null) { + return; + } + + const pairKey = `${deletionTokenIndex}:${additionTokenIndex}`; + if (processedPairs.has(pairKey)) { + return; + } + processedPairs.add(pairKey); + + const deletionLine = stripTrailingNewline(file.deletionLines[deletionTokenIndex] ?? ""); + const additionLine = stripTrailingNewline(file.additionLines[additionTokenIndex] ?? ""); + const ranges = computeWordAltDiffRanges({ deletionLine, additionLine }); + + if (ranges.deletion.length > 0) { + nextDeletionLines[deletionTokenIndex] = applyDiffRangesToTokens( + nextDeletionLines[deletionTokenIndex] ?? [], + ranges.deletion, + ); + changed = true; + } + + if (ranges.addition.length > 0) { + nextAdditionLines[additionTokenIndex] = applyDiffRangesToTokens( + nextAdditionLines[additionTokenIndex] ?? [], + ranges.addition, + ); + changed = true; + } + }); + + return changed + ? { + additionLines: nextAdditionLines, + deletionLines: nextDeletionLines, + } + : highlighted; +} + +function applyWordAltDiffHighlightsToSelectedLines(input: { + readonly lines: ReadonlyArray; + readonly tokenMap: Record>; +}): Record> { + const additionLineByTokenIndex = new Map(); + const deletionLineByTokenIndex = new Map(); + + input.lines.forEach((line) => { + if (line.change === "add" && line.additionTokenIndex !== null) { + additionLineByTokenIndex.set(line.additionTokenIndex, line); + } + if (line.change === "delete" && line.deletionTokenIndex !== null) { + deletionLineByTokenIndex.set(line.deletionTokenIndex, line); + } + }); + + const nextTokenMap = { ...input.tokenMap }; + const processedPairs = new Set(); + + input.lines.forEach((line) => { + if (line.change === "context" || !line.comparison) { + return; + } + + const pairedDeletionLine = + line.change === "delete" + ? line + : line.comparison.change === "delete" + ? deletionLineByTokenIndex.get(line.comparison.tokenIndex) + : undefined; + const pairedAdditionLine = + line.change === "add" + ? line + : line.comparison.change === "add" + ? additionLineByTokenIndex.get(line.comparison.tokenIndex) + : undefined; + + if ( + !pairedDeletionLine || + !pairedAdditionLine || + pairedDeletionLine.deletionTokenIndex === null || + pairedAdditionLine.additionTokenIndex === null + ) { + return; + } + + const pairKey = `${pairedDeletionLine.deletionTokenIndex}:${pairedAdditionLine.additionTokenIndex}`; + if (processedPairs.has(pairKey)) { + return; + } + processedPairs.add(pairKey); + + const ranges = computeWordAltDiffRanges({ + deletionLine: pairedDeletionLine.content, + additionLine: pairedAdditionLine.content, + }); + + if (ranges.deletion.length > 0) { + nextTokenMap[pairedDeletionLine.id] = applyDiffRangesToTokens( + nextTokenMap[pairedDeletionLine.id] ?? [], + ranges.deletion, + ); + } + if (ranges.addition.length > 0) { + nextTokenMap[pairedAdditionLine.id] = applyDiffRangesToTokens( + nextTokenMap[pairedAdditionLine.id] ?? [], + ranges.addition, + ); + } + }); + + return nextTokenMap; +} + +async function highlightLines( + code: string, + language: string, + theme: string, +): Promise>> { + if (code.length === 0) { + return []; + } + + const highlighter = await getHighlighter(); + const sourceLines = code.split("\n"); + const highlightedLines: Array> = []; + const shortLineBatch: string[] = []; + + const flushShortLineBatch = async (): Promise => { + if (shortLineBatch.length === 0) { + return; + } + + const tokenLines = highlighter.codeToTokensBase(shortLineBatch.join("\n"), { + lang: language, + theme, + }); + highlightedLines.push(...normalizeHighlightedLines(tokenLines)); + shortLineBatch.length = 0; + }; + + for (let lineIndex = 0; lineIndex < sourceLines.length; lineIndex += 1) { + const line = sourceLines[lineIndex] ?? ""; + + if (line.length > REVIEW_TOKENIZE_MAX_LINE_LENGTH) { + await flushShortLineBatch(); + highlightedLines.push([{ content: line, color: null, fontStyle: null }]); + } else { + shortLineBatch.push(line); + } + + if (shortLineBatch.length >= REVIEW_HIGHLIGHT_CHUNK_SIZE) { + await flushShortLineBatch(); + } + + if ( + sourceLines.length > REVIEW_HIGHLIGHT_CHUNK_LINE_THRESHOLD && + lineIndex + 1 < sourceLines.length && + (shortLineBatch.length === 0 || line.length > REVIEW_TOKENIZE_MAX_LINE_LENGTH) + ) { + await waitForNextFrame(); + } + } + + await flushShortLineBatch(); + + return highlightedLines; +} + +async function highlightPatchLinesInChunks(input: { + readonly lines: ReadonlyArray; + readonly language: string; + readonly theme: string; + readonly onChunk: ( + startIndex: number, + tokens: ReadonlyArray>, + ) => void; +}): Promise>> { + if (input.lines.length === 0) { + return []; + } + + const highlighter = await getHighlighter(); + const highlightedLines: Array> = []; + + for ( + let startIndex = 0; + startIndex < input.lines.length; + startIndex += REVIEW_HIGHLIGHT_CHUNK_SIZE + ) { + const lineChunk = input.lines.slice(startIndex, startIndex + REVIEW_HIGHLIGHT_CHUNK_SIZE); + const chunkTokens: Array> = []; + const tokenizableLines: string[] = []; + const tokenizableIndexes: number[] = []; + + lineChunk.forEach((line, index) => { + const strippedLine = stripTrailingNewline(line); + if (strippedLine.length > REVIEW_TOKENIZE_MAX_LINE_LENGTH) { + chunkTokens[index] = [{ content: strippedLine, color: null, fontStyle: null }]; + return; + } + + tokenizableIndexes.push(index); + tokenizableLines.push(strippedLine); + }); + + if (tokenizableLines.length > 0) { + const tokenLines = highlighter.codeToTokensBase(tokenizableLines.join("\n"), { + lang: input.language, + theme: input.theme, + }); + const normalizedTokenLines = normalizeHighlightedLines(tokenLines); + + tokenizableIndexes.forEach((chunkIndex, tokenIndex) => { + chunkTokens[chunkIndex] = normalizedTokenLines[tokenIndex] ?? []; + }); + } + + const completedChunk = lineChunk.map((_, index) => chunkTokens[index] ?? []); + highlightedLines.push(...completedChunk); + input.onChunk(startIndex, completedChunk); + + if (startIndex + REVIEW_HIGHLIGHT_CHUNK_SIZE < input.lines.length) { + await waitForNextFrame(); + } + } + + return highlightedLines; +} + +function getHighlightCacheKey(file: ReviewRenderableFile, theme: ReviewDiffTheme): string { + return `${SHIKI_THEME_NAME_BY_SCHEME[theme]}:${file.cacheKey}`; +} + +function storeResolvedHighlightedFile(cacheKey: string, highlighted: ReviewHighlightedFile): void { + if (resolvedHighlightCache.has(cacheKey)) { + resolvedHighlightCache.delete(cacheKey); + } + + resolvedHighlightCache.set(cacheKey, highlighted); + + while (resolvedHighlightCache.size > REVIEW_HIGHLIGHT_RESULT_CACHE_LIMIT) { + const oldestKey = resolvedHighlightCache.keys().next().value; + if (oldestKey === undefined) { + break; + } + resolvedHighlightCache.delete(oldestKey); + } +} + +export function clearReviewHighlightFileCache(): void { + highlightCache.clear(); + resolvedHighlightCache.clear(); +} + +export function getCachedHighlightedReviewFile( + file: ReviewRenderableFile, + theme: ReviewDiffTheme, +): ReviewHighlightedFile | null { + if (REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) { + return null; + } + + return resolvedHighlightCache.get(getHighlightCacheKey(file, theme)) ?? null; +} + +export async function highlightReviewFile( + file: ReviewRenderableFile, + theme: ReviewDiffTheme, +): Promise { + const shikiTheme = SHIKI_THEME_NAME_BY_SCHEME[theme]; + const cacheKey = getHighlightCacheKey(file, theme); + if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) { + const resolved = resolvedHighlightCache.get(cacheKey); + if (resolved) { + logReviewHighlighterDiagnostic("file highlight cache hit (resolved)", { + fileId: file.id, + filePath: file.path, + theme, + }); + return resolved; + } + const cached = highlightCache.get(cacheKey); + if (cached) { + logReviewHighlighterDiagnostic("file highlight cache hit (pending)", { + fileId: file.id, + filePath: file.path, + theme, + }); + return cached; + } + } + + const promise = (async () => { + const startedAt = Date.now(); + logReviewHighlighterDiagnostic("file highlight start", { + fileId: file.id, + filePath: file.path, + theme, + additionLineCount: file.additionLines.length, + deletionLineCount: file.deletionLines.length, + rowCount: file.rows.length, + }); + const loadedLanguage = resolveLoadedLanguageFromPath(file.path, file.languageHint); + const language = loadedLanguage ?? (await resolveLanguage(file)); + if (language === "text") { + const highlighted = applyWordAltDiffHighlightsToFile(file, { + additionLines: makePlainHighlightedLines(file.additionLines), + deletionLines: makePlainHighlightedLines(file.deletionLines), + }); + if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) { + storeResolvedHighlightedFile(cacheKey, highlighted); + } + logReviewHighlighterDiagnostic("file highlight complete", { + fileId: file.id, + filePath: file.path, + theme, + language, + highlightedAdditionLineCount: highlighted.additionLines.length, + highlightedDeletionLineCount: highlighted.deletionLines.length, + durationMs: Date.now() - startedAt, + }); + return highlighted; + } + + const additionLines = await highlightLines( + joinPatchLines(file.additionLines), + language, + shikiTheme, + ); + await waitForNextFrame(); + const deletionLines = await highlightLines( + joinPatchLines(file.deletionLines), + language, + shikiTheme, + ); + await waitForNextFrame(); + + const highlighted = applyWordAltDiffHighlightsToFile(file, { additionLines, deletionLines }); + if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) { + storeResolvedHighlightedFile(cacheKey, highlighted); + } + logReviewHighlighterDiagnostic("file highlight complete", { + fileId: file.id, + filePath: file.path, + theme, + language, + highlightedAdditionLineCount: highlighted.additionLines.length, + highlightedDeletionLineCount: highlighted.deletionLines.length, + durationMs: Date.now() - startedAt, + }); + return highlighted; + })(); + + if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) { + highlightCache.set(cacheKey, promise); + } + return promise.finally(() => { + if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) { + highlightCache.delete(cacheKey); + } + }); +} + +export async function streamHighlightReviewFile( + file: ReviewRenderableFile, + theme: ReviewDiffTheme, + onProgress: (progress: ReviewHighlightFileProgress) => void, +): Promise { + const shikiTheme = SHIKI_THEME_NAME_BY_SCHEME[theme]; + const cacheKey = getHighlightCacheKey(file, theme); + if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) { + const resolved = resolvedHighlightCache.get(cacheKey); + if (resolved) { + onProgress({ + highlightedFile: resolved, + complete: true, + highlightedLineCount: resolved.additionLines.length + resolved.deletionLines.length, + }); + return resolved; + } + } + + const startedAt = Date.now(); + logReviewHighlighterDiagnostic("file stream highlight start", { + fileId: file.id, + filePath: file.path, + theme, + additionLineCount: file.additionLines.length, + deletionLineCount: file.deletionLines.length, + rowCount: file.rows.length, + }); + + const loadedLanguage = resolveLoadedLanguageFromPath(file.path, file.languageHint); + const language = loadedLanguage ?? (await resolveLanguage(file)); + if (language === "text") { + const highlighted = applyWordAltDiffHighlightsToFile(file, { + additionLines: makePlainHighlightedLines(file.additionLines), + deletionLines: makePlainHighlightedLines(file.deletionLines), + }); + if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) { + storeResolvedHighlightedFile(cacheKey, highlighted); + } + onProgress({ + highlightedFile: highlighted, + complete: true, + highlightedLineCount: highlighted.additionLines.length + highlighted.deletionLines.length, + }); + logReviewHighlighterDiagnostic("file stream highlight complete", { + fileId: file.id, + filePath: file.path, + theme, + language, + highlightedAdditionLineCount: highlighted.additionLines.length, + highlightedDeletionLineCount: highlighted.deletionLines.length, + highlightedLineCount: highlighted.additionLines.length + highlighted.deletionLines.length, + durationMs: Date.now() - startedAt, + }); + return highlighted; + } + + const additionLines: Array> = []; + const deletionLines: Array> = []; + let highlightedLineCount = 0; + + await highlightPatchLinesInChunks({ + lines: file.additionLines, + language, + theme: shikiTheme, + onChunk: (startIndex, tokens) => { + tokens.forEach((lineTokens, index) => { + additionLines[startIndex + index] = lineTokens; + }); + highlightedLineCount += tokens.length; + }, + }); + await waitForNextFrame(); + await highlightPatchLinesInChunks({ + lines: file.deletionLines, + language, + theme: shikiTheme, + onChunk: (startIndex, tokens) => { + tokens.forEach((lineTokens, index) => { + deletionLines[startIndex + index] = lineTokens; + }); + highlightedLineCount += tokens.length; + }, + }); + + const highlighted = applyWordAltDiffHighlightsToFile(file, { additionLines, deletionLines }); + if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) { + storeResolvedHighlightedFile(cacheKey, highlighted); + } + onProgress({ + highlightedFile: highlighted, + complete: true, + highlightedLineCount, + }); + logReviewHighlighterDiagnostic("file stream highlight complete", { + fileId: file.id, + filePath: file.path, + theme, + language, + highlightedAdditionLineCount: highlighted.additionLines.length, + highlightedDeletionLineCount: highlighted.deletionLines.length, + highlightedLineCount, + durationMs: Date.now() - startedAt, + }); + return highlighted; +} + +export async function highlightReviewSelectedLines(input: { + readonly filePath: string; + readonly lines: ReadonlyArray; + readonly theme: ReviewDiffTheme; + readonly languageHint?: string | null; +}): Promise>> { + if (input.lines.length === 0) { + return {}; + } + + const loadedLanguage = resolveLoadedLanguageFromPath(input.filePath, input.languageHint ?? null); + const language = + loadedLanguage ?? (await resolveLanguageFromPath(input.filePath, input.languageHint ?? null)); + const shikiTheme = SHIKI_THEME_NAME_BY_SCHEME[input.theme]; + const additionLikeLines = input.lines + .filter((line) => line.change !== "delete") + .map((line) => `${line.content}\n`); + const deletionLines = input.lines + .filter((line) => line.change === "delete") + .map((line) => `${line.content}\n`); + const [additionTokens, deletionTokens] = await Promise.all([ + highlightLines(joinPatchLines(additionLikeLines), language, shikiTheme), + highlightLines(joinPatchLines(deletionLines), language, shikiTheme), + ]); + + const tokenMap: Record> = {}; + let additionIndex = 0; + let deletionIndex = 0; + + input.lines.forEach((line) => { + if (line.change === "delete") { + tokenMap[line.id] = deletionTokens[deletionIndex] ?? []; + deletionIndex += 1; + return; + } + + tokenMap[line.id] = additionTokens[additionIndex] ?? []; + additionIndex += 1; + }); + + return applyWordAltDiffHighlightsToSelectedLines({ + lines: input.lines, + tokenMap, + }); +} diff --git a/apps/mobile/src/features/review/useNativeReviewDiffBridge.test.ts b/apps/mobile/src/features/review/useNativeReviewDiffBridge.test.ts new file mode 100644 index 00000000000..479e34cc8f1 --- /dev/null +++ b/apps/mobile/src/features/review/useNativeReviewDiffBridge.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; + +import { buildNativeReviewTokensResetKey, hashReviewDiffKey } from "./useNativeReviewDiffBridge"; + +describe("native review diff bridge", () => { + it("builds stable reset keys from the rendered diff identity", () => { + const input = { + threadKey: "env:thread", + sectionId: "turn:2", + scheme: "dark" as const, + diff: "diff --git a/a.ts b/a.ts", + fileCount: 1, + rowCount: 4, + }; + + expect(buildNativeReviewTokensResetKey(input)).toBe(buildNativeReviewTokensResetKey(input)); + expect(buildNativeReviewTokensResetKey({ ...input, rowCount: 5 })).not.toBe( + buildNativeReviewTokensResetKey(input), + ); + expect(buildNativeReviewTokensResetKey({ ...input, diff: null })).toContain(":empty:"); + }); + + it("includes diff length in the hash key to reduce accidental collisions", () => { + expect(hashReviewDiffKey("abc")).toMatch(/^3:/); + expect(hashReviewDiffKey("abcd")).toMatch(/^4:/); + }); +}); diff --git a/apps/mobile/src/features/review/useNativeReviewDiffBridge.ts b/apps/mobile/src/features/review/useNativeReviewDiffBridge.ts new file mode 100644 index 00000000000..d0030e51a4a --- /dev/null +++ b/apps/mobile/src/features/review/useNativeReviewDiffBridge.ts @@ -0,0 +1,152 @@ +import { useCallback, useMemo, useState } from "react"; +import type { NativeSyntheticEvent } from "react-native"; + +import { type NativeReviewDiffHighlightScheme } from "../diffs/nativeReviewDiffHighlighter"; +import { + createNativeReviewDiffTheme, + NATIVE_REVIEW_DIFF_STYLE, + type NativeReviewDiffData, +} from "./nativeReviewDiffAdapter"; +import { useNativeReviewDiffHighlighting } from "./useNativeReviewDiffHighlighting"; + +export function hashReviewDiffKey(diff: string | null | undefined): string { + if (!diff) { + return "empty"; + } + + let hash = 5381; + for (let index = 0; index < diff.length; index += 1) { + hash = (hash * 33) ^ diff.charCodeAt(index); + } + + return `${diff.length}:${(hash >>> 0).toString(36)}`; +} + +export function buildNativeReviewTokensResetKey(input: { + readonly threadKey: string | null; + readonly sectionId: string | null; + readonly scheme: NativeReviewDiffHighlightScheme; + readonly diff: string | null | undefined; + readonly fileCount: number; + readonly rowCount: number; +}): string { + return [ + input.threadKey ?? "none", + input.sectionId ?? "none", + input.scheme, + hashReviewDiffKey(input.diff), + input.fileCount, + input.rowCount, + ].join(":"); +} + +export function useNativeReviewDiffBridge(input: { + readonly threadKey: string | null; + readonly sectionId: string | null; + readonly diff: string | null | undefined; + readonly data: NativeReviewDiffData; + readonly scheme: NativeReviewDiffHighlightScheme; + readonly collapsedFileIds: ReadonlyArray; + readonly viewedFileIds: ReadonlyArray; + readonly selectedRowIds: ReadonlyArray; + readonly canHighlight: boolean; +}) { + const { + canHighlight, + collapsedFileIds, + data, + diff, + scheme, + sectionId, + selectedRowIds, + threadKey, + viewedFileIds, + } = input; + const [collapsedCommentIds, setCollapsedCommentIds] = useState>( + () => new Set(), + ); + + const theme = useMemo(() => createNativeReviewDiffTheme(scheme), [scheme]); + const rowsJson = useMemo(() => JSON.stringify(data.rows), [data.rows]); + const collapsedFileIdsJson = useMemo(() => JSON.stringify(collapsedFileIds), [collapsedFileIds]); + const viewedFileIdsJson = useMemo(() => JSON.stringify(viewedFileIds), [viewedFileIds]); + const selectedRowIdsJson = useMemo(() => JSON.stringify(selectedRowIds), [selectedRowIds]); + const collapsedCommentIdsJson = useMemo( + () => JSON.stringify(Array.from(collapsedCommentIds)), + [collapsedCommentIds], + ); + const themeJson = useMemo(() => JSON.stringify(theme), [theme]); + const styleJson = useMemo(() => JSON.stringify(NATIVE_REVIEW_DIFF_STYLE), []); + const tokensResetKey = useMemo( + () => + buildNativeReviewTokensResetKey({ + threadKey, + sectionId, + scheme, + diff, + fileCount: data.files.length, + rowCount: data.rows.length, + }), + [data.files.length, data.rows.length, diff, scheme, sectionId, threadKey], + ); + const { tokensPatchJson, updateVisibleRange } = useNativeReviewDiffHighlighting({ + files: data.files, + rows: data.rows, + scheme, + resetKey: tokensResetKey, + enabled: canHighlight, + }); + + const onDebug = useCallback( + (event: NativeSyntheticEvent>) => { + const payload = event.nativeEvent; + const message = payload.message; + if ( + (message === "draw-metrics" || message === "visible-range") && + typeof payload.firstRowIndex === "number" && + typeof payload.lastRowIndex === "number" + ) { + updateVisibleRange({ + firstRowIndex: payload.firstRowIndex, + lastRowIndex: payload.lastRowIndex, + }); + } + }, + [updateVisibleRange], + ); + + const onToggleComment = useCallback( + (event: NativeSyntheticEvent<{ readonly commentId?: string }>) => { + const { commentId } = event.nativeEvent; + if (!commentId) { + return; + } + + setCollapsedCommentIds((current) => { + const next = new Set(current); + if (next.has(commentId)) { + next.delete(commentId); + } else { + next.add(commentId); + } + return next; + }); + }, + [], + ); + + return { + theme, + rowsJson, + collapsedFileIdsJson, + collapsedCommentIdsJson, + viewedFileIdsJson, + selectedRowIdsJson, + themeJson, + styleJson, + tokensPatchJson, + tokensResetKey, + onDebug, + onToggleComment, + }; +} diff --git a/apps/mobile/src/features/review/useNativeReviewDiffHighlighting.ts b/apps/mobile/src/features/review/useNativeReviewDiffHighlighting.ts new file mode 100644 index 00000000000..98df26641a9 --- /dev/null +++ b/apps/mobile/src/features/review/useNativeReviewDiffHighlighting.ts @@ -0,0 +1,136 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +import { + highlightNativeReviewDiffVisibleRows, + type NativeReviewDiffHighlightEngine, + type NativeReviewDiffHighlightScheme, +} from "../diffs/nativeReviewDiffHighlighter"; +import type { NativeReviewDiffRow } from "../diffs/nativeReviewDiffSurface"; +import type { NativeReviewDiffFile } from "../diffs/nativeReviewDiffTypes"; + +interface NativeReviewVisibleRange { + readonly firstRowIndex: number; + readonly lastRowIndex: number; +} + +function createEmptyTokenPatch(resetKey: string): string { + return JSON.stringify({ resetKey, tokensByRowId: {} }); +} + +function isReviewDiffDebugLoggingEnabled(): boolean { + return typeof __DEV__ !== "undefined" ? __DEV__ : false; +} + +function logReviewDiffDiagnostic(message: string, details?: Record): void { + if (!isReviewDiffDebugLoggingEnabled()) { + return; + } + + if (details) { + console.log(`[review-sheet] ${message}`, details); + return; + } + + console.log(`[review-sheet] ${message}`); +} + +export function useNativeReviewDiffHighlighting(input: { + readonly files: ReadonlyArray; + readonly rows: ReadonlyArray; + readonly scheme: NativeReviewDiffHighlightScheme; + readonly resetKey: string; + readonly enabled: boolean; +}) { + const { enabled, files, resetKey, rows, scheme } = input; + const highlightedRowIdsRef = useRef>(new Set()); + const visibleRangeRef = useRef({ + firstRowIndex: 0, + lastRowIndex: 80, + }); + const visibleChunkIndexRef = useRef(0); + const [tokensPatchJson, setTokensPatchJson] = useState(() => createEmptyTokenPatch(resetKey)); + const [visibleHighlightRequest, setVisibleHighlightRequest] = useState(0); + + useEffect(() => { + highlightedRowIdsRef.current = new Set(); + visibleChunkIndexRef.current = 0; + visibleRangeRef.current = { firstRowIndex: 0, lastRowIndex: 80 }; + setTokensPatchJson(createEmptyTokenPatch(resetKey)); + if (enabled && rows.length > 0) { + setVisibleHighlightRequest((request) => request + 1); + } + }, [enabled, resetKey, rows.length]); + + useEffect(() => { + if (!enabled || rows.length === 0) { + return; + } + + const abortController = new AbortController(); + const requestRange = visibleRangeRef.current; + const engine: NativeReviewDiffHighlightEngine = "native"; + + void (async () => { + try { + const result = await highlightNativeReviewDiffVisibleRows({ + files, + rows, + scheme, + engine, + firstRowIndex: requestRange.firstRowIndex, + lastRowIndex: requestRange.lastRowIndex, + alreadyHighlightedRowIds: highlightedRowIdsRef.current, + signal: abortController.signal, + }); + + if (abortController.signal.aborted || result.rowCount === 0) { + return; + } + + for (const rowId of Object.keys(result.tokensByRowId)) { + highlightedRowIdsRef.current.add(rowId); + } + + const chunkIndex = visibleChunkIndexRef.current; + visibleChunkIndexRef.current += 1; + setTokensPatchJson( + JSON.stringify({ + resetKey, + chunkIndex, + fileId: "visible", + filePath: "visible rows", + language: "diff", + lineCount: result.rowCount, + durationMs: result.durationMs, + tokensByRowId: result.tokensByRowId, + }), + ); + } catch (error) { + if (!abortController.signal.aborted) { + logReviewDiffDiagnostic("native visible highlight failed", { + error: error instanceof Error ? error.message : String(error), + }); + } + } + })(); + + return () => abortController.abort(); + }, [enabled, files, resetKey, rows, scheme, visibleHighlightRequest]); + + const updateVisibleRange = useCallback((nextRange: NativeReviewVisibleRange) => { + const previousRange = visibleRangeRef.current; + const movedRows = + Math.abs(nextRange.firstRowIndex - previousRange.firstRowIndex) + + Math.abs(nextRange.lastRowIndex - previousRange.lastRowIndex); + + visibleRangeRef.current = nextRange; + if (movedRows >= 20) { + setVisibleHighlightRequest((request) => request + 1); + } + }, []); + + return { + tokensPatchJson, + updateVisibleRange, + }; +} diff --git a/apps/mobile/src/features/review/useReviewCommentSelectionController.ts b/apps/mobile/src/features/review/useReviewCommentSelectionController.ts new file mode 100644 index 00000000000..683508b2b19 --- /dev/null +++ b/apps/mobile/src/features/review/useReviewCommentSelectionController.ts @@ -0,0 +1,193 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import type { NativeSyntheticEvent } from "react-native"; +import { useRouter } from "expo-router"; + +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; + +import { + buildReviewCommentTarget, + clearReviewCommentTarget, + formatReviewSelectedRangeLabel, + getSelectedReviewCommentLines, + setReviewCommentTarget, + useReviewCommentTarget, +} from "./reviewCommentSelection"; +import type { + NativeReviewDiffData, + NativeReviewDiffCommentTarget, +} from "./nativeReviewDiffAdapter"; +import type { ReviewSectionItem } from "./reviewModel"; + +interface PendingNativeCommentSelection extends NativeReviewDiffCommentTarget { + readonly sectionId: string; + readonly sectionTitle: string; + readonly rowId: string; +} + +export function useReviewCommentSelectionController(input: { + readonly environmentId?: EnvironmentId; + readonly threadId?: ThreadId; + readonly selectedSection: ReviewSectionItem | null; + readonly nativeReviewDiffData: NativeReviewDiffData; +}) { + const { environmentId, nativeReviewDiffData, selectedSection, threadId } = input; + const { push } = useRouter(); + const activeCommentTarget = useReviewCommentTarget(); + const [pendingNativeCommentSelection, setPendingNativeCommentSelection] = + useState(null); + + const openReviewCommentSheet = useCallback(() => { + if (!environmentId || !threadId) { + return; + } + + push({ + pathname: "/threads/[environmentId]/[threadId]/review-comment", + params: { environmentId, threadId }, + }); + }, [environmentId, push, threadId]); + + const selectedRowIds = useMemo(() => { + if ( + activeCommentTarget && + activeCommentTarget.sectionTitle === selectedSection?.title && + activeCommentTarget.startIndex !== activeCommentTarget.endIndex + ) { + return getSelectedReviewCommentLines(activeCommentTarget).flatMap((line) => { + const rowId = nativeReviewDiffData.rowIdByCommentLineId.get(line.id); + return rowId ? [rowId] : []; + }); + } + + return pendingNativeCommentSelection ? [pendingNativeCommentSelection.rowId] : []; + }, [ + activeCommentTarget, + nativeReviewDiffData.rowIdByCommentLineId, + pendingNativeCommentSelection, + selectedSection?.title, + ]); + + const selectionAction = useMemo(() => { + if ( + activeCommentTarget && + activeCommentTarget.sectionTitle === selectedSection?.title && + activeCommentTarget.startIndex !== activeCommentTarget.endIndex + ) { + return { + title: `Comment on ${formatReviewSelectedRangeLabel(activeCommentTarget)}`, + onOpenComment: openReviewCommentSheet, + }; + } + + if ( + pendingNativeCommentSelection && + pendingNativeCommentSelection.sectionTitle === selectedSection?.title + ) { + return { + title: "Select range end", + onOpenComment: null, + }; + } + + return null; + }, [ + activeCommentTarget, + openReviewCommentSheet, + pendingNativeCommentSelection, + selectedSection?.title, + ]); + + useEffect(() => { + clearReviewCommentTarget(); + setPendingNativeCommentSelection(null); + }, [selectedSection?.id]); + + useEffect(() => { + if (activeCommentTarget === null) { + setPendingNativeCommentSelection(null); + } + }, [activeCommentTarget]); + + const onPressLine = useCallback( + ( + event: NativeSyntheticEvent<{ + readonly rowId?: string; + readonly gesture?: "tap" | "longPress"; + }>, + ) => { + if (!selectedSection) { + return; + } + + const { rowId, gesture } = event.nativeEvent; + if (!rowId) { + return; + } + + const target = nativeReviewDiffData.commentTargetsByRowId.get(rowId); + if (!target) { + return; + } + + if (gesture === "longPress") { + clearReviewCommentTarget(); + setPendingNativeCommentSelection({ + ...target, + sectionId: selectedSection.id, + sectionTitle: selectedSection.title, + rowId, + }); + return; + } + + if ( + pendingNativeCommentSelection && + pendingNativeCommentSelection.sectionTitle === selectedSection.title && + pendingNativeCommentSelection.filePath === target.filePath + ) { + setReviewCommentTarget( + buildReviewCommentTarget( + { + sectionTitle: pendingNativeCommentSelection.sectionTitle, + sectionId: pendingNativeCommentSelection.sectionId, + filePath: pendingNativeCommentSelection.filePath, + lines: pendingNativeCommentSelection.lines, + }, + pendingNativeCommentSelection.lineIndex, + target.lineIndex, + ), + ); + return; + } + + setPendingNativeCommentSelection(null); + setReviewCommentTarget({ + sectionTitle: selectedSection.title, + sectionId: selectedSection.id, + filePath: target.filePath, + lines: target.lines, + startIndex: target.lineIndex, + endIndex: target.lineIndex, + }); + openReviewCommentSheet(); + }, + [ + nativeReviewDiffData.commentTargetsByRowId, + openReviewCommentSheet, + pendingNativeCommentSelection, + selectedSection, + ], + ); + + const clearSelection = useCallback(() => { + clearReviewCommentTarget(); + setPendingNativeCommentSelection(null); + }, []); + + return { + selectedRowIds, + selectionAction, + onPressLine, + clearSelection, + }; +} diff --git a/apps/mobile/src/features/review/useReviewDiffData.ts b/apps/mobile/src/features/review/useReviewDiffData.ts new file mode 100644 index 00000000000..ee04673dfc0 --- /dev/null +++ b/apps/mobile/src/features/review/useReviewDiffData.ts @@ -0,0 +1,109 @@ +import { useEffect, useMemo } from "react"; + +import { countReviewCommentContexts, parseReviewInlineComments } from "./reviewCommentSelection"; +import { buildNativeReviewDiffData } from "./nativeReviewDiffAdapter"; +import { markReviewEvent, measureReviewWork } from "./reviewPerf"; +import { getCachedReviewParsedDiff } from "./reviewState"; +import type { ReviewParsedDiff, ReviewSectionItem } from "./reviewModel"; + +function isReviewDiffDebugLoggingEnabled(): boolean { + return typeof __DEV__ !== "undefined" ? __DEV__ : false; +} + +function logReviewDiffDiagnostic(message: string, details?: Record): void { + if (!isReviewDiffDebugLoggingEnabled()) { + return; + } + + if (details) { + console.log(`[review-sheet] ${message}`, details); + return; + } + + console.log(`[review-sheet] ${message}`); +} + +export function formatHeaderDiffSummary(parsedDiff: ReviewParsedDiff): { + readonly additions: string | null; + readonly deletions: string | null; +} { + if (parsedDiff.kind !== "files") { + return { additions: null, deletions: null }; + } + + return { + additions: `+${parsedDiff.additions}`, + deletions: `-${parsedDiff.deletions}`, + }; +} + +export function useReviewDiffData(input: { + readonly threadKey: string | null; + readonly selectedSection: ReviewSectionItem | null; + readonly draftMessage: string; +}) { + const { draftMessage, selectedSection, threadKey } = input; + const parsedDiff = useMemo( + () => + measureReviewWork("parse-diff", () => + getCachedReviewParsedDiff({ + threadKey, + sectionId: selectedSection?.id ?? null, + diff: selectedSection?.diff, + }), + ), + [selectedSection?.diff, selectedSection?.id, threadKey], + ); + const headerDiffSummary = useMemo(() => formatHeaderDiffSummary(parsedDiff), [parsedDiff]); + const inlineReviewComments = useMemo( + () => parseReviewInlineComments(draftMessage), + [draftMessage], + ); + const selectedSectionInlineComments = useMemo( + () => + selectedSection + ? inlineReviewComments.filter((comment) => comment.sectionId === selectedSection.id) + : [], + [inlineReviewComments, selectedSection], + ); + const nativeReviewDiffData = useMemo( + () => + measureReviewWork("build-native-diff-data", () => + buildNativeReviewDiffData({ + parsedDiff, + comments: selectedSectionInlineComments, + }), + ), + [parsedDiff, selectedSectionInlineComments], + ); + const pendingReviewCommentCount = useMemo( + () => countReviewCommentContexts(draftMessage), + [draftMessage], + ); + + useEffect(() => { + if (parsedDiff.kind !== "files") { + return; + } + + markReviewEvent("parsed-diff-ready", { + sectionId: selectedSection?.id ?? null, + fileCount: parsedDiff.fileCount, + additions: parsedDiff.additions, + deletions: parsedDiff.deletions, + renderedItems: nativeReviewDiffData.rows.length, + }); + logReviewDiffDiagnostic("parsed diff files", { + selectedSectionId: selectedSection?.id ?? null, + fileCount: parsedDiff.fileCount, + renderableFileCount: parsedDiff.files.length, + }); + }, [nativeReviewDiffData.rows.length, parsedDiff, selectedSection?.id]); + + return { + parsedDiff, + headerDiffSummary, + nativeReviewDiffData, + pendingReviewCommentCount, + }; +} diff --git a/apps/mobile/src/features/review/useReviewSections.ts b/apps/mobile/src/features/review/useReviewSections.ts new file mode 100644 index 00000000000..8b6bc93e0db --- /dev/null +++ b/apps/mobile/src/features/review/useReviewSections.ts @@ -0,0 +1,245 @@ +import { useCallback, useEffect, useMemo, useRef } from "react"; + +import type { EnvironmentId, OrchestrationCheckpointSummary, ThreadId } from "@t3tools/contracts"; + +import { getEnvironmentClient } from "../../state/environment-session-registry"; +import { checkpointDiffManager, loadCheckpointDiff } from "../../state/use-checkpoint-diff"; +import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree"; +import { useSelectedThreadDetail } from "../../state/use-thread-detail"; +import { useReviewDiffPreview } from "./reviewDiffPreviewState"; +import { + buildReviewSectionItems, + getDefaultReviewSectionId, + getReadyReviewCheckpoints, + getReviewSectionIdForCheckpoint, +} from "./reviewModel"; +import { + setReviewAsyncError, + setReviewGitSections, + setReviewSelectedSectionId, + setReviewTurnDiffLoading, + setReviewTurnDiff, + type ReviewCacheForThread, +} from "./reviewState"; + +export function useReviewSections(input: { + readonly environmentId?: EnvironmentId; + readonly threadId?: ThreadId; + readonly reviewCache: ReviewCacheForThread; +}) { + const { environmentId, reviewCache, threadId } = input; + const selectedThread = useSelectedThreadDetail(); + const { selectedThreadCwd } = useSelectedThreadWorktree(); + const diffPreview = useReviewDiffPreview({ environmentId, cwd: selectedThreadCwd }); + const refreshDiffPreview = diffPreview.refresh; + const { loadingTurnIds } = reviewCache.asyncState; + const error = diffPreview.error ?? reviewCache.asyncState.error; + const loadingGitDiffs = diffPreview.isPending; + const turnDiffByIdRef = useRef(reviewCache.turnDiffById); + + useEffect(() => { + turnDiffByIdRef.current = reviewCache.turnDiffById; + }, [reviewCache.turnDiffById]); + + useEffect(() => { + if (reviewCache.threadKey && diffPreview.data) { + setReviewGitSections(reviewCache.threadKey, diffPreview.data.sources); + } + }, [diffPreview.data, reviewCache.threadKey]); + + const readyCheckpoints = useMemo( + () => getReadyReviewCheckpoints(selectedThread?.checkpoints ?? []), + [selectedThread?.checkpoints], + ); + const checkpointBySectionId = useMemo(() => { + return Object.fromEntries( + readyCheckpoints.map((checkpoint) => [ + getReviewSectionIdForCheckpoint(checkpoint), + checkpoint, + ]), + ) as Record; + }, [readyCheckpoints]); + const reviewSections = useMemo( + () => + buildReviewSectionItems({ + checkpoints: readyCheckpoints, + gitSections: reviewCache.gitSections, + turnDiffById: reviewCache.turnDiffById, + loadingTurnIds, + }), + [loadingTurnIds, readyCheckpoints, reviewCache.gitSections, reviewCache.turnDiffById], + ); + const selectedSection = useMemo( + () => + reviewSections.find((section) => section.id === reviewCache.selectedSectionId) ?? + reviewSections[0] ?? + null, + [reviewCache.selectedSectionId, reviewSections], + ); + const fallbackSectionId = useMemo( + () => getDefaultReviewSectionId(reviewSections), + [reviewSections], + ); + const hasReviewSections = reviewSections.length > 0; + const selectedSectionIdExists = useMemo( + () => + reviewCache.selectedSectionId + ? reviewSections.some((section) => section.id === reviewCache.selectedSectionId) + : false, + [reviewCache.selectedSectionId, reviewSections], + ); + + const loadTurnDiff = useCallback( + async (checkpoint: OrchestrationCheckpointSummary, force = false) => { + if (!environmentId || !threadId) { + return; + } + + const sectionId = getReviewSectionIdForCheckpoint(checkpoint); + if (reviewCache.threadKey) { + setReviewSelectedSectionId(reviewCache.threadKey, sectionId); + } + + if (!force && turnDiffByIdRef.current[sectionId] !== undefined) { + return; + } + + const target = { + environmentId, + threadId, + fromTurnCount: Math.max(0, checkpoint.checkpointTurnCount - 1), + toTurnCount: checkpoint.checkpointTurnCount, + ignoreWhitespace: false, + cacheScope: sectionId, + }; + const cached = checkpointDiffManager.getSnapshot(target).data; + if (!force && cached) { + if (reviewCache.threadKey) { + setReviewTurnDiff(reviewCache.threadKey, sectionId, cached.diff); + } + return; + } + + if (!getEnvironmentClient(environmentId)) { + if (reviewCache.threadKey) { + setReviewAsyncError(reviewCache.threadKey, "Remote connection is not ready."); + } + return; + } + + if (reviewCache.threadKey) { + setReviewTurnDiffLoading(reviewCache.threadKey, sectionId, true); + setReviewAsyncError(reviewCache.threadKey, null); + } + try { + const result = await loadCheckpointDiff(target, { force }); + if (reviewCache.threadKey) { + if (result) { + setReviewTurnDiff(reviewCache.threadKey, sectionId, result.diff); + } + } + } catch (cause) { + if (reviewCache.threadKey) { + setReviewAsyncError( + reviewCache.threadKey, + cause instanceof Error ? cause.message : "Failed to load turn diff.", + ); + } + } finally { + if (reviewCache.threadKey) { + setReviewTurnDiffLoading(reviewCache.threadKey, sectionId, false); + } + } + }, + [environmentId, reviewCache.threadKey, threadId], + ); + + useEffect(() => { + if (!hasReviewSections) { + return; + } + + if (reviewCache.threadKey && (!reviewCache.selectedSectionId || !selectedSectionIdExists)) { + setReviewSelectedSectionId(reviewCache.threadKey, fallbackSectionId); + } + }, [ + fallbackSectionId, + hasReviewSections, + reviewCache.selectedSectionId, + reviewCache.threadKey, + selectedSectionIdExists, + ]); + + const latestCheckpoint = readyCheckpoints[0] ?? null; + const latestSectionId = latestCheckpoint + ? getReviewSectionIdForCheckpoint(latestCheckpoint) + : null; + const latestTurnDiffLoaded = latestSectionId + ? reviewCache.turnDiffById[latestSectionId] !== undefined + : true; + const latestTurnDiffLoading = latestSectionId ? loadingTurnIds[latestSectionId] === true : false; + + useEffect(() => { + if (!latestCheckpoint || !latestSectionId || latestTurnDiffLoaded || latestTurnDiffLoading) { + return; + } + + void loadTurnDiff(latestCheckpoint); + }, [ + latestCheckpoint, + latestSectionId, + latestTurnDiffLoaded, + latestTurnDiffLoading, + loadTurnDiff, + ]); + + const selectedTurnCheckpoint = + selectedSection?.kind === "turn" ? (checkpointBySectionId[selectedSection.id] ?? null) : null; + const selectedTurnDiffMissing = + selectedSection?.kind === "turn" && selectedSection.diff === null && selectedTurnCheckpoint; + const selectedTurnDiffLoading = + selectedSection?.kind === "turn" ? loadingTurnIds[selectedSection.id] === true : false; + + useEffect(() => { + if (!selectedTurnDiffMissing || selectedTurnDiffLoading) { + return; + } + + void loadTurnDiff(selectedTurnDiffMissing); + }, [loadTurnDiff, selectedTurnDiffLoading, selectedTurnDiffMissing]); + + const refreshSelectedSection = useCallback(async () => { + if (!selectedSection) { + return; + } + + if (selectedSection.kind === "turn") { + const checkpoint = checkpointBySectionId[selectedSection.id]; + if (checkpoint) { + await loadTurnDiff(checkpoint, true); + } + return; + } + + refreshDiffPreview(); + }, [checkpointBySectionId, loadTurnDiff, refreshDiffPreview, selectedSection]); + + const selectSection = useCallback( + (sectionId: string) => { + if (reviewCache.threadKey) { + setReviewSelectedSectionId(reviewCache.threadKey, sectionId); + } + }, + [reviewCache.threadKey], + ); + + return { + error, + loadingGitDiffs, + loadingTurnIds, + reviewSections, + selectedSection, + refreshSelectedSection, + selectSection, + }; +} diff --git a/apps/mobile/src/features/terminal/NativeTerminalSurface.tsx b/apps/mobile/src/features/terminal/NativeTerminalSurface.tsx new file mode 100644 index 00000000000..30dd0e86030 --- /dev/null +++ b/apps/mobile/src/features/terminal/NativeTerminalSurface.tsx @@ -0,0 +1,217 @@ +import { memo, useCallback, useEffect } from "react"; +import { + Pressable, + ScrollView, + TextInput, + View, + type LayoutChangeEvent, + type NativeSyntheticEvent, + type ViewProps, + useColorScheme, +} from "react-native"; + +import { AppText as Text } from "../../components/AppText"; +import { resolveNativeTerminalSurfaceView } from "./nativeTerminalModule"; +import { + buildGhosttyThemeConfig, + getPierreTerminalTheme, + type TerminalTheme, +} from "./terminalTheme"; +import { terminalDebugLog } from "./terminalDebugLog"; + +interface TerminalInputEvent { + readonly data: string; +} + +interface TerminalResizeEvent { + readonly cols: number; + readonly rows: number; +} + +interface TerminalSurfaceProps extends ViewProps { + readonly terminalKey: string; + readonly buffer: string; + readonly fontSize?: number; + readonly isRunning: boolean; + readonly theme?: TerminalTheme; + readonly onInput: (data: string) => void; + readonly onResize: (size: { readonly cols: number; readonly rows: number }) => void; +} + +function estimateGridSize(input: { + readonly width: number; + readonly height: number; + readonly fontSize: number; +}): { readonly cols: number; readonly rows: number } { + const cellWidth = input.fontSize * 0.62; + const cellHeight = input.fontSize * 1.35; + return { + cols: Math.max(20, Math.min(400, Math.floor(input.width / cellWidth))), + rows: Math.max(5, Math.min(200, Math.floor(input.height / cellHeight))), + }; +} + +const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: TerminalSurfaceProps) { + const fontSize = props.fontSize ?? 12; + const appearanceScheme = useColorScheme() === "light" ? "light" : "dark"; + const theme = props.theme ?? getPierreTerminalTheme(appearanceScheme); + const statusLabel = props.isRunning + ? "Native terminal unavailable. Using text fallback." + : "Open terminal to start a shell."; + + const handleLayout = (event: LayoutChangeEvent) => { + const { width, height } = event.nativeEvent.layout; + props.onResize(estimateGridSize({ width, height, fontSize })); + }; + + return ( + + + + {statusLabel} + + + + {props.buffer || "$ "} + + + + + { + const text = event.nativeEvent.text; + if (text.length > 0) { + props.onInput(`${text}\n`); + } + }} + /> + ({ + opacity: !props.isRunning ? 0.35 : pressed ? 0.65 : 1, + paddingHorizontal: 10, + paddingVertical: 6, + borderRadius: 8, + backgroundColor: theme.border, + })} + onPress={() => props.onInput("\u0003")} + > + + Ctrl-C + + + + + ); +}); + +export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurfaceProps) { + const fontSize = props.fontSize ?? 12; + const appearanceScheme = useColorScheme() === "light" ? "light" : "dark"; + const theme = props.theme ?? getPierreTerminalTheme(appearanceScheme); + const { onInput, onResize } = props; + const NativeTerminalSurfaceView = resolveNativeTerminalSurfaceView(); + const hasNativeSurface = Boolean(NativeTerminalSurfaceView); + + useEffect(() => { + terminalDebugLog("native:surface", { + terminalKey: props.terminalKey, + native: hasNativeSurface, + bufferLen: props.buffer.length, + isRunning: props.isRunning, + }); + }, [hasNativeSurface, props.buffer.length, props.isRunning, props.terminalKey]); + const handleNativeInput = useCallback( + (event: NativeSyntheticEvent) => { + onInput(event.nativeEvent.data); + }, + [onInput], + ); + const handleNativeResize = useCallback( + (event: NativeSyntheticEvent) => { + onResize({ + cols: event.nativeEvent.cols, + rows: event.nativeEvent.rows, + }); + }, + [onResize], + ); + + if (NativeTerminalSurfaceView) { + return ( + + ); + } + + return ; +}); diff --git a/apps/mobile/src/features/terminal/ThreadTerminalPanel.tsx b/apps/mobile/src/features/terminal/ThreadTerminalPanel.tsx new file mode 100644 index 00000000000..71643336d54 --- /dev/null +++ b/apps/mobile/src/features/terminal/ThreadTerminalPanel.tsx @@ -0,0 +1,175 @@ +import { DEFAULT_TERMINAL_ID, type EnvironmentId, type ThreadId } from "@t3tools/contracts"; +import { SymbolView } from "expo-symbols"; +import { memo, useCallback, useEffect, useRef, useState } from "react"; +import { Pressable, View } from "react-native"; + +import { AppText as Text } from "../../components/AppText"; +import { getEnvironmentClient } from "../../state/environment-session-registry"; +import { + attachTerminalSession, + useTerminalSession, + useTerminalSessionTarget, +} from "../../state/use-terminal-session"; +import { TerminalSurface } from "./NativeTerminalSurface"; +import { hasNativeTerminalSurface } from "./nativeTerminalModule"; +import { terminalDebugLog } from "./terminalDebugLog"; + +interface ThreadTerminalPanelProps { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; + readonly cwd: string; + readonly worktreePath: string | null; + readonly visible: boolean; + readonly onClose: () => void; +} + +const DEFAULT_TERMINAL_COLS = 80; +const DEFAULT_TERMINAL_ROWS = 24; + +export const ThreadTerminalPanel = memo(function ThreadTerminalPanel( + props: ThreadTerminalPanelProps, +) { + const nativeTerminalAvailable = hasNativeTerminalSurface(); + const terminalId = DEFAULT_TERMINAL_ID; + const target = useTerminalSessionTarget({ + environmentId: props.environmentId, + threadId: props.threadId, + terminalId, + }); + const terminal = useTerminalSession(target); + const [lastGridSize, setLastGridSize] = useState({ + cols: DEFAULT_TERMINAL_COLS, + rows: DEFAULT_TERMINAL_ROWS, + }); + const lastGridSizeRef = useRef(lastGridSize); + lastGridSizeRef.current = lastGridSize; + + const terminalKey = `${props.environmentId}:${props.threadId}:${terminalId}`; + const isRunning = terminal.status === "running" || terminal.status === "starting"; + + useEffect(() => { + if (!props.visible) { + return; + } + + const client = getEnvironmentClient(props.environmentId); + if (!client) { + terminalDebugLog("panel:attach-skip", { + reason: "no-environment-client", + environmentId: props.environmentId, + }); + return; + } + + terminalDebugLog("panel:attach", { + environmentId: props.environmentId, + threadId: props.threadId, + terminalId, + }); + + return attachTerminalSession({ + environmentId: props.environmentId, + client, + terminal: { + threadId: props.threadId, + terminalId, + cwd: props.cwd, + worktreePath: props.worktreePath, + cols: lastGridSizeRef.current.cols, + rows: lastGridSizeRef.current.rows, + }, + }); + }, [ + props.cwd, + props.environmentId, + props.threadId, + props.worktreePath, + props.visible, + terminalId, + ]); + + const handleInput = useCallback( + (data: string) => { + const client = getEnvironmentClient(props.environmentId); + if (!client || !isRunning) { + return; + } + + void client.terminal.write({ + threadId: props.threadId, + terminalId, + data, + }); + }, + [isRunning, props.environmentId, props.threadId, terminalId], + ); + + const handleResize = useCallback( + (size: { readonly cols: number; readonly rows: number }) => { + if (size.cols === lastGridSize.cols && size.rows === lastGridSize.rows) { + return; + } + + setLastGridSize(size); + const client = getEnvironmentClient(props.environmentId); + if (!client || !isRunning) { + return; + } + + void client.terminal.resize({ + threadId: props.threadId, + terminalId, + cols: size.cols, + rows: size.rows, + }); + }, + [ + isRunning, + lastGridSize.cols, + lastGridSize.rows, + props.environmentId, + props.threadId, + terminalId, + ], + ); + + if (!props.visible) { + return null; + } + + return ( + + + + + Terminal + + + {nativeTerminalAvailable ? "Native Ghostty surface" : "Text fallback active"} + + + + {terminal.error ? ( + + {terminal.error} + + ) : null} + + + + + + + + ); +}); diff --git a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.test.ts b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.test.ts new file mode 100644 index 00000000000..482ff6cdbce --- /dev/null +++ b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from "vitest"; + +import { resolveTerminalRouteBootstrap } from "./terminalRouteBootstrap"; + +describe("resolveTerminalRouteBootstrap", () => { + it("redirects bare terminal routes to another already-running terminal for the thread", () => { + expect( + resolveTerminalRouteBootstrap({ + hasThread: true, + hasWorkspaceRoot: true, + hasOpened: false, + requestedTerminalId: null, + currentTerminalId: "default", + runningTerminalId: "term-2", + currentTerminalStatus: "closed", + hasCurrentTerminalHydration: false, + }), + ).toEqual({ + kind: "redirect", + terminalId: "term-2", + }); + }); + + it("hydrates the current running terminal when client state is not hydrated yet", () => { + expect( + resolveTerminalRouteBootstrap({ + hasThread: true, + hasWorkspaceRoot: true, + hasOpened: false, + requestedTerminalId: null, + currentTerminalId: "default", + runningTerminalId: "default", + currentTerminalStatus: "running", + hasCurrentTerminalHydration: false, + }), + ).toEqual({ + kind: "open", + }); + }); + + it("opens explicit terminal routes when the session still needs hydration", () => { + expect( + resolveTerminalRouteBootstrap({ + hasThread: true, + hasWorkspaceRoot: true, + hasOpened: false, + requestedTerminalId: "term-2", + currentTerminalId: "term-2", + runningTerminalId: "term-2", + currentTerminalStatus: "running", + hasCurrentTerminalHydration: false, + }), + ).toEqual({ + kind: "open", + }); + }); + + it("stays idle after the route already bootstrapped once", () => { + expect( + resolveTerminalRouteBootstrap({ + hasThread: true, + hasWorkspaceRoot: true, + hasOpened: true, + requestedTerminalId: null, + currentTerminalId: "default", + runningTerminalId: "default", + currentTerminalStatus: "running", + hasCurrentTerminalHydration: true, + }), + ).toEqual({ + kind: "idle", + }); + }); + + it("stays idle when the current running terminal is already hydrated in client state", () => { + expect( + resolveTerminalRouteBootstrap({ + hasThread: true, + hasWorkspaceRoot: true, + hasOpened: false, + requestedTerminalId: null, + currentTerminalId: "default", + runningTerminalId: "default", + currentTerminalStatus: "running", + hasCurrentTerminalHydration: true, + }), + ).toEqual({ + kind: "idle", + }); + }); + + it("stays idle for explicit running terminal routes that already have hydrated output", () => { + expect( + resolveTerminalRouteBootstrap({ + hasThread: true, + hasWorkspaceRoot: true, + hasOpened: false, + requestedTerminalId: "term-2", + currentTerminalId: "term-2", + runningTerminalId: "term-2", + currentTerminalStatus: "running", + hasCurrentTerminalHydration: true, + }), + ).toEqual({ + kind: "idle", + }); + }); +}); diff --git a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx new file mode 100644 index 00000000000..1b082d99836 --- /dev/null +++ b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx @@ -0,0 +1,1110 @@ +import { + DEFAULT_TERMINAL_ID, + EnvironmentId, + type TerminalAttachStreamEvent, + ThreadId, +} from "@t3tools/contracts"; +import type { KnownTerminalSession } from "@t3tools/client-runtime"; +import { Stack, useLocalSearchParams, useRouter } from "expo-router"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Pressable, ScrollView, Text as RNText, View, useColorScheme } from "react-native"; +import { KeyboardStickyView } from "react-native-keyboard-controller"; +import Animated, { useAnimatedKeyboard, useAnimatedStyle } from "react-native-reanimated"; + +import { EmptyState } from "../../components/EmptyState"; +import { LoadingScreen } from "../../components/LoadingScreen"; +import { buildThreadTerminalNavigation } from "../../lib/routes"; +import { getEnvironmentClient } from "../../state/environment-session-registry"; +import { useRemoteEnvironmentState } from "../../state/use-remote-environment-registry"; +import { + attachTerminalSession, + useKnownTerminalSessions, + useTerminalSession, + useTerminalSessionTarget, +} from "../../state/use-terminal-session"; +import { useThreadSelection } from "../../state/use-thread-selection"; +import { useSelectedThreadDetail } from "../../state/use-thread-detail"; +import { TerminalSurface } from "./NativeTerminalSurface"; +import { getPierreTerminalTheme } from "./terminalTheme"; +import { loadPreferences, savePreferencesPatch } from "../../lib/storage"; +import { terminalDebugLog } from "./terminalDebugLog"; +import { + getTerminalBufferReplayKey, + getTerminalSurfaceReplayBuffer, + TERMINAL_BUFFER_REPLAY_STABILITY_DELAY_MS, +} from "./terminalBufferReplay"; +import { resolveTerminalRouteBootstrap } from "./terminalRouteBootstrap"; +import { + resolveTerminalOpenLocation, + stagePendingTerminalLaunch, + takePendingTerminalLaunch, +} from "./terminalLaunchContext"; +import { + basename, + buildTerminalMenuSessions, + getTerminalStatusLabel, + nextOpenTerminalId, + resolveTerminalSessionLabel, + type TerminalMenuSession, +} from "./terminalMenu"; +import { + DEFAULT_TERMINAL_FONT_SIZE, + MAX_TERMINAL_FONT_SIZE, + MIN_TERMINAL_FONT_SIZE, + TERMINAL_FONT_SIZE_STEP, + normalizeTerminalFontSize, +} from "./terminalPreferences"; +import { + cacheTerminalFontSize, + cacheTerminalGridSize, + getCachedTerminalFontSize, + getCachedTerminalGridSize, +} from "./terminalUiState"; + +const DEFAULT_TERMINAL_COLS = 80; +const DEFAULT_TERMINAL_ROWS = 24; +const TERMINAL_ACCESSORY_HEIGHT = 52; + +type PendingModifier = "ctrl" | "meta"; +type HostPlatform = "mac" | "linux" | "windows" | "unknown"; + +type TerminalToolbarAction = + | { readonly kind: "send"; readonly key: string; readonly label: string; readonly data: string } + | { + readonly kind: "modifier"; + readonly key: string; + readonly label: string; + readonly modifier: PendingModifier; + }; + +function getTerminalStatusTone(input: { + readonly status: TerminalMenuSession["status"]; + readonly hasRunningSubprocess?: boolean; +}): { + readonly tintColor: string; + readonly textColor: string; +} { + if (input.status === "running") { + if (input.hasRunningSubprocess) { + return { + tintColor: "#fbbf24", + textColor: "#a3a3a3", + }; + } + + return { + tintColor: "#34d399", + textColor: "#a3a3a3", + }; + } + + if (input.status === "starting") { + return { + tintColor: "#f59e0b", + textColor: "#a3a3a3", + }; + } + + if (input.status === "error") { + return { + tintColor: "#ef4444", + textColor: "#fca5a5", + }; + } + + return { + tintColor: "#ef4444", + textColor: "#a3a3a3", + }; +} + +function firstRouteParam(value: string | string[] | undefined): string | null { + if (Array.isArray(value)) { + return value[0] ?? null; + } + + return value ?? null; +} + +function inferHostPlatform(environmentLabel: string | null): HostPlatform { + const value = environmentLabel?.toLowerCase() ?? ""; + if ( + value.includes("mac") || + value.includes("macbook") || + value.includes("mac mini") || + value.includes("imac") || + value.includes("darwin") + ) { + return "mac"; + } + if (value.includes("windows") || value.includes("win")) { + return "windows"; + } + if (value.includes("linux") || value.includes("ubuntu") || value.includes("debian")) { + return "linux"; + } + + return "unknown"; +} + +function applyCtrlModifier(input: string): string { + const firstCharacter = input[0]; + if (!firstCharacter) { + return input; + } + + const lowerCharacter = firstCharacter.toLowerCase(); + if (lowerCharacter >= "a" && lowerCharacter <= "z") { + return String.fromCharCode(lowerCharacter.charCodeAt(0) - 96); + } + + if (firstCharacter === "@") return "\u0000"; + if (firstCharacter === "[") return "\u001b"; + if (firstCharacter === "\\") return "\u001c"; + if (firstCharacter === "]") return "\u001d"; + if (firstCharacter === "^") return "\u001e"; + if (firstCharacter === "_") return "\u001f"; + if (firstCharacter === "?") return "\u007f"; + + return input; +} + +function withAlpha(hexColor: string, alpha: string): string { + return /^#[0-9a-f]{6}$/i.test(hexColor) ? `${hexColor}${alpha}` : hexColor; +} + +function pickRunningTerminalSessionForBootstrap( + sessions: ReadonlyArray, +): KnownTerminalSession | null { + const running = sessions.filter( + (session) => session.state.status === "running" || session.state.status === "starting", + ); + if (running.length === 0) { + return null; + } + return ( + running.find((session) => session.target.terminalId === DEFAULT_TERMINAL_ID) ?? + running[0] ?? + null + ); +} + +export function ThreadTerminalRouteScreen() { + const router = useRouter(); + const keyboard = useAnimatedKeyboard(); + const appearanceScheme = useColorScheme() === "light" ? "light" : "dark"; + const { isLoadingSavedConnection } = useRemoteEnvironmentState(); + const params = useLocalSearchParams<{ + environmentId?: string | string[]; + threadId?: string | string[]; + terminalId?: string | string[]; + }>(); + const { selectedThread, selectedThreadProject, selectedEnvironmentConnection } = + useThreadSelection(); + const selectedThreadDetail = useSelectedThreadDetail(); + const routeEnvironmentIdRaw = firstRouteParam(params.environmentId); + const routeThreadIdRaw = firstRouteParam(params.threadId); + const routeEnvironmentId = routeEnvironmentIdRaw + ? EnvironmentId.make(routeEnvironmentIdRaw) + : null; + const routeThreadId = routeThreadIdRaw ? ThreadId.make(routeThreadIdRaw) : null; + const requestedTerminalId = firstRouteParam(params.terminalId); + const terminalId = requestedTerminalId ?? DEFAULT_TERMINAL_ID; + const cachedFontSize = getCachedTerminalFontSize(); + const cachedRouteGridSize = + routeEnvironmentId && routeThreadId + ? getCachedTerminalGridSize({ + environmentId: routeEnvironmentId, + threadId: routeThreadId, + terminalId, + }) + : null; + const knownSessions = useKnownTerminalSessions({ + environmentId: selectedThread?.environmentId ?? null, + threadId: selectedThread?.id ?? null, + }); + const [lastGridSize, setLastGridSize] = useState( + cachedRouteGridSize ?? { + cols: DEFAULT_TERMINAL_COLS, + rows: DEFAULT_TERMINAL_ROWS, + }, + ); + const [fontSize, setFontSize] = useState(cachedFontSize ?? DEFAULT_TERMINAL_FONT_SIZE); + const hasOpenedRef = useRef(false); + const bufferReplayTimerRef = useRef | null>(null); + const attachStreamLogCountRef = useRef(0); + const firstNonEmptyBufferLoggedRef = useRef(false); + const lastBufferReplayKeyRef = useRef(null); + const [readyBufferReplayKey, setReadyBufferReplayKey] = useState(null); + const [hasResolvedFontPreference, setHasResolvedFontPreference] = useState( + cachedFontSize !== null, + ); + /** Default grid is always valid for attach; onResize refines cols/rows. Requiring a cached size blocked bootstrap for new terminal routes. */ + const [hasMeasuredSurface, setHasMeasuredSurface] = useState(true); + const [pendingModifierState, setPendingModifierState] = useState<{ + readonly terminalId: string; + readonly value: PendingModifier | null; + }>({ + terminalId, + value: null, + }); + const target = useTerminalSessionTarget({ + environmentId: selectedThread?.environmentId ?? null, + threadId: selectedThread?.id ?? null, + terminalId, + }); + const terminal = useTerminalSession(target); + const terminalKey = selectedThread + ? `${selectedThread.environmentId}:${selectedThread.id}:${terminalId}` + : terminalId; + const bufferReplayKey = useMemo( + () => getTerminalBufferReplayKey({ terminalKey, fontSize }), + [fontSize, terminalKey], + ); + if (lastBufferReplayKeyRef.current === null) { + lastBufferReplayKeyRef.current = bufferReplayKey; + } + const terminalSurfaceBuffer = getTerminalSurfaceReplayBuffer({ + buffer: terminal.buffer, + replayKey: bufferReplayKey, + readyReplayKey: readyBufferReplayKey, + }); + const isRunning = terminal.status === "running" || terminal.status === "starting"; + + useEffect(() => { + terminalDebugLog("surface:props", { + terminalKey, + atomBufferLen: terminal.buffer.length, + surfaceBufferLen: terminalSurfaceBuffer.length, + replayKey: bufferReplayKey, + readyReplayKey: readyBufferReplayKey, + status: terminal.status, + version: terminal.version, + }); + }, [ + bufferReplayKey, + readyBufferReplayKey, + terminal.buffer.length, + terminal.status, + terminal.version, + terminalKey, + terminalSurfaceBuffer.length, + ]); + + useEffect(() => { + terminalDebugLog("session:status", { + terminalKey, + status: terminal.status, + error: terminal.error, + summary: terminal.summary?.cwd ?? null, + bufferLen: terminal.buffer.length, + version: terminal.version, + }); + }, [ + terminal.buffer.length, + terminal.error, + terminal.status, + terminal.summary?.cwd, + terminal.version, + terminalKey, + ]); + + useEffect(() => { + if (terminal.buffer.length === 0 || firstNonEmptyBufferLoggedRef.current) { + return; + } + firstNonEmptyBufferLoggedRef.current = true; + terminalDebugLog("session:first-nonempty-buffer", { + terminalKey, + length: terminal.buffer.length, + preview: terminal.buffer.slice(0, 160), + }); + }, [terminal.buffer, terminal.buffer.length, terminalKey]); + const cwd = terminal.summary?.cwd ?? selectedThreadProject?.workspaceRoot ?? null; + const hostPlatform = useMemo( + () => inferHostPlatform(selectedEnvironmentConnection?.environmentLabel ?? null), + [selectedEnvironmentConnection?.environmentLabel], + ); + const runningSession = useMemo( + () => pickRunningTerminalSessionForBootstrap(knownSessions), + [knownSessions], + ); + const activeKnownSession = useMemo( + () => knownSessions.find((session) => session.target.terminalId === terminalId) ?? null, + [knownSessions, terminalId], + ); + + const terminalAttachLaunchHintsRef = useRef({ + terminalSummary: terminal.summary, + activeKnownSummary: activeKnownSession?.state.summary ?? null, + }); + terminalAttachLaunchHintsRef.current = { + terminalSummary: terminal.summary, + activeKnownSummary: activeKnownSession?.state.summary ?? null, + }; + + const terminalStatusTone = useMemo( + () => + getTerminalStatusTone({ + status: terminal.status, + hasRunningSubprocess: terminal.hasRunningSubprocess, + }), + [terminal.hasRunningSubprocess, terminal.status], + ); + const terminalTheme = getPierreTerminalTheme(appearanceScheme); + const pendingModifier = + pendingModifierState.terminalId === terminalId ? pendingModifierState.value : null; + const headerTitle = useMemo(() => { + const topLineParts = [ + selectedEnvironmentConnection?.environmentLabel ?? null, + selectedThreadProject?.title ?? null, + ].filter((value): value is string => Boolean(value)); + + return { + topLine: topLineParts.join(" \u00b7 "), + bottomLine: cwd ?? selectedThreadProject?.workspaceRoot ?? "", + }; + }, [ + cwd, + selectedEnvironmentConnection?.environmentLabel, + selectedThreadProject?.title, + selectedThreadProject?.workspaceRoot, + ]); + const terminalToolbarActions = useMemo>(() => { + const modifierActions: ReadonlyArray = + hostPlatform === "mac" + ? [ + { kind: "modifier", key: "cmd", label: "cmd", modifier: "meta" }, + { kind: "modifier", key: "ctrl", label: "ctrl", modifier: "ctrl" }, + ] + : [ + { kind: "modifier", key: "ctrl", label: "ctrl", modifier: "ctrl" }, + { kind: "modifier", key: "alt", label: "alt", modifier: "meta" }, + ]; + + return [ + { kind: "send", key: "esc", label: "esc", data: "\u001b" }, + ...modifierActions, + { kind: "send", key: "tab", label: "tab", data: "\t" }, + { kind: "send", key: "up", label: "↑", data: "\u001b[A" }, + { kind: "send", key: "down", label: "↓", data: "\u001b[B" }, + { kind: "send", key: "left", label: "←", data: "\u001b[D" }, + { kind: "send", key: "right", label: "→", data: "\u001b[C" }, + { kind: "send", key: "tilde", label: "~", data: "~" }, + { kind: "send", key: "pipe", label: "|", data: "|" }, + { kind: "send", key: "slash", label: "/", data: "/" }, + { kind: "send", key: "dash", label: "-", data: "-" }, + ]; + }, [hostPlatform]); + const terminalBottomInset = TERMINAL_ACCESSORY_HEIGHT; + const terminalContainerAnimatedStyle = useAnimatedStyle(() => ({ + paddingBottom: + keyboard.height.value > 0 + ? keyboard.height.value + TERMINAL_ACCESSORY_HEIGHT + : terminalBottomInset, + })); + + const terminalMenuSessions = useMemo>( + () => + buildTerminalMenuSessions({ + knownSessions, + workspaceRoot: selectedThreadProject?.workspaceRoot ?? null, + currentSession: { + terminalId, + cwd: cwd ?? null, + status: terminal.status, + hasRunningSubprocess: terminal.hasRunningSubprocess, + displayLabel: resolveTerminalSessionLabel(terminalId, terminal.summary), + updatedAt: terminal.updatedAt, + }, + }), + [ + cwd, + knownSessions, + selectedThreadProject?.workspaceRoot, + terminal.hasRunningSubprocess, + terminal.summary, + terminal.status, + terminal.updatedAt, + terminalId, + ], + ); + + const logAttachStreamEvent = useCallback((event: TerminalAttachStreamEvent) => { + const n = ++attachStreamLogCountRef.current; + if (event.type === "output" && n > 32 && n % 64 !== 0) { + return; + } + if (event.type === "snapshot") { + terminalDebugLog("attach:stream", { + n, + type: event.type, + status: event.snapshot.status, + historyLen: event.snapshot.history.length, + cwd: event.snapshot.cwd, + }); + return; + } + if (event.type === "output") { + terminalDebugLog("attach:stream", { n, type: event.type, dataLen: event.data.length }); + return; + } + terminalDebugLog("attach:stream", { n, type: event.type }); + }, []); + + const attachTerminal = useCallback(() => { + if (!selectedThread || !selectedThreadProject?.workspaceRoot) { + terminalDebugLog("attach:abort", { reason: "no-thread-or-workspace" }); + return null; + } + + const client = getEnvironmentClient(selectedThread.environmentId); + if (!client) { + terminalDebugLog("attach:abort", { + reason: "no-environment-client", + environmentId: selectedThread.environmentId, + }); + return null; + } + + const pendingLaunchTarget = { + environmentId: selectedThread.environmentId, + threadId: selectedThread.id, + terminalId, + }; + const pendingLaunch = takePendingTerminalLaunch(pendingLaunchTarget); + let initialInputSent = false; + + try { + const launchLocation = pendingLaunch + ? { + cwd: pendingLaunch.cwd, + worktreePath: pendingLaunch.worktreePath, + } + : resolveTerminalOpenLocation({ + terminalLocation: terminalAttachLaunchHintsRef.current.terminalSummary, + activeSessionLocation: terminalAttachLaunchHintsRef.current.activeKnownSummary, + workspaceRoot: selectedThreadProject.workspaceRoot, + threadShellWorktreePath: selectedThread.worktreePath ?? null, + threadDetailWorktreePath: selectedThreadDetail?.worktreePath ?? null, + }); + + terminalDebugLog("attach:start", { + terminalId, + threadId: selectedThread.id, + cols: lastGridSize.cols, + rows: lastGridSize.rows, + cwd: launchLocation.cwd, + worktreePath: launchLocation.worktreePath, + }); + + return attachTerminalSession({ + environmentId: selectedThread.environmentId, + client, + terminal: { + threadId: selectedThread.id, + terminalId, + cwd: launchLocation.cwd, + worktreePath: launchLocation.worktreePath, + cols: lastGridSize.cols, + rows: lastGridSize.rows, + env: pendingLaunch?.env, + ...(pendingLaunch ? { restartIfNotRunning: true } : {}), + }, + onEvent: logAttachStreamEvent, + onSnapshot: () => { + if (!pendingLaunch?.initialInput || initialInputSent) { + return; + } + + initialInputSent = true; + void client.terminal.write({ + threadId: selectedThread.id, + terminalId, + data: pendingLaunch.initialInput, + }); + }, + }); + } catch (error) { + terminalDebugLog("attach:error", { + message: error instanceof Error ? error.message : String(error), + }); + if (pendingLaunch) { + stagePendingTerminalLaunch({ + target: pendingLaunchTarget, + launch: pendingLaunch, + }); + } + + throw error; + } + }, [ + lastGridSize.cols, + lastGridSize.rows, + logAttachStreamEvent, + selectedThreadDetail?.worktreePath, + selectedThread, + selectedThreadProject?.workspaceRoot, + terminalId, + ]); + + const attachTerminalRef = useRef(attachTerminal); + attachTerminalRef.current = attachTerminal; + const selectedThreadRef = useRef(selectedThread); + selectedThreadRef.current = selectedThread; + const selectedThreadProjectBootstrapRef = useRef(selectedThreadProject); + selectedThreadProjectBootstrapRef.current = selectedThreadProject; + const runningSessionRef = useRef(runningSession); + runningSessionRef.current = runningSession; + const terminalBootstrapRef = useRef({ + status: terminal.status, + bufferLen: terminal.buffer.length, + }); + terminalBootstrapRef.current = { + status: terminal.status, + bufferLen: terminal.buffer.length, + }; + + useEffect(() => { + hasOpenedRef.current = false; + attachStreamLogCountRef.current = 0; + firstNonEmptyBufferLoggedRef.current = false; + }, [terminalKey]); + + const clearBufferReplayTimer = useCallback(() => { + if (bufferReplayTimerRef.current !== null) { + clearTimeout(bufferReplayTimerRef.current); + bufferReplayTimerRef.current = null; + } + }, []); + + const scheduleBufferReplayReady = useCallback(() => { + clearBufferReplayTimer(); + const replayKey = bufferReplayKey; + terminalDebugLog("replay:schedule-ready", { + replayKey, + delayMs: TERMINAL_BUFFER_REPLAY_STABILITY_DELAY_MS, + }); + bufferReplayTimerRef.current = setTimeout(() => { + bufferReplayTimerRef.current = null; + setReadyBufferReplayKey(replayKey); + terminalDebugLog("replay:ready", { replayKey }); + }, TERMINAL_BUFFER_REPLAY_STABILITY_DELAY_MS); + }, [bufferReplayKey, clearBufferReplayTimer]); + + useEffect(() => { + if (lastBufferReplayKeyRef.current === bufferReplayKey) { + return; + } + + lastBufferReplayKeyRef.current = bufferReplayKey; + clearBufferReplayTimer(); + setReadyBufferReplayKey(null); + }, [bufferReplayKey, clearBufferReplayTimer]); + + useEffect(() => clearBufferReplayTimer, [clearBufferReplayTimer]); + + useEffect(() => { + if (!routeEnvironmentId || !routeThreadId) { + setLastGridSize({ + cols: DEFAULT_TERMINAL_COLS, + rows: DEFAULT_TERMINAL_ROWS, + }); + return; + } + + setLastGridSize( + getCachedTerminalGridSize({ + environmentId: routeEnvironmentId, + threadId: routeThreadId, + terminalId, + }) ?? { + cols: DEFAULT_TERMINAL_COLS, + rows: DEFAULT_TERMINAL_ROWS, + }, + ); + setHasMeasuredSurface(true); + }, [routeEnvironmentId, routeThreadId, terminalId]); + + useEffect(() => { + let cancelled = false; + + void loadPreferences() + .then((preferences) => { + if (cancelled) { + return; + } + + setFontSize(cacheTerminalFontSize(preferences.terminalFontSize)); + setHasResolvedFontPreference(true); + }) + .catch(() => { + if (cancelled) { + return; + } + + setHasResolvedFontPreference(true); + }); + + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => { + if (!hasResolvedFontPreference) { + return; + } + + cacheTerminalFontSize(fontSize); + void savePreferencesPatch({ + terminalFontSize: normalizeTerminalFontSize(fontSize), + }); + }, [fontSize, hasResolvedFontPreference]); + + // Subscribes `terminal.attach` once per route+terminal until thread/env/attach args change. + // Use refs for `attachTerminal` / `selectedThread` / `runningSession`: their identities change when + // unrelated store updates (e.g. terminal buffer) re-render the parent, which was firing cleanup + // → detach immediately after the first snapshot. + useEffect(() => { + if (!hasResolvedFontPreference || !hasMeasuredSurface) { + return; + } + + const thread = selectedThreadRef.current; + const project = selectedThreadProjectBootstrapRef.current; + const running = runningSessionRef.current; + const termSnap = terminalBootstrapRef.current; + + const bootstrapAction = resolveTerminalRouteBootstrap({ + hasThread: thread !== null, + hasWorkspaceRoot: Boolean(project?.workspaceRoot), + hasOpened: hasOpenedRef.current, + requestedTerminalId, + currentTerminalId: terminalId, + runningTerminalId: running?.target.terminalId ?? null, + currentTerminalStatus: termSnap.status, + // Metadata summary (cwd/status) is not scrollback. Only `terminal.attach` fills `buffer`; + // treating summary as "hydrated" skipped attach while status was running → empty surface. + hasCurrentTerminalHydration: termSnap.bufferLen > 0, + }); + if (bootstrapAction.kind !== "idle") { + terminalDebugLog("bootstrap:action", { + kind: bootstrapAction.kind, + hasOpenedBefore: hasOpenedRef.current, + hasHydration: termSnap.bufferLen > 0, + terminalStatus: termSnap.status, + bufLen: termSnap.bufferLen, + }); + } + if (bootstrapAction.kind === "idle" || !thread) { + return; + } + + if (bootstrapAction.kind === "redirect") { + router.replace(buildThreadTerminalNavigation(thread, bootstrapAction.terminalId)); + return; + } + + hasOpenedRef.current = true; + try { + const detach = attachTerminalRef.current(); + terminalDebugLog("bootstrap:subscribe", { hasDetach: Boolean(detach) }); + if (!detach) { + hasOpenedRef.current = false; + return; + } + return () => { + detach(); + hasOpenedRef.current = false; + terminalDebugLog("bootstrap:unsubscribe"); + }; + } catch (error) { + hasOpenedRef.current = false; + terminalDebugLog("bootstrap:attach-threw", { + message: error instanceof Error ? error.message : String(error), + }); + return; + } + }, [ + hasMeasuredSurface, + hasResolvedFontPreference, + requestedTerminalId, + router, + selectedThread?.environmentId, + selectedThread?.id, + selectedThreadProject?.workspaceRoot, + terminalId, + ]); + + const writeInput = useCallback( + (data: string) => { + if (!selectedThread || !isRunning) { + return; + } + + const client = getEnvironmentClient(selectedThread.environmentId); + if (!client) { + return; + } + + void client.terminal.write({ + threadId: selectedThread.id, + terminalId, + data, + }); + }, + [isRunning, selectedThread, terminalId], + ); + + const handleInput = useCallback( + (data: string) => { + if (data.length === 0) { + return; + } + + if (pendingModifier === "ctrl") { + setPendingModifierState({ terminalId, value: null }); + writeInput(applyCtrlModifier(data)); + } else if (pendingModifier === "meta") { + setPendingModifierState({ terminalId, value: null }); + writeInput(`\u001b${data}`); + } else { + writeInput(data); + } + }, + [pendingModifier, terminalId, writeInput], + ); + + const handleResize = useCallback( + (size: { readonly cols: number; readonly rows: number }) => { + terminalDebugLog("native:onResize", { + cols: size.cols, + rows: size.rows, + terminalKey, + }); + setHasMeasuredSurface(true); + if (readyBufferReplayKey !== bufferReplayKey) { + scheduleBufferReplayReady(); + } + if (routeEnvironmentId && routeThreadId) { + cacheTerminalGridSize( + { + environmentId: routeEnvironmentId, + threadId: routeThreadId, + terminalId, + }, + size, + ); + } + if (size.cols === lastGridSize.cols && size.rows === lastGridSize.rows) { + return; + } + + setLastGridSize(size); + if (!selectedThread || !isRunning) { + return; + } + + const client = getEnvironmentClient(selectedThread.environmentId); + if (!client) { + return; + } + + void client.terminal.resize({ + threadId: selectedThread.id, + terminalId, + cols: size.cols, + rows: size.rows, + }); + }, + [ + isRunning, + lastGridSize.cols, + lastGridSize.rows, + bufferReplayKey, + readyBufferReplayKey, + routeEnvironmentId, + routeThreadId, + scheduleBufferReplayReady, + selectedThread, + terminalId, + terminalKey, + ], + ); + + const handleSelectTerminal = useCallback( + (nextTerminalId: string) => { + if (!selectedThread || nextTerminalId === terminalId) { + return; + } + + router.replace(buildThreadTerminalNavigation(selectedThread, nextTerminalId)); + }, + [router, selectedThread, terminalId], + ); + + const handleOpenNewTerminal = useCallback(() => { + if (!selectedThread) { + return; + } + + router.replace( + buildThreadTerminalNavigation( + selectedThread, + nextOpenTerminalId({ + listedTerminalIds: terminalMenuSessions.map((session) => session.terminalId), + activeRouteTerminalId: terminalId, + }), + ), + ); + }, [router, selectedThread, terminalId, terminalMenuSessions]); + + const adjustFontSize = useCallback((delta: number) => { + setTimeout(() => { + setFontSize((current) => cacheTerminalFontSize(current + delta)); + }, 0); + }, []); + + const handleDecreaseFontSize = useCallback(() => { + adjustFontSize(-TERMINAL_FONT_SIZE_STEP); + }, [adjustFontSize]); + + const handleIncreaseFontSize = useCallback(() => { + adjustFontSize(TERMINAL_FONT_SIZE_STEP); + }, [adjustFontSize]); + + const handleToolbarActionPress = useCallback( + (action: TerminalToolbarAction) => { + if (action.kind === "modifier") { + setPendingModifierState((current) => ({ + terminalId, + value: + (current.terminalId === terminalId ? current.value : null) === action.modifier + ? null + : action.modifier, + })); + return; + } + + setPendingModifierState({ terminalId, value: null }); + if (pendingModifier === "ctrl") { + writeInput(applyCtrlModifier(action.data)); + } else if (pendingModifier === "meta") { + writeInput(`\u001b${action.data}`); + } else { + writeInput(action.data); + } + }, + [pendingModifier, terminalId, writeInput], + ); + + if (!selectedThread) { + if (isLoadingSavedConnection) { + return ; + } + + return ( + + + + ); + } + + if (!selectedThreadProject?.workspaceRoot) { + return ( + + + + ); + } + + return ( + <> + ( + + + {headerTitle.topLine} + + + {headerTitle.bottomLine} + + + ), + }} + /> + + + + + {getTerminalStatusLabel({ + status: terminal.status, + hasRunningSubprocess: terminal.hasRunningSubprocess, + })} + + + Text size + + {`A- ${Math.max(MIN_TERMINAL_FONT_SIZE, fontSize - TERMINAL_FONT_SIZE_STEP).toFixed(1)} pt`} + + = MAX_TERMINAL_FONT_SIZE} + discoverabilityLabel="Increase terminal text size" + onPress={handleIncreaseFontSize} + > + {`A+ ${Math.min(MAX_TERMINAL_FONT_SIZE, fontSize + TERMINAL_FONT_SIZE_STEP).toFixed(1)} pt`} + + + {terminalMenuSessions.map((session) => ( + handleSelectTerminal(session.terminalId)} + subtitle={[getTerminalStatusLabel({ status: session.status }), basename(session.cwd)] + .filter(Boolean) + .join(" · ")} + > + {session.displayLabel} + + ))} + + Open new terminal + + + + + + + + + + + + + {terminalToolbarActions.map((action) => { + const active = action.kind === "modifier" && pendingModifier === action.modifier; + + return ( + handleToolbarActionPress(action)} + style={({ pressed }) => ({ + alignItems: "center", + backgroundColor: active + ? withAlpha(terminalTheme.palette[10] ?? terminalTheme.foreground, "2e") + : pressed + ? withAlpha(terminalTheme.foreground, "1f") + : withAlpha(terminalTheme.foreground, "12"), + borderColor: active + ? withAlpha(terminalTheme.palette[10] ?? terminalTheme.foreground, "52") + : terminalTheme.border, + borderRadius: 12, + borderWidth: 1, + justifyContent: "center", + minWidth: action.label.length > 1 ? 46 : 38, + paddingHorizontal: 11, + paddingVertical: 8, + })} + > + + {action.label} + + + ); + })} + + + + + + ); +} diff --git a/apps/mobile/src/features/terminal/nativeTerminalModule.test.ts b/apps/mobile/src/features/terminal/nativeTerminalModule.test.ts new file mode 100644 index 00000000000..db750f39842 --- /dev/null +++ b/apps/mobile/src/features/terminal/nativeTerminalModule.test.ts @@ -0,0 +1,52 @@ +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("resolveNativeTerminalSurfaceView", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.resetModules(); + globalThis.expo = undefined as unknown as typeof globalThis.expo; + }); + + afterEach(() => { + globalThis.expo = originalExpo; + }); + + it("returns null when the native terminal view config is unavailable", async () => { + const { resolveNativeTerminalSurfaceView } = await import("./nativeTerminalModule"); + expect(resolveNativeTerminalSurfaceView()).toBeNull(); + expect(expoModulesCoreMocks.requireNativeViewManager).not.toHaveBeenCalled(); + }); + + it("returns the native terminal view when the view config is installed", async () => { + setExpoViewConfigAvailable(); + expoModulesCoreMocks.requireNativeViewManager.mockReturnValue(nativeView); + const { resolveNativeTerminalSurfaceView } = await import("./nativeTerminalModule"); + expect(resolveNativeTerminalSurfaceView()).toBe(nativeView); + expect(expoModulesCoreMocks.requireNativeViewManager).toHaveBeenCalledWith("T3TerminalSurface"); + }); + + it("returns null when the view manager cannot be required", async () => { + setExpoViewConfigAvailable(); + expoModulesCoreMocks.requireNativeViewManager.mockImplementation(() => { + throw new Error("boom"); + }); + const { resolveNativeTerminalSurfaceView } = await import("./nativeTerminalModule"); + expect(resolveNativeTerminalSurfaceView()).toBeNull(); + }); +}); diff --git a/apps/mobile/src/features/terminal/nativeTerminalModule.ts b/apps/mobile/src/features/terminal/nativeTerminalModule.ts new file mode 100644 index 00000000000..85705c790dd --- /dev/null +++ b/apps/mobile/src/features/terminal/nativeTerminalModule.ts @@ -0,0 +1,65 @@ +import type { ComponentType } from "react"; +import type { NativeSyntheticEvent, ViewProps } from "react-native"; +import { requireNativeViewManager } from "expo-modules-core"; + +const NATIVE_TERMINAL_MODULE_NAME = "T3TerminalSurface"; + +interface ExpoGlobalWithViewConfig { + readonly expo?: { + getViewConfig?: (moduleName: string, viewName?: string) => unknown; + }; +} + +interface TerminalInputEvent { + readonly data: string; +} + +interface TerminalResizeEvent { + readonly cols: number; + readonly rows: number; +} + +export interface NativeTerminalSurfaceProps extends ViewProps { + readonly appearanceScheme?: "light" | "dark"; + readonly themeConfig?: string; + readonly backgroundColor?: string; + readonly foregroundColor?: string; + readonly mutedForegroundColor?: string; + readonly terminalKey: string; + readonly initialBuffer: string; + readonly fontSize: number; + readonly onInput?: (event: NativeSyntheticEvent) => void; + readonly onResize?: (event: NativeSyntheticEvent) => void; +} + +let cachedNativeTerminalSurfaceView: ComponentType | undefined; + +function getExpoViewConfig(moduleName: string) { + return (globalThis as typeof globalThis & ExpoGlobalWithViewConfig).expo?.getViewConfig?.( + moduleName, + ); +} + +export function resolveNativeTerminalSurfaceView(): ComponentType | null { + if (cachedNativeTerminalSurfaceView) { + return cachedNativeTerminalSurfaceView; + } + + if (getExpoViewConfig(NATIVE_TERMINAL_MODULE_NAME) == null) { + return null; + } + + try { + cachedNativeTerminalSurfaceView = requireNativeViewManager( + NATIVE_TERMINAL_MODULE_NAME, + ); + } catch { + return null; + } + + return cachedNativeTerminalSurfaceView ?? null; +} + +export function hasNativeTerminalSurface() { + return resolveNativeTerminalSurfaceView() !== null; +} diff --git a/apps/mobile/src/features/terminal/terminalBufferReplay.test.ts b/apps/mobile/src/features/terminal/terminalBufferReplay.test.ts new file mode 100644 index 00000000000..b4b3a033fc1 --- /dev/null +++ b/apps/mobile/src/features/terminal/terminalBufferReplay.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; + +import { getTerminalBufferReplayKey, getTerminalSurfaceReplayBuffer } from "./terminalBufferReplay"; + +describe("terminalBufferReplay", () => { + it("keys replay readiness by terminal identity and font metrics", () => { + expect( + getTerminalBufferReplayKey({ + terminalKey: "env-1:thread-1:default", + fontSize: 10, + }), + ).toBe("env-1:thread-1:default:10"); + }); + + it("shows terminal history while replay key is unset (initial mount / after key change)", () => { + const replayKey = getTerminalBufferReplayKey({ + terminalKey: "env-1:thread-1:default", + fontSize: 10, + }); + + expect( + getTerminalSurfaceReplayBuffer({ + buffer: "fastfetch output", + replayKey, + readyReplayKey: null, + }), + ).toBe("fastfetch output"); + expect( + getTerminalSurfaceReplayBuffer({ + buffer: "fastfetch output", + replayKey, + readyReplayKey: "env-1:thread-1:default:11", + }), + ).toBe(""); + expect( + getTerminalSurfaceReplayBuffer({ + buffer: "fastfetch output", + replayKey, + readyReplayKey: replayKey, + }), + ).toBe("fastfetch output"); + }); +}); diff --git a/apps/mobile/src/features/terminal/terminalBufferReplay.ts b/apps/mobile/src/features/terminal/terminalBufferReplay.ts new file mode 100644 index 00000000000..edbdeb37f3a --- /dev/null +++ b/apps/mobile/src/features/terminal/terminalBufferReplay.ts @@ -0,0 +1,29 @@ +import { terminalDebugLog } from "./terminalDebugLog"; + +export const TERMINAL_BUFFER_REPLAY_STABILITY_DELAY_MS = 180; + +export function getTerminalBufferReplayKey(input: { + readonly terminalKey: string; + readonly fontSize: number; +}): string { + return `${input.terminalKey}:${input.fontSize}`; +} + +export function getTerminalSurfaceReplayBuffer(input: { + readonly buffer: string; + readonly replayKey: string; + readonly readyReplayKey: string | null; +}): string { + // Pass live buffer whenever ready key is unset or matches. Only return "" when ready key is + // stale vs current replay key (e.g. mid font-size transition). + if (input.readyReplayKey !== null && input.readyReplayKey !== input.replayKey) { + terminalDebugLog("replay:stale-key-hiding-buffer", { + replayKey: input.replayKey, + readyReplayKey: input.readyReplayKey, + bufferLen: input.buffer.length, + }); + return ""; + } + + return input.buffer; +} diff --git a/apps/mobile/src/features/terminal/terminalDebugLog.ts b/apps/mobile/src/features/terminal/terminalDebugLog.ts new file mode 100644 index 00000000000..eb11419b330 --- /dev/null +++ b/apps/mobile/src/features/terminal/terminalDebugLog.ts @@ -0,0 +1,24 @@ +/** + * Debug logging for the mobile terminal pipeline. Prefix: `[t3-terminal]`. + * + * Enabled when `__DEV__` is true, or set `globalThis.__T3_TERMINAL_DEBUG__ = true` in a JS + * debugger / Metro console to trace release/TestFlight builds. + */ +export function isTerminalDebugEnabled(): boolean { + return ( + (typeof __DEV__ !== "undefined" && __DEV__) || + (typeof globalThis !== "undefined" && + (globalThis as { __T3_TERMINAL_DEBUG__?: boolean }).__T3_TERMINAL_DEBUG__ === true) + ); +} + +export function terminalDebugLog(message: string, data?: Record): void { + if (!isTerminalDebugEnabled()) { + return; + } + if (data !== undefined) { + console.log(`[t3-terminal] ${message}`, data); + } else { + console.log(`[t3-terminal] ${message}`); + } +} diff --git a/apps/mobile/src/features/terminal/terminalLaunchContext.test.ts b/apps/mobile/src/features/terminal/terminalLaunchContext.test.ts new file mode 100644 index 00000000000..8324aeb1cf9 --- /dev/null +++ b/apps/mobile/src/features/terminal/terminalLaunchContext.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from "vitest"; +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; + +import { + peekPendingTerminalLaunch, + resolvePreferredThreadWorktreePath, + resolveTerminalOpenLocation, + stagePendingTerminalLaunch, + takePendingTerminalLaunch, +} from "./terminalLaunchContext"; + +describe("resolvePreferredThreadWorktreePath", () => { + it("prefers thread detail worktree paths over thread shell paths", () => { + expect( + resolvePreferredThreadWorktreePath({ + threadShellWorktreePath: "/repo/root", + threadDetailWorktreePath: "/repo/worktrees/feature", + }), + ).toBe("/repo/worktrees/feature"); + }); + + it("falls back to the thread shell worktree path when detail is unavailable", () => { + expect( + resolvePreferredThreadWorktreePath({ + threadShellWorktreePath: "/repo/worktrees/feature", + threadDetailWorktreePath: null, + }), + ).toBe("/repo/worktrees/feature"); + }); +}); + +describe("resolveTerminalOpenLocation", () => { + it("uses the thread detail worktree path before the workspace root for a fresh mobile open", () => { + expect( + resolveTerminalOpenLocation({ + terminalLocation: null, + activeSessionLocation: null, + workspaceRoot: "/repo/root", + threadShellWorktreePath: null, + threadDetailWorktreePath: "/repo/worktrees/feature", + }), + ).toEqual({ + cwd: "/repo/worktrees/feature", + worktreePath: "/repo/worktrees/feature", + }); + }); + + it("preserves the running terminal snapshot cwd when attaching to an existing session", () => { + expect( + resolveTerminalOpenLocation({ + terminalLocation: null, + activeSessionLocation: { + cwd: "/repo/worktrees/feature", + worktreePath: "/repo/worktrees/feature", + }, + workspaceRoot: "/repo/root", + threadShellWorktreePath: null, + threadDetailWorktreePath: "/repo/worktrees/other", + }), + ).toEqual({ + cwd: "/repo/worktrees/feature", + worktreePath: "/repo/worktrees/feature", + }); + }); +}); + +describe("pending terminal launches", () => { + it("stages and consumes launch details for a specific terminal target", () => { + const target = { + environmentId: EnvironmentId.make("env-1"), + threadId: ThreadId.make("thread-1"), + terminalId: "term-2", + }; + + stagePendingTerminalLaunch({ + target, + launch: { + cwd: "/repo/worktrees/feature", + worktreePath: "/repo/worktrees/feature", + env: { FOO: "bar" }, + initialInput: "pnpm dev\r", + }, + }); + + expect(peekPendingTerminalLaunch(target)).toEqual({ + cwd: "/repo/worktrees/feature", + worktreePath: "/repo/worktrees/feature", + env: { FOO: "bar" }, + initialInput: "pnpm dev\r", + }); + expect(takePendingTerminalLaunch(target)).toEqual({ + cwd: "/repo/worktrees/feature", + worktreePath: "/repo/worktrees/feature", + env: { FOO: "bar" }, + initialInput: "pnpm dev\r", + }); + expect(peekPendingTerminalLaunch(target)).toBeNull(); + }); + + it("keeps pending launches isolated per terminal target", () => { + const primaryTarget = { + environmentId: EnvironmentId.make("env-1"), + threadId: ThreadId.make("thread-1"), + terminalId: "term-2", + }; + const otherTarget = { + environmentId: EnvironmentId.make("env-1"), + threadId: ThreadId.make("thread-1"), + terminalId: "term-3", + }; + + stagePendingTerminalLaunch({ + target: primaryTarget, + launch: { + cwd: "/repo/root", + worktreePath: null, + initialInput: "pnpm i\r", + }, + }); + + expect(peekPendingTerminalLaunch(otherTarget)).toBeNull(); + expect(takePendingTerminalLaunch(otherTarget)).toBeNull(); + expect(takePendingTerminalLaunch(primaryTarget)).toEqual({ + cwd: "/repo/root", + worktreePath: null, + env: undefined, + initialInput: "pnpm i\r", + }); + }); +}); diff --git a/apps/mobile/src/features/terminal/terminalLaunchContext.ts b/apps/mobile/src/features/terminal/terminalLaunchContext.ts new file mode 100644 index 00000000000..c1a77492039 --- /dev/null +++ b/apps/mobile/src/features/terminal/terminalLaunchContext.ts @@ -0,0 +1,90 @@ +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; + +interface TerminalLocationLike { + readonly cwd: string; + readonly worktreePath: string | null; +} + +interface PendingTerminalLaunchTarget { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; + readonly terminalId: string; +} + +export interface PendingTerminalLaunch { + readonly cwd: string; + readonly worktreePath: string | null; + readonly env?: Record; + readonly initialInput?: string; +} + +const pendingTerminalLaunches = new Map(); + +function pendingTerminalLaunchKey(input: PendingTerminalLaunchTarget): string { + return `${input.environmentId}:${input.threadId}:${input.terminalId}`; +} + +export function stagePendingTerminalLaunch(input: { + readonly target: PendingTerminalLaunchTarget; + readonly launch: PendingTerminalLaunch; +}) { + pendingTerminalLaunches.set(pendingTerminalLaunchKey(input.target), { + cwd: input.launch.cwd, + worktreePath: input.launch.worktreePath, + env: input.launch.env ? { ...input.launch.env } : undefined, + initialInput: input.launch.initialInput, + }); +} + +export function peekPendingTerminalLaunch( + target: PendingTerminalLaunchTarget, +): PendingTerminalLaunch | null { + return pendingTerminalLaunches.get(pendingTerminalLaunchKey(target)) ?? null; +} + +export function takePendingTerminalLaunch( + target: PendingTerminalLaunchTarget, +): PendingTerminalLaunch | null { + const key = pendingTerminalLaunchKey(target); + const launch = pendingTerminalLaunches.get(key) ?? null; + if (launch) { + pendingTerminalLaunches.delete(key); + } + + return launch; +} + +export function resolvePreferredThreadWorktreePath(input: { + readonly threadShellWorktreePath: string | null; + readonly threadDetailWorktreePath: string | null; +}): string | null { + return input.threadDetailWorktreePath ?? input.threadShellWorktreePath ?? null; +} + +export function resolveTerminalOpenLocation(input: { + readonly terminalLocation: TerminalLocationLike | null; + readonly activeSessionLocation: TerminalLocationLike | null; + readonly workspaceRoot: string; + readonly threadShellWorktreePath: string | null; + readonly threadDetailWorktreePath: string | null; +}): { + readonly cwd: string; + readonly worktreePath: string | null; +} { + const preferredThreadWorktreePath = resolvePreferredThreadWorktreePath({ + threadShellWorktreePath: input.threadShellWorktreePath, + threadDetailWorktreePath: input.threadDetailWorktreePath, + }); + + return { + cwd: + input.terminalLocation?.cwd ?? + input.activeSessionLocation?.cwd ?? + preferredThreadWorktreePath ?? + input.workspaceRoot, + worktreePath: + input.terminalLocation?.worktreePath ?? + input.activeSessionLocation?.worktreePath ?? + preferredThreadWorktreePath, + }; +} diff --git a/apps/mobile/src/features/terminal/terminalMenu.test.ts b/apps/mobile/src/features/terminal/terminalMenu.test.ts new file mode 100644 index 00000000000..f5def9542d9 --- /dev/null +++ b/apps/mobile/src/features/terminal/terminalMenu.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, it } from "vitest"; + +import type { KnownTerminalSession } from "@t3tools/client-runtime"; +import { DEFAULT_TERMINAL_ID, EnvironmentId, ThreadId } from "@t3tools/contracts"; + +import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; + +import { + buildTerminalMenuSessions, + nextOpenTerminalId, + nextTerminalId, + resolveProjectScriptTerminalId, +} from "./terminalMenu"; + +function makeKnownSession(input: { + readonly terminalId: string; + readonly status: KnownTerminalSession["state"]["status"]; + readonly cwd?: string | null; + readonly updatedAt?: string | null; +}): KnownTerminalSession { + return { + target: { + environmentId: EnvironmentId.make("env-1"), + threadId: ThreadId.make("thread-1"), + terminalId: input.terminalId, + }, + state: { + summary: input.cwd + ? { + threadId: "thread-1", + terminalId: input.terminalId, + cwd: input.cwd, + worktreePath: input.cwd, + status: input.status === "closed" ? "error" : input.status, + pid: input.status === "running" ? 123 : null, + exitCode: null, + exitSignal: null, + hasRunningSubprocess: false, + label: getTerminalLabel(input.terminalId), + updatedAt: input.updatedAt ?? "2026-04-15T20:00:00.000Z", + } + : null, + buffer: "", + status: input.status, + error: null, + hasRunningSubprocess: false, + updatedAt: input.updatedAt ?? "2026-04-15T20:00:00.000Z", + version: 1, + }, + }; +} + +describe("buildTerminalMenuSessions", () => { + it("only lists server-known sessions that are running or starting (plus current)", () => { + expect( + buildTerminalMenuSessions({ + knownSessions: [ + makeKnownSession({ + terminalId: "term-3", + status: "running", + cwd: "/workspace/feature", + updatedAt: "2026-04-15T20:05:00.000Z", + }), + makeKnownSession({ + terminalId: "term-2", + status: "exited", + cwd: "/workspace/exited", + updatedAt: "2026-04-15T20:06:00.000Z", + }), + ], + workspaceRoot: "/workspace/root", + }), + ).toEqual([ + { + terminalId: "term-3", + cwd: "/workspace/feature", + status: "running", + hasRunningSubprocess: false, + displayLabel: "Terminal 3", + updatedAt: "2026-04-15T20:05:00.000Z", + }, + ]); + }); + + it("keeps the current terminal visible even if it is no longer running", () => { + expect( + buildTerminalMenuSessions({ + knownSessions: [], + workspaceRoot: "/workspace/root", + currentSession: { + terminalId: "term-4", + cwd: "/workspace/exited", + status: "exited", + hasRunningSubprocess: false, + displayLabel: "Terminal 4", + updatedAt: "2026-04-15T20:07:00.000Z", + }, + }), + ).toEqual([ + { + terminalId: "term-4", + cwd: "/workspace/exited", + status: "exited", + hasRunningSubprocess: false, + displayLabel: "Terminal 4", + updatedAt: "2026-04-15T20:07:00.000Z", + }, + ]); + }); +}); + +describe("nextTerminalId", () => { + it("uses the primary id when no terminals are listed yet", () => { + expect(nextTerminalId([])).toBe(DEFAULT_TERMINAL_ID); + }); + + it("allocates term-2 when only the primary shell exists", () => { + expect(nextTerminalId([DEFAULT_TERMINAL_ID])).toBe("term-2"); + }); +}); + +describe("nextOpenTerminalId", () => { + it("matches nextTerminalId when not on a terminal route", () => { + expect(nextOpenTerminalId({ listedTerminalIds: [] })).toBe(DEFAULT_TERMINAL_ID); + expect(nextOpenTerminalId({ listedTerminalIds: [DEFAULT_TERMINAL_ID] })).toBe("term-2"); + }); + + it("avoids the mounted primary tab when the session list is still empty", () => { + expect( + nextOpenTerminalId({ + listedTerminalIds: [], + activeRouteTerminalId: DEFAULT_TERMINAL_ID, + }), + ).toBe("term-2"); + }); + + it("does not double-count when the route id is already listed", () => { + expect( + nextOpenTerminalId({ + listedTerminalIds: [DEFAULT_TERMINAL_ID], + activeRouteTerminalId: DEFAULT_TERMINAL_ID, + }), + ).toBe("term-2"); + }); +}); + +describe("resolveProjectScriptTerminalId", () => { + it("reuses the default shell when no terminal is running", () => { + expect( + resolveProjectScriptTerminalId({ + existingTerminalIds: [DEFAULT_TERMINAL_ID], + hasRunningTerminal: false, + }), + ).toBe(DEFAULT_TERMINAL_ID); + }); + + it("opens a new terminal when a shell is already running", () => { + expect( + resolveProjectScriptTerminalId({ + existingTerminalIds: [DEFAULT_TERMINAL_ID, "term-2", "term-4"], + hasRunningTerminal: true, + }), + ).toBe("term-3"); + }); +}); diff --git a/apps/mobile/src/features/terminal/terminalMenu.ts b/apps/mobile/src/features/terminal/terminalMenu.ts new file mode 100644 index 00000000000..04ffaa682c9 --- /dev/null +++ b/apps/mobile/src/features/terminal/terminalMenu.ts @@ -0,0 +1,131 @@ +import type { KnownTerminalSession } from "@t3tools/client-runtime"; +import { DEFAULT_TERMINAL_ID, type ProjectScript } from "@t3tools/contracts"; +import { nextTerminalId, resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels"; + +export { + getTerminalLabel, + nextTerminalId, + resolveTerminalSessionLabel, +} from "@t3tools/shared/terminalLabels"; + +export interface TerminalMenuSession { + readonly terminalId: string; + readonly cwd: string | null; + readonly status: "starting" | "running" | "exited" | "error" | "closed"; + readonly hasRunningSubprocess: boolean; + /** Server-authoritative title with the same fallback rules as web. */ + readonly displayLabel: string; + readonly updatedAt: string | null; +} + +export function basename(input: string | null): string | null { + if (!input) { + return null; + } + + const normalized = input.replace(/\/+$/, ""); + if (normalized.length === 0) { + return "/"; + } + + const segments = normalized.split("/"); + return segments[segments.length - 1] ?? normalized; +} + +export function getTerminalStatusLabel(input: { + readonly status: TerminalMenuSession["status"]; + readonly hasRunningSubprocess?: boolean; +}): string { + if (input.status === "running") { + return input.hasRunningSubprocess ? "Task running" : "Ready"; + } + if (input.status === "starting") { + return "Starting"; + } + if (input.status === "exited") { + return "Exited"; + } + if (input.status === "error") { + return "Error"; + } + + return "Not started"; +} + +/** + * Picks an id for "open another shell". Counts the terminal screen already mounted + * (`activeRouteTerminalId`) as occupied so an empty session list on the primary route + * still advances to `term-2` instead of `replace`-navigating to the same `default` tab. + */ +export function nextOpenTerminalId(input: { + readonly listedTerminalIds: ReadonlyArray; + readonly activeRouteTerminalId?: string | null; +}): string { + const listed = input.listedTerminalIds.filter((id) => id.trim().length > 0); + const routeId = input.activeRouteTerminalId?.trim() ? input.activeRouteTerminalId : null; + + if (!routeId || listed.includes(routeId)) { + return nextTerminalId(listed); + } + + return nextTerminalId([...listed, routeId]); +} + +export function buildTerminalMenuSessions(input: { + readonly knownSessions: ReadonlyArray; + readonly workspaceRoot: string | null; + readonly currentSession?: TerminalMenuSession | null; +}): ReadonlyArray { + const sessionsById = new Map(); + + for (const session of input.knownSessions) { + if ( + session.state.status !== "running" && + session.state.status !== "starting" && + session.target.terminalId !== input.currentSession?.terminalId + ) { + continue; + } + + sessionsById.set(session.target.terminalId, { + terminalId: session.target.terminalId, + cwd: session.state.summary?.cwd ?? input.workspaceRoot, + status: session.state.status, + hasRunningSubprocess: session.state.hasRunningSubprocess, + displayLabel: resolveTerminalSessionLabel(session.target.terminalId, session.state.summary), + updatedAt: session.state.updatedAt, + }); + } + + if (input.currentSession && !sessionsById.has(input.currentSession.terminalId)) { + sessionsById.set(input.currentSession.terminalId, input.currentSession); + } + + return Array.from(sessionsById.values()).sort((left, right) => + left.terminalId.localeCompare(right.terminalId, undefined, { numeric: true }), + ); +} + +export function resolveProjectScriptTerminalId(input: { + readonly existingTerminalIds: ReadonlyArray; + readonly hasRunningTerminal: boolean; +}): string { + if (!input.hasRunningTerminal) { + return DEFAULT_TERMINAL_ID; + } + + return nextTerminalId(input.existingTerminalIds); +} + +export function projectScriptMenuLabel(script: ProjectScript): string { + return script.runOnWorktreeCreate ? `${script.name} (setup)` : script.name; +} + +export function projectScriptMenuIcon(icon: ProjectScript["icon"]) { + if (icon === "test") return "flask"; + if (icon === "lint") return "checklist"; + if (icon === "configure") return "wrench.and.screwdriver"; + if (icon === "build") return "hammer"; + if (icon === "debug") return "ladybug"; + return "play"; +} diff --git a/apps/mobile/src/features/terminal/terminalPreferences.test.ts b/apps/mobile/src/features/terminal/terminalPreferences.test.ts new file mode 100644 index 00000000000..06bfdfbf3c1 --- /dev/null +++ b/apps/mobile/src/features/terminal/terminalPreferences.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; + +import { + DEFAULT_TERMINAL_FONT_SIZE, + MAX_TERMINAL_FONT_SIZE, + MIN_TERMINAL_FONT_SIZE, + normalizeTerminalFontSize, +} from "./terminalPreferences"; + +describe("normalizeTerminalFontSize", () => { + it("returns the default size for missing or invalid values", () => { + expect(normalizeTerminalFontSize(undefined)).toBe(DEFAULT_TERMINAL_FONT_SIZE); + expect(normalizeTerminalFontSize(null)).toBe(DEFAULT_TERMINAL_FONT_SIZE); + expect(normalizeTerminalFontSize(Number.NaN)).toBe(DEFAULT_TERMINAL_FONT_SIZE); + }); + + it("clamps below the minimum", () => { + expect(normalizeTerminalFontSize(MIN_TERMINAL_FONT_SIZE - 4)).toBe(MIN_TERMINAL_FONT_SIZE); + }); + + it("clamps above the maximum", () => { + expect(normalizeTerminalFontSize(MAX_TERMINAL_FONT_SIZE + 4)).toBe(MAX_TERMINAL_FONT_SIZE); + }); + + it("preserves in-range values", () => { + expect(normalizeTerminalFontSize(9.5)).toBe(9.5); + }); +}); diff --git a/apps/mobile/src/features/terminal/terminalPreferences.ts b/apps/mobile/src/features/terminal/terminalPreferences.ts new file mode 100644 index 00000000000..095876fa2a8 --- /dev/null +++ b/apps/mobile/src/features/terminal/terminalPreferences.ts @@ -0,0 +1,12 @@ +export const DEFAULT_TERMINAL_FONT_SIZE = 10; +export const TERMINAL_FONT_SIZE_STEP = 0.5; +export const MIN_TERMINAL_FONT_SIZE = 6; +export const MAX_TERMINAL_FONT_SIZE = 14; + +export function normalizeTerminalFontSize(value: number | null | undefined): number { + if (typeof value !== "number" || !Number.isFinite(value)) { + return DEFAULT_TERMINAL_FONT_SIZE; + } + + return Math.min(MAX_TERMINAL_FONT_SIZE, Math.max(MIN_TERMINAL_FONT_SIZE, value)); +} diff --git a/apps/mobile/src/features/terminal/terminalRouteBootstrap.ts b/apps/mobile/src/features/terminal/terminalRouteBootstrap.ts new file mode 100644 index 00000000000..20514224d6f --- /dev/null +++ b/apps/mobile/src/features/terminal/terminalRouteBootstrap.ts @@ -0,0 +1,35 @@ +export function resolveTerminalRouteBootstrap(input: { + readonly hasThread: boolean; + readonly hasWorkspaceRoot: boolean; + readonly hasOpened: boolean; + readonly requestedTerminalId: string | null; + readonly currentTerminalId: string; + readonly runningTerminalId: string | null; + readonly currentTerminalStatus: "starting" | "running" | "exited" | "error" | "closed"; + /** True once the attach stream has populated scrollback (`buffer` non-empty), not merely metadata. */ + readonly hasCurrentTerminalHydration: boolean; +}): + | { readonly kind: "idle" } + | { readonly kind: "redirect"; readonly terminalId: string } + | { readonly kind: "open" } { + if (!input.hasThread || !input.hasWorkspaceRoot || input.hasOpened) { + return { kind: "idle" }; + } + + if ( + input.requestedTerminalId === null && + input.runningTerminalId !== null && + input.runningTerminalId !== input.currentTerminalId + ) { + return { kind: "redirect", terminalId: input.runningTerminalId }; + } + + if ( + (input.currentTerminalStatus === "running" || input.currentTerminalStatus === "starting") && + input.hasCurrentTerminalHydration + ) { + return { kind: "idle" }; + } + + return { kind: "open" }; +} diff --git a/apps/mobile/src/features/terminal/terminalTheme.test.ts b/apps/mobile/src/features/terminal/terminalTheme.test.ts new file mode 100644 index 00000000000..5c5dba9cb7c --- /dev/null +++ b/apps/mobile/src/features/terminal/terminalTheme.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; + +import { buildGhosttyThemeConfig, getPierreTerminalTheme } from "./terminalTheme"; + +describe("getPierreTerminalTheme", () => { + it("returns the Pierre light terminal palette", () => { + expect(getPierreTerminalTheme("light")).toMatchObject({ + background: "#f2f2f7", + foreground: "#6C6C71", + cursorForeground: "#009fff", + cursorBackground: "#f2f2f7", + }); + }); + + it("returns the Pierre dark terminal palette", () => { + expect(getPierreTerminalTheme("dark")).toMatchObject({ + background: "#0a0a0a", + foreground: "#adadb1", + cursorForeground: "#009fff", + cursorBackground: "#0a0a0a", + }); + }); +}); + +describe("buildGhosttyThemeConfig", () => { + it("serializes theme colors into a ghostty config file", () => { + const config = buildGhosttyThemeConfig(getPierreTerminalTheme("dark")); + + expect(config).toContain("background = #0a0a0a"); + expect(config).toContain("foreground = #adadb1"); + expect(config).toContain("cursor-color = #009fff"); + expect(config).toContain("palette = 0=#141415"); + expect(config).toContain("palette = 15=#c6c6c8"); + expect(config.endsWith("\n")).toBe(true); + }); +}); diff --git a/apps/mobile/src/features/terminal/terminalTheme.ts b/apps/mobile/src/features/terminal/terminalTheme.ts new file mode 100644 index 00000000000..c5ebd10b689 --- /dev/null +++ b/apps/mobile/src/features/terminal/terminalTheme.ts @@ -0,0 +1,86 @@ +export type TerminalAppearanceScheme = "light" | "dark"; + +export interface TerminalTheme { + readonly background: string; + readonly foreground: string; + readonly mutedForeground: string; + readonly border: string; + readonly cursorForeground: string; + readonly cursorBackground: string; + readonly palette: readonly string[]; +} + +const PIERRE_LIGHT_THEME: TerminalTheme = { + // Pierre terminal palette with the app's shared screen background. + background: "#f2f2f7", + foreground: "#6C6C71", + mutedForeground: "#8E8E95", + border: "#eeeeef", + cursorForeground: "#009fff", + cursorBackground: "#f2f2f7", + palette: [ + "#1F1F21", + "#ff2e3f", + "#0dbe4e", + "#ffca00", + "#009fff", + "#c635e4", + "#08c0ef", + "#c6c6c8", + "#1F1F21", + "#ff2e3f", + "#0dbe4e", + "#ffca00", + "#009fff", + "#c635e4", + "#08c0ef", + "#c6c6c8", + ], +}; + +const PIERRE_DARK_THEME: TerminalTheme = { + // Pierre terminal palette with the app's shared screen background. + background: "#0a0a0a", + foreground: "#adadb1", + mutedForeground: "#8E8E95", + border: "#2e2e30", + cursorForeground: "#009fff", + cursorBackground: "#0a0a0a", + palette: [ + "#141415", + "#ff2e3f", + "#0dbe4e", + "#ffca00", + "#009fff", + "#c635e4", + "#08c0ef", + "#c6c6c8", + "#141415", + "#ff2e3f", + "#0dbe4e", + "#ffca00", + "#009fff", + "#c635e4", + "#08c0ef", + "#c6c6c8", + ], +}; + +export function getPierreTerminalTheme(scheme: TerminalAppearanceScheme): TerminalTheme { + return scheme === "light" ? PIERRE_LIGHT_THEME : PIERRE_DARK_THEME; +} + +export function buildGhosttyThemeConfig(theme: TerminalTheme): string { + const lines = [ + `background = ${theme.background}`, + `foreground = ${theme.foreground}`, + `cursor-color = ${theme.cursorForeground}`, + `cursor-text = ${theme.cursorBackground}`, + ]; + + for (const [index, color] of theme.palette.entries()) { + lines.push(`palette = ${index}=${color}`); + } + + return `${lines.join("\n")}\n`; +} diff --git a/apps/mobile/src/features/terminal/terminalUiState.test.ts b/apps/mobile/src/features/terminal/terminalUiState.test.ts new file mode 100644 index 00000000000..f1016a84cab --- /dev/null +++ b/apps/mobile/src/features/terminal/terminalUiState.test.ts @@ -0,0 +1,53 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; + +import { + cacheTerminalFontSize, + cacheTerminalGridSize, + getCachedTerminalFontSize, + getCachedTerminalGridSize, + resetTerminalUiStateCaches, +} from "./terminalUiState"; + +describe("terminalUiState", () => { + beforeEach(() => { + resetTerminalUiStateCaches(); + }); + + it("caches terminal font size using the shared normalization rules", () => { + expect(getCachedTerminalFontSize()).toBeNull(); + expect(cacheTerminalFontSize(8.5)).toBe(8.5); + expect(getCachedTerminalFontSize()).toBe(8.5); + expect(cacheTerminalFontSize(100)).toBe(14); + expect(getCachedTerminalFontSize()).toBe(14); + }); + + it("stores terminal grid sizes per terminal target", () => { + const primaryTarget = { + environmentId: EnvironmentId.make("env-1"), + threadId: ThreadId.make("thread-1"), + terminalId: "default", + }; + const otherTarget = { + environmentId: EnvironmentId.make("env-1"), + threadId: ThreadId.make("thread-1"), + terminalId: "term-2", + }; + + expect(getCachedTerminalGridSize(primaryTarget)).toBeNull(); + expect( + cacheTerminalGridSize(primaryTarget, { + cols: 107.9, + rows: 33.2, + }), + ).toEqual({ + cols: 107, + rows: 33, + }); + expect(getCachedTerminalGridSize(primaryTarget)).toEqual({ + cols: 107, + rows: 33, + }); + expect(getCachedTerminalGridSize(otherTarget)).toBeNull(); + }); +}); diff --git a/apps/mobile/src/features/terminal/terminalUiState.ts b/apps/mobile/src/features/terminal/terminalUiState.ts new file mode 100644 index 00000000000..2cac0bf52b9 --- /dev/null +++ b/apps/mobile/src/features/terminal/terminalUiState.ts @@ -0,0 +1,52 @@ +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; + +import { DEFAULT_TERMINAL_FONT_SIZE, normalizeTerminalFontSize } from "./terminalPreferences"; + +export interface TerminalGridSize { + readonly cols: number; + readonly rows: number; +} + +export interface TerminalUiStateTarget { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; + readonly terminalId: string; +} + +const terminalGridSizeCache = new Map(); +let cachedTerminalFontSize: number | null = null; + +function terminalUiStateKey(target: TerminalUiStateTarget): string { + return `${target.environmentId}:${target.threadId}:${target.terminalId}`; +} + +export function getCachedTerminalFontSize(): number | null { + return cachedTerminalFontSize; +} + +export function cacheTerminalFontSize(value: number | null | undefined): number { + const normalized = normalizeTerminalFontSize(value ?? DEFAULT_TERMINAL_FONT_SIZE); + cachedTerminalFontSize = normalized; + return normalized; +} + +export function getCachedTerminalGridSize(target: TerminalUiStateTarget): TerminalGridSize | null { + return terminalGridSizeCache.get(terminalUiStateKey(target)) ?? null; +} + +export function cacheTerminalGridSize( + target: TerminalUiStateTarget, + size: TerminalGridSize, +): TerminalGridSize { + const normalized = { + cols: Math.max(1, Math.floor(size.cols)), + rows: Math.max(1, Math.floor(size.rows)), + }; + terminalGridSizeCache.set(terminalUiStateKey(target), normalized); + return normalized; +} + +export function resetTerminalUiStateCaches() { + cachedTerminalFontSize = null; + terminalGridSizeCache.clear(); +} diff --git a/apps/mobile/src/features/threads/ComposerCommandPopover.tsx b/apps/mobile/src/features/threads/ComposerCommandPopover.tsx new file mode 100644 index 00000000000..08746eb74e7 --- /dev/null +++ b/apps/mobile/src/features/threads/ComposerCommandPopover.tsx @@ -0,0 +1,211 @@ +import { isLiquidGlassSupported, LiquidGlassView } from "@callstack/liquid-glass"; +import type { ComposerTriggerKind } from "@t3tools/shared/composerTrigger"; +import type { ServerProviderSkill, ServerProviderSlashCommand } from "@t3tools/contracts"; +import { SymbolView } from "expo-symbols"; +import { memo } from "react"; +import { Pressable, ScrollView, useColorScheme, View, type ViewStyle } from "react-native"; + +import { AppText as Text } from "../../components/AppText"; + +export type ComposerCommandItem = + | { + readonly id: string; + readonly type: "path"; + readonly path: string; + readonly kind: "file" | "directory"; + readonly label: string; + readonly description: string; + } + | { + readonly id: string; + readonly type: "slash-command"; + readonly command: string; + readonly label: string; + readonly description: string; + } + | { + readonly id: string; + readonly type: "provider-slash-command"; + readonly command: ServerProviderSlashCommand; + readonly label: string; + readonly description: string; + } + | { + readonly id: string; + readonly type: "skill"; + readonly skill: ServerProviderSkill; + readonly label: string; + readonly description: string; + }; + +interface ComposerCommandPopoverProps { + readonly items: ReadonlyArray; + readonly triggerKind: ComposerTriggerKind | null; + readonly isLoading: boolean; + readonly onSelect: (item: ComposerCommandItem) => void; +} + +function PopoverSurface(props: { + readonly children: React.ReactNode; + readonly isDarkMode: boolean; + readonly style?: ViewStyle; +}) { + const baseStyle: ViewStyle = { + borderRadius: 16, + overflow: "hidden", + ...props.style, + }; + + if (isLiquidGlassSupported) { + return ( + + {props.children} + + ); + } + + return ( + + {props.children} + + ); +} + +function itemIcon(item: ComposerCommandItem) { + switch (item.type) { + case "path": + return item.kind === "directory" ? ("folder" as const) : ("doc" as const); + case "slash-command": + case "provider-slash-command": + return "terminal" as const; + case "skill": + return "cube" as const; + } +} + +function groupLabel(triggerKind: ComposerTriggerKind | null): string | null { + switch (triggerKind) { + case "slash-command": + return "Commands"; + case "skill": + return "Skills"; + case "path": + return "Files"; + default: + return null; + } +} + +function emptyText(triggerKind: ComposerTriggerKind | null, isLoading: boolean): string { + if (isLoading) { + return triggerKind === "path" ? "Searching files…" : "Loading…"; + } + switch (triggerKind) { + case "path": + return "No matching files or folders."; + case "skill": + return "No skills found."; + case "slash-command": + return "No matching commands."; + default: + return "No results."; + } +} + +const CommandRow = memo(function CommandRow(props: { + readonly item: ComposerCommandItem; + readonly onPress: () => void; + readonly isLast: boolean; +}) { + const iconName = itemIcon(props.item); + const iconColor = "#a1a1aa"; + + return ( + ({ + flexDirection: "row", + alignItems: "center", + paddingHorizontal: 14, + paddingVertical: 10, + gap: 10, + opacity: pressed ? 0.6 : 1, + borderBottomWidth: props.isLast ? 0 : 0.5, + borderBottomColor: "rgba(255,255,255,0.1)", + })} + > + + + {props.item.label} + + {props.item.description ? ( + + {props.item.description} + + ) : null} + + ); +}); + +export const ComposerCommandPopover = memo(function ComposerCommandPopover( + props: ComposerCommandPopoverProps, +) { + const isDarkMode = useColorScheme() === "dark"; + const label = groupLabel(props.triggerKind); + + return ( + + {label ? ( + + + {label} + + + ) : null} + {props.items.length > 0 ? ( + + {props.items.map((item, index) => ( + props.onSelect(item)} + isLast={index === props.items.length - 1} + /> + ))} + + ) : ( + + + {emptyText(props.triggerKind, props.isLoading)} + + + )} + + ); +}); diff --git a/apps/mobile/src/features/threads/GitActionProgressOverlay.tsx b/apps/mobile/src/features/threads/GitActionProgressOverlay.tsx new file mode 100644 index 00000000000..96fce3e3ccd --- /dev/null +++ b/apps/mobile/src/features/threads/GitActionProgressOverlay.tsx @@ -0,0 +1,121 @@ +import * as Haptics from "expo-haptics"; +import { SymbolView } from "expo-symbols"; +import { useCallback, useEffect, useRef } from "react"; +import { ActivityIndicator, Linking, Pressable, View } from "react-native"; +import Animated, { FadeIn, FadeOut } from "react-native-reanimated"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { AppText as Text } from "../../components/AppText"; +import { useThemeColor } from "../../lib/useThemeColor"; +import type { GitActionProgress } from "../../state/use-vcs-action-state"; + +export function GitActionProgressOverlay(props: { + readonly progress: GitActionProgress; + readonly onDismiss: () => void; +}) { + const { progress, onDismiss } = props; + const insets = useSafeAreaInsets(); + const prevPhaseRef = useRef(progress.phase); + + useEffect(() => { + const prev = prevPhaseRef.current; + prevPhaseRef.current = progress.phase; + + if (prev === "running" && progress.phase === "success") { + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + } else if (prev === "running" && progress.phase === "error") { + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); + } + }, [progress.phase]); + + const handlePress = useCallback(() => { + if (progress.prUrl) { + void Linking.openURL(progress.prUrl); + return; + } + if (progress.phase === "success" || progress.phase === "error") { + onDismiss(); + } + }, [onDismiss, progress.phase, progress.prUrl]); + + if (progress.phase === "idle") { + return null; + } + + return ( + + + + + + ); +} + +function OverlayContent(props: { readonly progress: GitActionProgress }) { + const { progress } = props; + const iconColor = useThemeColor("--color-icon"); + + const bgClass = + progress.phase === "error" + ? "bg-red-50 dark:bg-red-950/80 border-red-200 dark:border-red-800" + : "bg-card border-border"; + + return ( + + + + + {progress.label ? ( + + {progress.label} + + ) : null} + {progress.description ? ( + + {progress.description} + + ) : null} + + + {progress.prUrl ? ( + + ) : null} + + ); +} + +function OverlayIcon(props: { + readonly phase: GitActionProgress["phase"]; + readonly iconColor: ReturnType; +}) { + switch (props.phase) { + case "running": + return ; + case "success": + return ( + + + + ); + case "error": + return ( + + + + ); + default: + return null; + } +} diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx new file mode 100644 index 00000000000..9569c7bd422 --- /dev/null +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -0,0 +1,519 @@ +import { MenuView } from "@react-native-menu/menu"; +import { useRouter } from "expo-router"; +import { SymbolView } from "expo-symbols"; +import { TextInputWrapper } from "expo-paste-input"; +import { useCallback, useEffect, useMemo } from "react"; +import { Pressable, View, useColorScheme } from "react-native"; +import Animated, { useAnimatedKeyboard, useAnimatedStyle } from "react-native-reanimated"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { useThemeColor } from "../../lib/useThemeColor"; + +import { EnvironmentId, type ModelSelection } from "@t3tools/contracts"; + +import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; +import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip"; +import { ControlPill } from "../../components/ControlPill"; +import { ProviderIcon } from "../../components/ProviderIcon"; + +import { convertPastedImagesToAttachments, pickComposerImages } from "../../lib/composerImages"; +import { buildThreadRoutePath } from "../../lib/routes"; +import { useRemoteCatalog } from "../../state/use-remote-catalog"; +import { useNativePaste } from "../../lib/useNativePaste"; +import { CLAUDE_AGENT_EFFORT_OPTIONS } from "./claudeEffortOptions"; +import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider"; +import { useProjectActions } from "./use-project-actions"; + +export function NewTaskDraftScreen(props: { + readonly initialProjectRef?: { + readonly environmentId?: string; + readonly projectId?: string; + }; +}) { + const { projects } = useRemoteCatalog(); + const { onCreateThreadWithOptions } = useProjectActions(); + const flow = useNewTaskFlow(); + const router = useRouter(); + const insets = useSafeAreaInsets(); + const isDarkMode = useColorScheme() === "dark"; + const keyboard = useAnimatedKeyboard(); + const containerAnimatedStyle = useAnimatedStyle(() => ({ + paddingBottom: keyboard.height.value, + })); + const controlsBottomPadding = useAnimatedStyle(() => ({ + paddingBottom: keyboard.height.value > 0 ? 4 : Math.max(insets.bottom, 10), + })); + const { logicalProjects, selectedProject, setProject } = flow; + + const iconColor = useThemeColor("--color-icon"); + const borderColor = useThemeColor("--color-border"); + + useEffect(() => { + if (props.initialProjectRef?.environmentId && props.initialProjectRef?.projectId) { + const directProject = + projects.find( + (project) => + project.environmentId === props.initialProjectRef?.environmentId && + project.id === props.initialProjectRef?.projectId, + ) ?? null; + + if (directProject) { + setProject(directProject); + return; + } + } + + if (selectedProject) { + return; + } + + if (logicalProjects.length === 1) { + setProject(logicalProjects[0]!.project); + return; + } + + router.replace("/new"); + }, [ + logicalProjects, + projects, + props.initialProjectRef?.environmentId, + props.initialProjectRef?.projectId, + router, + selectedProject, + setProject, + ]); + + useEffect(() => { + if (!selectedProject) { + return; + } + void flow.loadBranches(); + }, [flow, selectedProject]); + + const environmentMenuActions = useMemo( + () => + flow.environments.map((environment) => ({ + id: `environment:${environment.environmentId}`, + title: environment.environmentLabel, + state: + flow.selectedEnvironmentId === environment.environmentId ? ("on" as const) : undefined, + })), + [flow.environments, flow.selectedEnvironmentId], + ); + + const modelMenuActions = useMemo( + () => + flow.providerGroups.map((group) => ({ + id: `provider:${group.providerKey}`, + title: group.providerLabel, + subtitle: group.models.find( + (model) => + flow.selectedModel && + model.selection.instanceId === flow.selectedModel.instanceId && + model.selection.model === flow.selectedModel.model, + )?.label, + subactions: group.models.map((option) => ({ + id: `model:${option.key}`, + title: option.label, + state: + flow.selectedModel && + option.selection.instanceId === flow.selectedModel.instanceId && + option.selection.model === flow.selectedModel.model + ? ("on" as const) + : undefined, + })), + })), + [flow.providerGroups, flow.selectedModel], + ); + + const optionsMenuActions = useMemo( + () => [ + { + id: "options-effort", + title: "Effort", + subtitle: `${flow.effort.charAt(0).toUpperCase()}${flow.effort.slice(1)}`, + subactions: CLAUDE_AGENT_EFFORT_OPTIONS.map((level) => ({ + id: `options:effort:${level}`, + title: `${level}${level === "high" ? " (default)" : ""}`, + state: flow.effort === level ? ("on" as const) : undefined, + })), + }, + { + id: "options-fast-mode", + title: "Fast Mode", + subtitle: flow.fastMode ? "On" : "Off", + subactions: ([false, true] as const).map((value) => ({ + id: `options:fast-mode:${value ? "on" : "off"}`, + title: value ? "On" : "Off", + state: flow.fastMode === value ? ("on" as const) : undefined, + })), + }, + { + id: "options-context-window", + title: "Context Window", + subtitle: flow.contextWindow, + subactions: (["200k", "1M"] as const).map((value) => ({ + id: `options:context-window:${value}`, + title: `${value}${value === "1M" ? " (default)" : ""}`, + state: flow.contextWindow === value ? ("on" as const) : undefined, + })), + }, + { + id: "options-runtime", + title: "Runtime", + subtitle: + flow.runtimeMode === "approval-required" + ? "Approve actions" + : flow.runtimeMode === "auto-accept-edits" + ? "Auto-accept edits" + : "Full access", + subactions: [ + { id: "options:runtime:approval-required", title: "Approve actions" }, + { id: "options:runtime:auto-accept-edits", title: "Auto-accept edits" }, + { id: "options:runtime:full-access", title: "Full access" }, + ].map((option) => { + const value = option.id.replace("options:runtime:", ""); + return { + id: option.id, + title: option.title, + state: flow.runtimeMode === value ? ("on" as const) : undefined, + }; + }), + }, + { + id: "options-interaction", + title: "Interaction", + subtitle: flow.interactionMode === "plan" ? "Plan" : "Default", + subactions: [ + { id: "options:interaction:default", title: "Default" }, + { id: "options:interaction:plan", title: "Plan" }, + ].map((option) => { + const value = option.id.replace("options:interaction:", ""); + return { + id: option.id, + title: option.title, + state: flow.interactionMode === value ? ("on" as const) : undefined, + }; + }), + }, + ], + [flow.contextWindow, flow.effort, flow.fastMode, flow.interactionMode, flow.runtimeMode], + ); + + const workspaceMenuActions = useMemo(() => { + const branchActions = + flow.availableBranches.length === 0 + ? [ + { + id: "workspace:branch:none", + title: flow.branchesLoading ? "Loading branches…" : "No branches available", + attributes: { disabled: true }, + }, + ] + : flow.availableBranches.slice(0, 12).map((branch) => { + const badge = branchBadgeLabel({ + branch, + project: flow.selectedProject, + }); + + return { + id: `workspace:branch:${branch.name}`, + title: branch.name, + subtitle: badge ? badge.toUpperCase() : undefined, + state: flow.selectedBranchName === branch.name ? ("on" as const) : undefined, + }; + }); + + return [ + { + id: "workspace:mode", + title: "Mode", + subtitle: flow.workspaceMode === "local" ? "Local" : "Worktree", + subactions: (["local", "worktree"] as const).map((value) => ({ + id: `workspace:mode:${value}`, + title: value === "local" ? "Local" : "Worktree", + state: flow.workspaceMode === value ? ("on" as const) : undefined, + })), + }, + { + id: "workspace:branch", + title: "Branch", + subtitle: flow.selectedBranchName ?? "Choose branch", + subactions: branchActions, + }, + ]; + }, [ + flow.availableBranches, + flow.branchesLoading, + flow.selectedBranchName, + flow.selectedProject, + flow.workspaceMode, + ]); + + function handleModelMenuAction(event: string) { + if (!event.startsWith("model:")) { + return; + } + // Defer state update so the native menu dismiss animation completes + // before re-rendering the menu actions (prevents submenu jump). + setTimeout(() => { + flow.setSelectedModelKey(event.slice("model:".length)); + }, 150); + } + + function handleEnvironmentMenuAction(event: string) { + if (!event.startsWith("environment:")) { + return; + } + flow.selectEnvironment(EnvironmentId.make(event.slice("environment:".length))); + } + + function handleOptionsMenuAction(event: string) { + if (event.startsWith("options:effort:")) { + flow.setEffort(event.slice("options:effort:".length) as typeof flow.effort); + return; + } + if (event.startsWith("options:fast-mode:")) { + flow.setFastMode(event.endsWith(":on")); + return; + } + if (event.startsWith("options:context-window:")) { + flow.setContextWindow(event.slice("options:context-window:".length)); + return; + } + if (event.startsWith("options:runtime:")) { + flow.setRuntimeMode( + event.slice("options:runtime:".length) as Parameters[0], + ); + return; + } + if (event.startsWith("options:interaction:")) { + flow.setInteractionMode( + event.slice("options:interaction:".length) as Parameters[0], + ); + } + } + + function handleWorkspaceMenuAction(event: string) { + if (event.startsWith("workspace:mode:")) { + flow.setWorkspaceMode( + event.slice("workspace:mode:".length) as Parameters[0], + ); + return; + } + if (event.startsWith("workspace:branch:")) { + const branchName = event.slice("workspace:branch:".length); + const branch = flow.availableBranches.find((candidate) => candidate.name === branchName); + if (branch) { + flow.selectBranch(branch); + } + } + } + + async function handlePickImages(): Promise { + const result = await pickComposerImages({ existingCount: flow.attachments.length }); + if (result.images.length > 0) { + flow.appendAttachments(result.images); + } + } + + const handleNativePasteImages = useCallback( + async (uris: ReadonlyArray) => { + try { + const images = await convertPastedImagesToAttachments({ + uris, + existingCount: flow.attachments.length, + }); + if (images.length > 0) { + flow.appendAttachments(images); + } + } catch (error) { + console.error("[native paste] error converting images", error); + } + }, + [flow], + ); + + const handleNativePaste = useNativePaste((uris) => { + void handleNativePasteImages(uris); + }); + + async function handleStart(): Promise { + if ( + !flow.selectedProject || + !flow.selectedModel || + flow.prompt.trim().length === 0 || + flow.submitting || + (flow.workspaceMode === "worktree" && !flow.selectedBranchName) + ) { + return; + } + + flow.setSubmitting(true); + try { + const modelWithOptions: ModelSelection = + flow.selectedModelOption?.providerDriver === "claudeAgent" + ? { + ...flow.selectedModel, + options: [ + { id: "effort", value: flow.effort }, + ...(flow.fastMode ? [{ id: "fastMode", value: true }] : []), + { id: "contextWindow", value: flow.contextWindow }, + ], + } + : flow.selectedModelOption?.providerDriver === "codex" + ? { + ...flow.selectedModel, + ...(flow.fastMode ? { options: [{ id: "fastMode", value: true }] } : {}), + } + : flow.selectedModel; + + const createdThread = await onCreateThreadWithOptions({ + project: flow.selectedProject, + modelSelection: modelWithOptions, + envMode: flow.workspaceMode, + branch: flow.selectedBranchName, + worktreePath: flow.workspaceMode === "worktree" ? null : flow.selectedWorktreePath, + runtimeMode: flow.runtimeMode, + interactionMode: flow.interactionMode, + initialMessageText: flow.prompt.trim(), + initialAttachments: flow.attachments, + }); + + if (createdThread) { + router.replace(buildThreadRoutePath(createdThread)); + } + } finally { + flow.setSubmitting(false); + } + } + + if (!selectedProject) { + return ( + + + + + New task + + Loading task + + + ); + } + + return ( + + + + + {flow.logicalProjects.length > 1 ? ( + router.back()} + > + + + ) : null} + + New task + + {selectedProject.title} + + + + void handleNativePaste(payload)}> + + + + + + {flow.attachments.length > 0 ? ( + + + + ) : null} + + void handlePickImages()} /> + handleModelMenuAction(nativeEvent.event)} + themeVariant={isDarkMode ? "dark" : "light"} + > + + } + /> + + handleOptionsMenuAction(nativeEvent.event)} + themeVariant={isDarkMode ? "dark" : "light"} + > + + + handleEnvironmentMenuAction(nativeEvent.event)} + themeVariant={isDarkMode ? "dark" : "light"} + > + + + handleWorkspaceMenuAction(nativeEvent.event)} + themeVariant={isDarkMode ? "dark" : "light"} + > + + + void handleStart()} + variant="primary" + disabled={ + !flow.selectedProject || + !flow.selectedModel || + flow.prompt.trim().length === 0 || + flow.submitting || + (flow.workspaceMode === "worktree" && !flow.selectedBranchName) + } + /> + + + + ); +} diff --git a/apps/mobile/src/features/threads/PendingApprovalCard.tsx b/apps/mobile/src/features/threads/PendingApprovalCard.tsx new file mode 100644 index 00000000000..eb9e929ed14 --- /dev/null +++ b/apps/mobile/src/features/threads/PendingApprovalCard.tsx @@ -0,0 +1,57 @@ +import type { ApprovalRequestId, ProviderApprovalDecision } from "@t3tools/contracts"; +import { Pressable, View } from "react-native"; + +import { AppText as Text } from "../../components/AppText"; +import type { PendingApproval } from "../../lib/threadActivity"; + +export interface PendingApprovalCardProps { + readonly approval: PendingApproval; + readonly respondingApprovalId: ApprovalRequestId | null; + readonly onRespond: ( + requestId: ApprovalRequestId, + decision: ProviderApprovalDecision, + ) => Promise; +} + +export function PendingApprovalCard(props: PendingApprovalCardProps) { + return ( + + + Approval needed + + + {props.approval.requestKind} + + {props.approval.detail ? ( + + {props.approval.detail} + + ) : null} + + void props.onRespond(props.approval.requestId, "accept")} + > + Allow once + + void props.onRespond(props.approval.requestId, "acceptForSession")} + > + + Allow session + + + void props.onRespond(props.approval.requestId, "decline")} + > + Decline + + + + ); +} diff --git a/apps/mobile/src/features/threads/PendingUserInputCard.tsx b/apps/mobile/src/features/threads/PendingUserInputCard.tsx new file mode 100644 index 00000000000..c42a7ff34e0 --- /dev/null +++ b/apps/mobile/src/features/threads/PendingUserInputCard.tsx @@ -0,0 +1,105 @@ +import type { ApprovalRequestId } from "@t3tools/contracts"; +import { Pressable, View } from "react-native"; + +import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; +import { cn } from "../../lib/cn"; +import type { PendingUserInput, PendingUserInputDraftAnswer } from "../../lib/threadActivity"; + +export interface PendingUserInputCardProps { + readonly pendingUserInput: PendingUserInput; + readonly drafts: Record; + readonly answers: Record | null; + readonly respondingUserInputId: ApprovalRequestId | null; + readonly onSelectOption: ( + requestId: ApprovalRequestId, + questionId: string, + label: string, + ) => void; + readonly onChangeCustomAnswer: ( + requestId: ApprovalRequestId, + questionId: string, + customAnswer: string, + ) => void; + readonly onSubmit: () => Promise; +} + +export function PendingUserInputCard(props: PendingUserInputCardProps) { + return ( + + + User input needed + + + Fill in the pending answers + + {props.pendingUserInput.questions.map((question) => { + const draft = props.drafts[question.id]; + return ( + + + {question.header} + + + {question.question} + + + {question.options.map((option) => { + const selected = + draft?.selectedOptionLabel === option.label && !draft.customAnswer?.trim().length; + return ( + + props.onSelectOption( + props.pendingUserInput.requestId, + question.id, + option.label, + ) + } + > + + {option.label} + + + ); + })} + + + props.onChangeCustomAnswer(props.pendingUserInput.requestId, question.id, value) + } + placeholder="Or type a custom answer" + className="min-h-[54px] rounded-2xl border border-neutral-200 bg-white px-3.5 py-3 font-sans text-[15px] text-neutral-950 dark:border-white/8 dark:bg-neutral-950/70 dark:text-neutral-50" + /> + + ); + })} + void props.onSubmit()} + > + Submit answers + + + ); +} diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx new file mode 100644 index 00000000000..654aee8f417 --- /dev/null +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -0,0 +1,828 @@ +import { isLiquidGlassSupported, LiquidGlassView } from "@callstack/liquid-glass"; +import { MenuView } from "@react-native-menu/menu"; +import type { + EnvironmentId, + ModelSelection, + OrchestrationThread, + ProviderInteractionMode, + RuntimeMode, + ServerConfig as T3ServerConfig, +} from "@t3tools/contracts"; +import { + detectComposerTrigger, + replaceTextRange, + type ComposerTrigger, +} from "@t3tools/shared/composerTrigger"; +import { + getModelSelectionBooleanOptionValue, + getModelSelectionStringOptionValue, +} from "@t3tools/shared/model"; +import { TextInputWrapper } from "expo-paste-input"; +import type { ReactNode } from "react"; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + Image, + Pressable, + TextInput as RNTextInput, + useColorScheme, + View, + type NativeSyntheticEvent, + type TextInputSelectionChangeEventData, + type ViewStyle, +} from "react-native"; +import ImageViewing from "react-native-image-viewing"; +import { useThemeColor } from "../../lib/useThemeColor"; + +import { AppText as Text } from "../../components/AppText"; +import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip"; +import { ControlPill } from "../../components/ControlPill"; +import { ProviderIcon } from "../../components/ProviderIcon"; +import type { DraftComposerImageAttachment } from "../../lib/composerImages"; +import { buildModelOptions, groupByProvider } from "../../lib/modelOptions"; +import type { RemoteClientConnectionState } from "../../lib/connection"; +import { useNativePaste } from "../../lib/useNativePaste"; +import { + insertRankedSearchResult, + normalizeSearchQuery, + scoreQueryMatch, +} from "@t3tools/shared/searchRanking"; +import { useComposerPathSearch } from "../../state/use-composer-path-search"; +import { CLAUDE_AGENT_EFFORT_OPTIONS, type ClaudeAgentEffort } from "./claudeEffortOptions"; +import { ComposerCommandPopover, type ComposerCommandItem } from "./ComposerCommandPopover"; + +/** + * Height of the collapsed composer (pill + vertical padding, excluding safe-area inset). + * Exported so the parent can compute feed overlap / content insets. + */ +export const COMPOSER_COLLAPSED_CHROME = 68; + +/** + * Height of the expanded composer (card + toolbar + vertical padding, excluding safe-area inset). + * Used by the parent to compute the larger feed bottom inset when the composer is focused. + */ +export const COMPOSER_EXPANDED_CHROME = 174; + +/** + * Height of the expanded-only toolbar below the text surface. + * Used by the feed inset because KeyboardAvoidingLegendList only accounts for + * keyboard height; the floating toolbar remains an additional overlay. + */ +export const COMPOSER_EXPANDED_TOOLBAR_CHROME = 54; + +function withModelSelectionOption( + selection: ModelSelection, + id: string, + value: string | boolean | undefined, +): ModelSelection { + const options = (selection.options ?? []).filter((option) => option.id !== id); + if (value !== undefined) { + options.push({ id, value }); + } + if (options.length === 0) { + const { options: _options, ...rest } = selection; + return rest as ModelSelection; + } + return { ...selection, options } as ModelSelection; +} + +export interface ThreadComposerProps { + readonly draftMessage: string; + readonly draftAttachments: ReadonlyArray; + readonly placeholder: string; + readonly bottomInset?: number; + readonly connectionState: RemoteClientConnectionState; + readonly selectedThread: OrchestrationThread; + readonly serverConfig: T3ServerConfig | null; + readonly queueCount: number; + readonly activeThreadBusy: boolean; + readonly environmentId: EnvironmentId; + readonly projectCwd: string | null; + readonly onChangeDraftMessage: (value: string) => void; + readonly onPickDraftImages: () => Promise; + readonly onNativePasteImages: (uris: ReadonlyArray) => Promise; + readonly onRemoveDraftImage: (imageId: string) => void; + readonly onRefresh: () => Promise; + readonly onStopThread: () => Promise; + readonly onSendMessage: () => void; + readonly onUpdateModelSelection: (modelSelection: ModelSelection) => Promise; + readonly onUpdateRuntimeMode: (runtimeMode: RuntimeMode) => Promise; + readonly onUpdateInteractionMode: (interactionMode: ProviderInteractionMode) => Promise; + readonly onExpandedChange?: (expanded: boolean) => void; +} + +/** + * The pill / card container — renders as LiquidGlassView on supported + * iOS 26+ devices (progressive blur, native morph), opaque View otherwise. + */ +function ComposerSurface(props: { + readonly children: ReactNode; + readonly style: ViewStyle; + readonly isDarkMode: boolean; +}) { + if (isLiquidGlassSupported) { + return ( + + {props.children} + + ); + } + + return ( + + {props.children} + + ); +} + +export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposerProps) { + const isDarkMode = useColorScheme() === "dark"; + const themePlaceholderColor = useThemeColor("--color-placeholder"); + const placeholderColor = isDarkMode ? "#a1a1aa" : themePlaceholderColor; + const foregroundColor = useThemeColor("--color-foreground"); + const inputRef = useRef(null); + const [isFocused, setIsFocused] = useState(false); + const wasExpandedBeforePreviewRef = useRef(false); + const { onExpandedChange } = props; + + const [previewImageUri, setPreviewImageUri] = useState(null); + const hasContent = props.draftMessage.trim().length > 0 || props.draftAttachments.length > 0; + const isExpanded = isFocused; + const canSend = props.connectionState === "ready" && hasContent; + + const onPressImage = useCallback( + (uri: string) => { + wasExpandedBeforePreviewRef.current = isFocused; + setPreviewImageUri(uri); + }, + [isFocused], + ); + + const closePreview = useCallback(() => { + setPreviewImageUri(null); + if (wasExpandedBeforePreviewRef.current) { + setTimeout(() => inputRef.current?.focus(), 100); + } + }, []); + + useEffect(() => { + onExpandedChange?.(isExpanded); + }, [isExpanded, onExpandedChange]); + const showStopAction = + props.selectedThread.session?.status === "running" || + props.selectedThread.session?.status === "starting" || + props.queueCount > 0; + + const sendLabel = props.activeThreadBusy || props.queueCount > 0 ? "Queue" : "Send"; + const currentModelSelection = props.selectedThread.modelSelection; + const currentModelDriver = + props.serverConfig?.providers.find( + (provider) => provider.instanceId === currentModelSelection.instanceId, + )?.driver ?? currentModelSelection.instanceId; + const modelProvider = currentModelDriver; + const currentRuntimeMode = props.selectedThread.runtimeMode; + const currentInteractionMode = props.selectedThread.interactionMode ?? "default"; + + // Extract current model options (effort, fastMode, contextWindow) + const currentEffort = + currentModelDriver === "claudeAgent" + ? ((getModelSelectionStringOptionValue(currentModelSelection, "effort") ?? + "high") as ClaudeAgentEffort) + : "high"; + const currentFastMode = + getModelSelectionBooleanOptionValue(currentModelSelection, "fastMode") ?? false; + const currentContextWindow = + currentModelDriver === "claudeAgent" + ? (getModelSelectionStringOptionValue(currentModelSelection, "contextWindow") ?? "1M") + : "1M"; + + const handleNativePaste = useNativePaste((uris) => { + void props.onNativePasteImages(uris); + }); + + // ── Trigger detection ──────────────────────────────────── + const [cursorPosition, setCursorPosition] = useState(0); + + const handleSelectionChange = useCallback( + (event: NativeSyntheticEvent) => { + const { start } = event.nativeEvent.selection; + setCursorPosition(start); + }, + [], + ); + + const composerTrigger = useMemo( + () => detectComposerTrigger(props.draftMessage, cursorPosition), + [cursorPosition, props.draftMessage], + ); + const pathSearch = useComposerPathSearch({ + environmentId: props.environmentId, + cwd: composerTrigger?.kind === "path" ? props.projectCwd : null, + query: composerTrigger?.kind === "path" ? composerTrigger.query : null, + }); + + // ── Build menu items ───────────────────────────────────── + const selectedProviderStatus = useMemo(() => { + if (!props.serverConfig) return null; + return ( + props.serverConfig.providers.find( + (p) => p.instanceId === props.selectedThread.modelSelection.instanceId, + ) ?? null + ); + }, [props.serverConfig, props.selectedThread.modelSelection.instanceId]); + + const composerMenuItems: ComposerCommandItem[] = useMemo(() => { + if (!composerTrigger) return []; + + if (composerTrigger.kind === "slash-command") { + const q = composerTrigger.query.toLowerCase(); + const allBuiltIn = [ + { + id: "cmd:model", + type: "slash-command" as const, + command: "model", + label: "/model", + description: "Switch model", + }, + { + id: "cmd:plan", + type: "slash-command" as const, + command: "plan", + label: "/plan", + description: "Switch to plan mode", + }, + { + id: "cmd:default", + type: "slash-command" as const, + command: "default", + label: "/default", + description: "Switch to default mode", + }, + ]; + const builtIn = allBuiltIn.filter((item) => item.command.includes(q)); + + const providerCommands: ComposerCommandItem[] = (selectedProviderStatus?.slashCommands ?? []) + .filter((cmd) => cmd.name.toLowerCase().includes(q)) + .map((cmd) => ({ + id: `pcmd:${cmd.name}`, + type: "provider-slash-command" as const, + command: cmd, + label: `/${cmd.name}`, + description: cmd.description ?? "", + })); + + return [...builtIn, ...providerCommands]; + } + + if (composerTrigger.kind === "skill") { + const enabledSkills = (selectedProviderStatus?.skills ?? []).filter((s) => s.enabled); + const normalizedQuery = normalizeSearchQuery(composerTrigger.query, { + trimLeadingPattern: /^\$+/, + }); + + if (!normalizedQuery) { + return enabledSkills.slice(0, 20).map((skill) => ({ + id: `skill:${skill.name}`, + type: "skill" as const, + skill, + label: skill.displayName ?? skill.name, + description: skill.shortDescription ?? skill.description ?? "", + })); + } + + const ranked: Array<{ + item: (typeof enabledSkills)[number]; + score: number; + tieBreaker: string; + }> = []; + for (const skill of enabledSkills) { + const displayLabel = (skill.displayName ?? skill.name).toLowerCase(); + const scores = [ + scoreQueryMatch({ + value: skill.name.toLowerCase(), + query: normalizedQuery, + exactBase: 0, + prefixBase: 2, + boundaryBase: 4, + includesBase: 6, + fuzzyBase: 100, + boundaryMarkers: ["-", "_", "/"], + }), + scoreQueryMatch({ + value: displayLabel, + query: normalizedQuery, + exactBase: 1, + prefixBase: 3, + boundaryBase: 5, + includesBase: 7, + fuzzyBase: 110, + }), + scoreQueryMatch({ + value: skill.shortDescription?.toLowerCase() ?? "", + query: normalizedQuery, + exactBase: 20, + prefixBase: 22, + boundaryBase: 24, + includesBase: 26, + }), + scoreQueryMatch({ + value: skill.description?.toLowerCase() ?? "", + query: normalizedQuery, + exactBase: 30, + prefixBase: 32, + boundaryBase: 34, + includesBase: 36, + }), + ].filter((s): s is number => s !== null); + + if (scores.length > 0) { + insertRankedSearchResult( + ranked, + { + item: skill, + score: Math.min(...scores), + tieBreaker: `${displayLabel}\u0000${skill.name}`, + }, + 20, + ); + } + } + + return ranked.map(({ item: skill }) => ({ + id: `skill:${skill.name}`, + type: "skill" as const, + skill, + label: skill.displayName ?? skill.name, + description: skill.shortDescription ?? skill.description ?? "", + })); + } + + if (composerTrigger.kind === "path") { + return pathSearch.entries.map((entry) => { + const parts = entry.path.split("/"); + return { + id: `path:${entry.path}`, + type: "path" as const, + path: entry.path, + kind: entry.kind, + label: parts[parts.length - 1] ?? entry.path, + description: parts.length > 1 ? parts.slice(0, -1).join("/") : "", + }; + }); + } + + return []; + }, [composerTrigger, pathSearch.entries, selectedProviderStatus]); + + // ── Handle command selection ────────────────────────────── + const { onChangeDraftMessage, onUpdateInteractionMode, draftMessage, onSendMessage } = props; + + const handleSend = useCallback(() => { + onSendMessage(); + inputRef.current?.blur(); + }, [onSendMessage]); + const handleCommandSelect = useCallback( + (item: ComposerCommandItem) => { + if (!composerTrigger) return; + + if ( + item.type === "slash-command" && + (item.command === "plan" || item.command === "default") + ) { + const result = replaceTextRange( + draftMessage, + composerTrigger.rangeStart, + composerTrigger.rangeEnd, + "", + ); + setCursorPosition(result.cursor); + onChangeDraftMessage(result.text); + void onUpdateInteractionMode(item.command); + return; + } + + let replacement = ""; + if (item.type === "path") { + replacement = `@${item.path} `; + } else if (item.type === "skill") { + replacement = `$${item.skill.name} `; + } else if (item.type === "slash-command") { + replacement = `/${item.command} `; + } else if (item.type === "provider-slash-command") { + replacement = `/${item.command.name} `; + } + + const result = replaceTextRange( + draftMessage, + composerTrigger.rangeStart, + composerTrigger.rangeEnd, + replacement, + ); + setCursorPosition(result.cursor); + onChangeDraftMessage(result.text); + }, + [composerTrigger, draftMessage, onChangeDraftMessage, onUpdateInteractionMode], + ); + + // ── Model menu ─────────────────────────────────────────── + const providerGroups = useMemo(() => { + const options = buildModelOptions(props.serverConfig, currentModelSelection); + return groupByProvider(options); + }, [props.serverConfig, currentModelSelection]); + + const modelMenuActions = useMemo( + () => + providerGroups.map((group) => ({ + id: `provider:${group.providerKey}`, + title: group.providerLabel, + subtitle: group.models.find( + (model) => + model.selection.instanceId === currentModelSelection.instanceId && + model.selection.model === currentModelSelection.model, + )?.label, + subactions: group.models.map((option) => ({ + id: `model:${option.key}`, + title: option.label, + state: + option.selection.instanceId === currentModelSelection.instanceId && + option.selection.model === currentModelSelection.model + ? ("on" as const) + : undefined, + })), + })), + [providerGroups, currentModelSelection], + ); + + // ── Options menu ───────────────────────────────────────── + const optionsMenuActions = useMemo( + () => [ + { + id: "options-effort", + title: "Effort", + subtitle: `${currentEffort.charAt(0).toUpperCase()}${currentEffort.slice(1)}`, + subactions: CLAUDE_AGENT_EFFORT_OPTIONS.map((level) => ({ + id: `options:effort:${level}`, + title: `${level}${level === "high" ? " (default)" : ""}`, + state: currentEffort === level ? ("on" as const) : undefined, + })), + }, + { + id: "options-fast-mode", + title: "Fast Mode", + subtitle: currentFastMode ? "On" : "Off", + subactions: ([false, true] as const).map((value) => ({ + id: `options:fast-mode:${value ? "on" : "off"}`, + title: value ? "On" : "Off", + state: currentFastMode === value ? ("on" as const) : undefined, + })), + }, + { + id: "options-context-window", + title: "Context Window", + subtitle: currentContextWindow, + subactions: (["200k", "1M"] as const).map((value) => ({ + id: `options:context-window:${value}`, + title: `${value}${value === "1M" ? " (default)" : ""}`, + state: currentContextWindow === value ? ("on" as const) : undefined, + })), + }, + { + id: "options-runtime", + title: "Runtime", + subtitle: + currentRuntimeMode === "approval-required" + ? "Approve actions" + : currentRuntimeMode === "auto-accept-edits" + ? "Auto-accept edits" + : "Full access", + subactions: [ + { id: "options:runtime:approval-required", title: "Approve actions" }, + { id: "options:runtime:auto-accept-edits", title: "Auto-accept edits" }, + { id: "options:runtime:full-access", title: "Full access" }, + ].map((option) => { + const value = option.id.replace("options:runtime:", ""); + return { + id: option.id, + title: option.title, + state: currentRuntimeMode === value ? ("on" as const) : undefined, + }; + }), + }, + { + id: "options-interaction", + title: "Interaction", + subtitle: currentInteractionMode === "plan" ? "Plan" : "Default", + subactions: [ + { id: "options:interaction:default", title: "Default" }, + { id: "options:interaction:plan", title: "Plan" }, + ].map((option) => { + const value = option.id.replace("options:interaction:", ""); + return { + id: option.id, + title: option.title, + state: currentInteractionMode === value ? ("on" as const) : undefined, + }; + }), + }, + ], + [ + currentEffort, + currentFastMode, + currentContextWindow, + currentRuntimeMode, + currentInteractionMode, + ], + ); + + // ── Menu handlers ──────────────────────────────────────── + function handleModelMenuAction(event: string) { + if (!event.startsWith("model:")) { + return; + } + const modelKey = event.slice("model:".length); + const options = buildModelOptions(props.serverConfig, currentModelSelection); + const option = options.find((o) => o.key === modelKey); + if (option) { + void props.onUpdateModelSelection(option.selection); + } + } + + function handleOptionsMenuAction(event: string) { + if (event.startsWith("options:effort:")) { + const effort = event.slice("options:effort:".length); + const updated: ModelSelection = + currentModelDriver === "claudeAgent" + ? withModelSelectionOption(currentModelSelection, "effort", effort) + : currentModelSelection; + void props.onUpdateModelSelection(updated); + return; + } + if (event.startsWith("options:fast-mode:")) { + const fastMode = event.endsWith(":on"); + const nextFast = fastMode || undefined; + if (currentModelDriver === "opencode") { + return; + } + const updated = withModelSelectionOption(currentModelSelection, "fastMode", nextFast); + void props.onUpdateModelSelection(updated); + return; + } + if (event.startsWith("options:context-window:")) { + const contextWindow = event.slice("options:context-window:".length); + const updated: ModelSelection = + currentModelDriver === "claudeAgent" + ? withModelSelectionOption(currentModelSelection, "contextWindow", contextWindow) + : currentModelSelection; + void props.onUpdateModelSelection(updated); + return; + } + if (event.startsWith("options:runtime:")) { + const runtimeMode = event.slice("options:runtime:".length) as RuntimeMode; + void props.onUpdateRuntimeMode(runtimeMode); + return; + } + if (event.startsWith("options:interaction:")) { + const interactionMode = event.slice("options:interaction:".length) as ProviderInteractionMode; + void props.onUpdateInteractionMode(interactionMode); + } + } + + return ( + + + {composerTrigger && composerMenuItems.length > 0 ? ( + + + + ) : null} + + + {/* Attachment strip — inside the card, above the text input */} + {isExpanded ? ( + 0 ? 10 : 0 }}> + + + ) : null} + + + + setIsFocused(true)} + onBlur={() => setIsFocused(false)} + textAlignVertical={isExpanded ? "top" : "center"} + style={ + isExpanded + ? { + minHeight: 80, + maxHeight: 160, + paddingHorizontal: 4, + paddingVertical: 4, + fontSize: 15, + lineHeight: 22, + color: foregroundColor, + fontFamily: "DMSans_400Regular", + } + : { + maxHeight: 36, + paddingVertical: 6, + fontSize: 15, + lineHeight: 20, + color: foregroundColor, + fontFamily: "DMSans_400Regular", + } + } + /> + + + {!isExpanded && props.draftAttachments.length > 0 ? ( + + {props.draftAttachments.slice(0, 3).map((image) => ( + onPressImage(image.previewUri)}> + + + ))} + {props.draftAttachments.length > 3 ? ( + + + +{props.draftAttachments.length - 3} + + + ) : null} + + ) : null} + {!isExpanded ? ( + showStopAction ? ( + void props.onStopThread()} + /> + ) : ( + + ) + ) : null} + + + {/* Toolbar row — matches draft page layout (expanded only) */} + {isExpanded ? ( + + void props.onPickDraftImages()} /> + handleModelMenuAction(nativeEvent.event)} + themeVariant={isDarkMode ? "dark" : "light"} + > + } /> + + handleOptionsMenuAction(nativeEvent.event)} + themeVariant={isDarkMode ? "dark" : "light"} + > + + + void props.onRefresh()} /> + {showStopAction ? ( + void props.onStopThread()} + /> + ) : null} + + + ) : null} + + {/* Queue count */} + {props.queueCount > 0 ? ( + + {props.queueCount} queued message{props.queueCount === 1 ? "" : "s"} will send + automatically. + + ) : null} + + + + + ); +}); diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx new file mode 100644 index 00000000000..069bfecbe77 --- /dev/null +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -0,0 +1,354 @@ +import type { + ApprovalRequestId, + EnvironmentId, + ModelSelection, + OrchestrationThread, + ProviderApprovalDecision, + ProviderInteractionMode, + RuntimeMode, + ServerConfig as T3ServerConfig, + ThreadId, +} from "@t3tools/contracts"; +import { formatElapsed } from "@t3tools/shared/orchestrationTiming"; +import * as Haptics from "expo-haptics"; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { View, type LayoutChangeEvent } from "react-native"; +import { Gesture, GestureDetector } from "react-native-gesture-handler"; +import { KeyboardStickyView } from "react-native-keyboard-controller"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { runOnJS } from "react-native-reanimated"; + +import { AppText as Text } from "../../components/AppText"; +import type { StatusTone } from "../../components/StatusPill"; +import type { DraftComposerImageAttachment } from "../../lib/composerImages"; +import type { MobileLayoutVariant } from "../../lib/mobileLayout"; +import type { + PendingApproval, + PendingUserInput, + PendingUserInputDraftAnswer, + ThreadFeedEntry, +} from "../../lib/threadActivity"; +import { PendingApprovalCard } from "./PendingApprovalCard"; +import { PendingUserInputCard } from "./PendingUserInputCard"; +import { + COMPOSER_COLLAPSED_CHROME, + COMPOSER_EXPANDED_CHROME, + COMPOSER_EXPANDED_TOOLBAR_CHROME, + ThreadComposer, +} from "./ThreadComposer"; +import { ThreadFeed } from "./ThreadFeed"; + +export interface ThreadDetailScreenProps { + readonly selectedThread: OrchestrationThread; + readonly screenTone: StatusTone; + readonly connectionError: string | null; + readonly httpBaseUrl: string | null; + readonly bearerToken: string | null; + readonly selectedThreadFeed: ReadonlyArray; + readonly activeWorkStartedAt: string | null; + readonly activePendingApproval: PendingApproval | null; + readonly respondingApprovalId: ApprovalRequestId | null; + readonly activePendingUserInput: PendingUserInput | null; + readonly activePendingUserInputDrafts: Record; + readonly activePendingUserInputAnswers: Record | null; + readonly respondingUserInputId: ApprovalRequestId | null; + readonly draftMessage: string; + readonly draftAttachments: ReadonlyArray; + readonly connectionStateLabel: "ready" | "connecting" | "reconnecting" | "disconnected" | "idle"; + readonly activeThreadBusy: boolean; + readonly environmentId: EnvironmentId; + readonly projectWorkspaceRoot: string | null; + readonly selectedThreadQueueCount: number; + readonly serverConfig: T3ServerConfig | null; + readonly layoutVariant?: MobileLayoutVariant; + readonly onOpenDrawer: () => void; + readonly onOpenConnectionEditor: () => void; + readonly onChangeDraftMessage: (value: string) => void; + readonly onPickDraftImages: () => Promise; + readonly onNativePasteImages: (uris: ReadonlyArray) => Promise; + readonly onRemoveDraftImage: (imageId: string) => void; + readonly onRefresh: () => Promise; + readonly onStopThread: () => Promise; + readonly onSendMessage: () => void; + readonly onUpdateThreadModelSelection: (modelSelection: ModelSelection) => Promise; + readonly onUpdateThreadRuntimeMode: (runtimeMode: RuntimeMode) => Promise; + readonly onUpdateThreadInteractionMode: ( + interactionMode: ProviderInteractionMode, + ) => Promise; + readonly onRespondToApproval: ( + requestId: ApprovalRequestId, + decision: ProviderApprovalDecision, + ) => Promise; + readonly onSelectUserInputOption: ( + requestId: ApprovalRequestId, + questionId: string, + label: string, + ) => void; + readonly onChangeUserInputCustomAnswer: ( + requestId: ApprovalRequestId, + questionId: string, + customAnswer: string, + ) => void; + readonly onSubmitUserInput: () => Promise; + readonly showContent?: boolean; +} + +function latestStreamingAssistantMessage( + feed: ReadonlyArray, +): { readonly id: string; readonly textLength: number } | null { + for (let index = feed.length - 1; index >= 0; index -= 1) { + const entry = feed[index]; + if (entry?.type !== "message") { + continue; + } + if (entry.message.role !== "assistant" || !entry.message.streaming) { + continue; + } + return { + id: entry.message.id, + textLength: entry.message.text.length, + }; + } + + return null; +} + +function useStreamingHaptics(threadId: ThreadId, feed: ReadonlyArray) { + const lastStreamingAssistantRef = useRef<{ + readonly id: string; + readonly textLength: number; + } | null>(null); + const lastStreamHapticAtRef = useRef(0); + const hydratedRef = useRef(false); + const previousThreadIdRef = useRef(threadId); + + useEffect(() => { + if (previousThreadIdRef.current !== threadId) { + previousThreadIdRef.current = threadId; + hydratedRef.current = false; + } + + const latestStreamingMessage = latestStreamingAssistantMessage(feed); + + if (!hydratedRef.current) { + hydratedRef.current = true; + lastStreamingAssistantRef.current = latestStreamingMessage; + return; + } + + if (!latestStreamingMessage) { + lastStreamingAssistantRef.current = null; + return; + } + + const previousStreamingMessage = lastStreamingAssistantRef.current; + lastStreamingAssistantRef.current = latestStreamingMessage; + + const isNewStream = previousStreamingMessage?.id !== latestStreamingMessage.id; + const textGrew = + previousStreamingMessage?.id === latestStreamingMessage.id && + latestStreamingMessage.textLength > previousStreamingMessage.textLength; + + if (!isNewStream && !textGrew) { + return; + } + + const now = Date.now(); + if (!isNewStream && now - lastStreamHapticAtRef.current < 320) { + return; + } + + lastStreamHapticAtRef.current = now; + void Haptics.selectionAsync(); + }, [threadId, feed]); +} + +const WORKING_INDICATOR_HEIGHT = 44; + +const WorkingDurationPill = memo(function WorkingDurationPill(props: { + readonly startedAt: string; +}) { + const [nowMs, setNowMs] = useState(() => Date.now()); + + useEffect(() => { + const intervalId = setInterval(() => { + setNowMs(Date.now()); + }, 1_000); + return () => clearInterval(intervalId); + }, [props.startedAt]); + + const durationLabel = formatElapsed(props.startedAt, new Date(nowMs).toISOString()) ?? "0s"; + + return ( + + + + + + + + + + Working for {durationLabel} + + + + + ); +}); + +export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: ThreadDetailScreenProps) { + const { onOpenDrawer, onRefresh } = props; + + const insets = useSafeAreaInsets(); + const selectedProvider = props.serverConfig?.providers.find( + (provider) => provider.instanceId === props.selectedThread.modelSelection.instanceId, + ); + const agentName = + selectedProvider?.displayName ?? + selectedProvider?.driver ?? + props.selectedThread.modelSelection.instanceId; + const agentLabel = `${agentName} agent`; + const composerBottomInset = Math.max(insets.bottom, 12); + const [composerExpanded, setComposerExpanded] = useState(false); + const composerChrome = composerExpanded ? COMPOSER_EXPANDED_CHROME : COMPOSER_COLLAPSED_CHROME; + const composerOverlapHeight = composerChrome + composerBottomInset; + const activeWorkIndicatorHeight = props.activeWorkStartedAt ? WORKING_INDICATOR_HEIGHT : 0; + const estimatedOverlayHeight = composerOverlapHeight + activeWorkIndicatorHeight; + const [measuredOverlayHeight, setMeasuredOverlayHeight] = useState(0); + const [refreshing, setRefreshing] = useState(false); + const showContent = props.showContent ?? true; + const layoutVariant = props.layoutVariant ?? "compact"; + const isSplitLayout = layoutVariant === "split"; + useStreamingHaptics(props.selectedThread.id, props.selectedThreadFeed); + const expandedToolbarInset = composerExpanded ? COMPOSER_EXPANDED_TOOLBAR_CHROME : 0; + const feedBottomInset = + Math.max(estimatedOverlayHeight, measuredOverlayHeight) + expandedToolbarInset + 8; + + const completeDrawerGesture = useCallback(() => { + void Haptics.selectionAsync(); + onOpenDrawer(); + }, [onOpenDrawer]); + + const handleRefresh = useCallback(async (): Promise => { + if (refreshing) { + return; + } + + setRefreshing(true); + try { + await onRefresh(); + } finally { + setRefreshing(false); + } + }, [onRefresh, refreshing]); + + const drawerGestureThreshold = 80; + const headerDrawerGesture = useMemo( + () => + Gesture.Pan() + .enabled(!isSplitLayout) + .hitSlop({ left: 0, width: 40 }) + .activeOffsetX([10, 999]) + .failOffsetY([-24, 24]) + .onEnd((event) => { + const translationX = Math.max(event.translationX, 0); + if (event.y < drawerGestureThreshold && translationX > 56) { + runOnJS(completeDrawerGesture)(); + } + }), + [completeDrawerGesture, isSplitLayout], + ); + + const handleOverlayLayout = useCallback((event: LayoutChangeEvent) => { + const nextHeight = Math.ceil(event.nativeEvent.layout.height); + setMeasuredOverlayHeight((current) => + Math.abs(current - nextHeight) > 1 ? nextHeight : current, + ); + }, []); + + return ( + + + {showContent ? ( + + ) : ( + + )} + + {/* Floating composer — sticks to keyboard via KeyboardStickyView */} + {showContent ? ( + + + {props.activeWorkStartedAt ? ( + + ) : null} + + {props.activePendingApproval || props.activePendingUserInput ? ( + + {props.activePendingApproval ? ( + + ) : null} + {props.activePendingUserInput ? ( + + ) : null} + + ) : null} + + + + + ) : null} + + + ); +}); diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx new file mode 100644 index 00000000000..2974b17683d --- /dev/null +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -0,0 +1,989 @@ +import * as Clipboard from "expo-clipboard"; +import * as Haptics from "expo-haptics"; +import { KeyboardAvoidingLegendList } from "@legendapp/list/keyboard"; +import { type LegendListRef } from "@legendapp/list/react-native"; +import type { ThreadId } from "@t3tools/contracts"; +import { SymbolView } from "expo-symbols"; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + Markdown, + type CustomRenderers, + type NodeStyleOverrides, + type PartialMarkdownTheme, +} from "react-native-nitro-markdown"; +import { + Image, + Pressable, + ScrollView, + StyleSheet, + Text as NativeText, + type ColorValue, + useColorScheme, + useWindowDimensions, + View, +} from "react-native"; +import { TouchableOpacity } from "react-native-gesture-handler"; +import ImageViewing from "react-native-image-viewing"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { useThemeColor } from "../../lib/useThemeColor"; + +import { AppText as Text } from "../../components/AppText"; +import { EmptyState } from "../../components/EmptyState"; +import { + parseReviewCommentMessageSegments, + type ReviewInlineComment, +} from "../review/reviewCommentSelection"; +import { resolveNativeReviewDiffView } from "../diffs/nativeReviewDiffSurface"; +import { + buildNativeReviewDiffData, + createNativeReviewDiffTheme, + NATIVE_REVIEW_DIFF_CONTENT_WIDTH, + NATIVE_REVIEW_DIFF_ROW_HEIGHT, + NATIVE_REVIEW_DIFF_STYLE, +} from "../review/nativeReviewDiffAdapter"; +import { buildReviewParsedDiff } from "../review/reviewModel"; +import { cn } from "../../lib/cn"; +import type { MobileLayoutVariant } from "../../lib/mobileLayout"; +import type { ThreadFeedEntry } from "../../lib/threadActivity"; +import { relativeTime } from "../../lib/time"; +import { messageImageUrl } from "./threadPresentation"; + +export interface ThreadFeedProps { + readonly threadId: ThreadId; + readonly feed: ReadonlyArray; + readonly httpBaseUrl: string | null; + readonly bearerToken: string | null; + readonly agentLabel: string; + readonly contentBottomInset?: number; + readonly refreshing?: boolean; + readonly onRefresh?: () => void; + readonly layoutVariant?: MobileLayoutVariant; + readonly composerExpanded?: boolean; +} + +function stripShellWrapper(value: string): string { + const trimmed = value.trim(); + const match = trimmed.match(/^\/bin\/zsh -lc ['"]?([\s\S]*?)['"]?$/); + return (match?.[1] ?? trimmed).trim(); +} + +function compactActivityDetail(detail: string | null): string | null { + if (!detail) { + return null; + } + + const cleaned = stripShellWrapper(detail).replace(/\s+/g, " ").trim(); + return cleaned.length > 0 ? cleaned : null; +} + +function buildActivityRows( + activities: ReadonlyArray<{ + readonly id: string; + readonly createdAt: string; + readonly summary: string; + readonly detail: string | null; + readonly status: string | null; + }>, +) { + return activities.map<{ + id: string; + createdAt: string; + summary: string; + detail: string | null; + status: string | null; + }>((activity) => ({ + id: activity.id, + createdAt: activity.createdAt, + summary: activity.summary, + detail: compactActivityDetail(activity.detail), + status: activity.status, + })); +} + +const MAX_VISIBLE_WORK_LOG_ENTRIES = 6; + +function toMarkdownThemeColor(value: ColorValue): string { + return value as string; +} + +interface MarkdownStyleSets { + readonly user: MarkdownStyleSet; + readonly assistant: MarkdownStyleSet; +} + +interface MarkdownStyleSet { + readonly theme: PartialMarkdownTheme; + readonly styles: NodeStyleOverrides; + readonly renderers: CustomRenderers; +} + +interface ReviewCommentColors { + readonly background: ColorValue; + readonly border: ColorValue; + readonly mutedBackground: ColorValue; + readonly text: ColorValue; + readonly mutedText: ColorValue; + readonly codeBackground: ColorValue; +} + +function useReviewCommentColors(): ReviewCommentColors { + const colorScheme = useColorScheme(); + const isDark = colorScheme === "dark"; + const background = isDark ? "#151515" : "#ffffff"; + const border = isDark ? "#2a2a2a" : "#d7d7d7"; + const mutedBackground = isDark ? "#242424" : "#f2f2f2"; + const text = isDark ? "#f3f3f3" : "#111111"; + const mutedText = isDark ? "#8f8f8f" : "#666666"; + const codeBackground = isDark ? "#0f0f0f" : "#ffffff"; + + return useMemo( + () => ({ + background, + border, + mutedBackground, + text, + mutedText, + codeBackground, + }), + [background, border, codeBackground, mutedBackground, mutedText, text], + ); +} + +function useMarkdownStyles(): MarkdownStyleSets { + const bodyColor = useThemeColor("--color-md-body"); + const strongColor = useThemeColor("--color-md-strong"); + const linkColor = useThemeColor("--color-md-link"); + const blockquoteBg = useThemeColor("--color-md-blockquote-bg"); + const blockquoteBorder = useThemeColor("--color-md-blockquote-border"); + const codeBg = useThemeColor("--color-md-code-bg"); + const codeText = useThemeColor("--color-md-code-text"); + const hrColor = useThemeColor("--color-md-hr"); + const userBodyColor = useThemeColor("--color-user-bubble-foreground"); + const userCodeBg = useThemeColor("--color-md-user-code-bg"); + const userCodeText = useThemeColor("--color-md-user-code-text"); + const userFenceBg = useThemeColor("--color-md-user-fence-bg"); + const userFenceText = useThemeColor("--color-md-user-fence-text"); + + return useMemo(() => { + const markdownBodyColor = toMarkdownThemeColor(bodyColor); + const markdownStrongColor = toMarkdownThemeColor(strongColor); + const markdownLinkColor = toMarkdownThemeColor(linkColor); + const markdownBlockquoteBg = toMarkdownThemeColor(blockquoteBg); + const markdownBlockquoteBorder = toMarkdownThemeColor(blockquoteBorder); + const markdownCodeBg = toMarkdownThemeColor(codeBg); + const markdownCodeText = toMarkdownThemeColor(codeText); + const markdownHrColor = toMarkdownThemeColor(hrColor); + const markdownUserBodyColor = toMarkdownThemeColor(userBodyColor); + const markdownUserCodeBg = toMarkdownThemeColor(userCodeBg); + const markdownUserCodeText = toMarkdownThemeColor(userCodeText); + const markdownUserFenceBg = toMarkdownThemeColor(userFenceBg); + const markdownUserFenceText = toMarkdownThemeColor(userFenceText); + + const baseTheme: PartialMarkdownTheme = { + colors: { + text: markdownBodyColor, + heading: markdownStrongColor, + link: markdownLinkColor, + blockquote: markdownBlockquoteBorder, + border: markdownHrColor, + surfaceLight: markdownBlockquoteBg, + accent: markdownLinkColor, + tableBorder: markdownHrColor, + tableHeader: markdownBlockquoteBg, + tableHeaderText: markdownStrongColor, + tableRowOdd: "transparent", + tableRowEven: "transparent", + }, + spacing: { + xs: 4, + s: 4, + m: 8, + l: 8, + xl: 16, + }, + fontSizes: { + s: 13, + m: 15, + h1: 22, + h2: 19, + h3: 17, + h4: 15, + h5: 15, + h6: 15, + }, + fontFamilies: { + regular: "DMSans_400Regular", + heading: "DMSans_700Bold", + mono: "ui-monospace", + }, + headingWeight: "700", + borderRadius: { + s: 4, + m: 8, + l: 12, + }, + showCodeLanguage: false, + }; + + const baseStyles: NodeStyleOverrides = { + document: { flexShrink: 1 }, + paragraph: { marginTop: 0, marginBottom: 8 }, + list: { marginTop: 4, marginBottom: 4 }, + list_item: { marginTop: 0, marginBottom: 4 }, + task_list_item: { marginTop: 0, marginBottom: 4 }, + text: { lineHeight: 22 }, + bold: { + fontWeight: "700", + color: markdownStrongColor, + fontFamily: "DMSans_700Bold", + }, + italic: { fontStyle: "italic" }, + link: { + color: markdownLinkColor, + textDecorationLine: "underline" as const, + }, + blockquote: { + borderLeftWidth: 3, + borderLeftColor: markdownBlockquoteBorder, + backgroundColor: markdownBlockquoteBg, + paddingLeft: 12, + paddingVertical: 6, + marginLeft: 0, + marginVertical: 4, + borderRadius: 4, + }, + heading: { + fontFamily: "DMSans_700Bold", + color: markdownStrongColor, + marginTop: 12, + marginBottom: 6, + }, + horizontal_rule: { + backgroundColor: markdownHrColor, + height: 1, + marginVertical: 12, + }, + }; + + const createCodeRenderers = ( + inlineBackgroundColor: string, + inlineTextColor: string, + blockBackgroundColor: string, + blockTextColor: string, + ): CustomRenderers => ({ + code_inline: ({ content }) => ( + + {content} + + ), + code_block: ({ content }) => ( + + + + {content} + + + + ), + }); + + const userTheme: PartialMarkdownTheme = { + ...baseTheme, + colors: { + ...baseTheme.colors, + text: markdownUserBodyColor, + heading: markdownUserBodyColor, + link: markdownUserBodyColor, + code: markdownUserCodeText, + codeBackground: markdownUserCodeBg, + border: markdownUserFenceBg, + }, + }; + const userStyles: NodeStyleOverrides = { + ...baseStyles, + paragraph: { marginTop: 0, marginBottom: 0 }, + bold: { + fontWeight: "700", + color: markdownUserBodyColor, + fontFamily: "DMSans_700Bold", + }, + heading: { + ...baseStyles.heading, + color: markdownUserBodyColor, + }, + link: { + color: markdownUserBodyColor, + textDecorationLine: "underline" as const, + }, + }; + + const assistantTheme: PartialMarkdownTheme = { + ...baseTheme, + colors: { + ...baseTheme.colors, + code: markdownCodeText, + codeBackground: markdownCodeBg, + border: markdownCodeBg, + }, + }; + const assistantStyles: NodeStyleOverrides = { + ...baseStyles, + }; + + return { + user: { + theme: userTheme, + styles: userStyles, + renderers: createCodeRenderers( + markdownUserCodeBg, + markdownUserCodeText, + markdownUserFenceBg, + markdownUserFenceText, + ), + }, + assistant: { + theme: assistantTheme, + styles: assistantStyles, + renderers: createCodeRenderers( + markdownCodeBg, + markdownCodeText, + markdownCodeBg, + markdownCodeText, + ), + }, + }; + }, [ + blockquoteBg, + blockquoteBorder, + bodyColor, + codeBg, + codeText, + hrColor, + linkColor, + strongColor, + userBodyColor, + userCodeBg, + userCodeText, + userFenceBg, + userFenceText, + ]); +} + +function renderFeedEntry( + info: { item: ThreadFeedEntry; index: number }, + props: Pick & { + readonly copiedRowId: string | null; + readonly expandedWorkGroups: Record; + readonly onCopyWorkRow: (rowId: string, value: string) => void; + readonly onToggleWorkGroup: (groupId: string) => void; + readonly onPressImage: (uri: string, headers?: Record) => void; + readonly iconSubtleColor: string | import("react-native").ColorValue; + readonly userBubbleColor: string | import("react-native").ColorValue; + readonly markdownStyles: MarkdownStyleSets; + readonly reviewCommentColors: ReviewCommentColors; + readonly reviewCommentBubbleWidth: number; + }, +) { + const entry = info.item; + const { markdownStyles, iconSubtleColor, userBubbleColor } = props; + + if (entry.type === "message") { + const { message } = entry; + const isUser = message.role === "user"; + const styles = isUser ? markdownStyles.user : markdownStyles.assistant; + const timestampLabel = `${relativeTime(message.createdAt)}${message.streaming ? " • live" : ""}`; + const attachments = message.attachments ?? []; + const hasReviewCommentContext = message.text.includes(" + + {message.text.trim().length > 0 ? ( + + ) : null} + {attachments.map((attachment) => { + const uri = messageImageUrl(props.httpBaseUrl, attachment.id); + if (!uri) { + return null; + } + const headers = props.bearerToken + ? { Authorization: `Bearer ${props.bearerToken}` } + : undefined; + + return ( + props.onPressImage(uri, headers)} + > + + + ); + })} + + + {timestampLabel} + + + ); + } + + // Skip empty assistant messages (no text, no attachments) — they would + // render as an orphaned timestamp and break adjacent activity-group merging. + if (message.text.trim().length === 0 && attachments.length === 0) { + return null; + } + + return ( + + {message.text.trim().length > 0 ? ( + + {message.text} + + ) : null} + {attachments.map((attachment) => { + const uri = messageImageUrl(props.httpBaseUrl, attachment.id); + if (!uri) { + return null; + } + const headers = props.bearerToken + ? { Authorization: `Bearer ${props.bearerToken}` } + : undefined; + + return ( + props.onPressImage(uri, headers)} + > + + + ); + })} + + {timestampLabel} + + + ); + } + + if (entry.type === "queued-message") { + return ( + + + + {entry.queuedMessage.text} + + {entry.queuedMessage.attachments.length > 0 ? ( + + {entry.queuedMessage.attachments.length} image + {entry.queuedMessage.attachments.length === 1 ? "" : "s"} attached + + ) : null} + + + {entry.sending ? "dispatching" : `${relativeTime(entry.createdAt)} • pending`} + + + ); + } + + const rows = buildActivityRows(entry.activities); + const isExpanded = props.expandedWorkGroups[entry.id] ?? false; + const hasOverflow = rows.length > MAX_VISIBLE_WORK_LOG_ENTRIES; + const visibleRows = hasOverflow && !isExpanded ? rows.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES) : rows; + const hiddenCount = rows.length - visibleRows.length; + const showHeader = hasOverflow; + + return ( + + {showHeader ? ( + + + Tool calls ({rows.length}) + + props.onToggleWorkGroup(entry.id)}> + + {isExpanded ? "Show less" : `Show ${hiddenCount} more`} + + + + ) : null} + {visibleRows.map((row, index) => ( + 0 && "border-t border-neutral-200/80 dark:border-white/[0.06]", + )} + > + + + + + { + const copyValue = row.detail ?? row.summary; + props.onCopyWorkRow(row.id, copyValue); + }} + style={{ + fontFamily: "ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, monospace", + }} + > + {row.detail ? `${row.summary} - ${row.detail}` : row.summary} + + + {props.copiedRowId === row.id ? ( + + Copied + + ) : null} + + ))} + + ); +} + +function UserMessageContent(props: { + readonly text: string; + readonly markdownStyles: MarkdownStyleSet; + readonly reviewCommentColors: ReviewCommentColors; +}) { + const segments = parseReviewCommentMessageSegments(props.text); + const hasReviewComment = segments.some((segment) => segment.kind === "review-comment"); + if (!hasReviewComment) { + return ( + + {props.text} + + ); + } + + return ( + + {segments.map((segment) => { + if (segment.kind === "review-comment") { + return ( + + ); + } + + const text = segment.text.trim(); + if (text.length === 0) { + return null; + } + + return ( + + {text} + + ); + })} + + ); +} + +const ReviewCommentCard = memo(function ReviewCommentCard(props: { + readonly comment: ReviewInlineComment; + readonly colors: ReviewCommentColors; +}) { + const colorScheme = useColorScheme(); + const appearanceScheme = colorScheme === "light" ? "light" : "dark"; + const NativeReviewDiffView = resolveNativeReviewDiffView(); + const patch = useMemo(() => buildReviewCommentPatch(props.comment), [props.comment]); + const parsedDiff = useMemo( + () => buildReviewParsedDiff(patch, `thread-review-comment:${props.comment.id}`), + [patch, props.comment.id], + ); + const nativeReviewDiffData = useMemo(() => buildNativeReviewDiffData(parsedDiff), [parsedDiff]); + const compactNativeRows = useMemo( + () => nativeReviewDiffData.rows.filter((row) => row.kind !== "file"), + [nativeReviewDiffData.rows], + ); + const nativeReviewDiffTheme = useMemo( + () => createNativeReviewDiffTheme(appearanceScheme), + [appearanceScheme], + ); + const nativeRowsJson = useMemo(() => JSON.stringify(compactNativeRows), [compactNativeRows]); + const nativeThemeJson = useMemo( + () => JSON.stringify(nativeReviewDiffTheme), + [nativeReviewDiffTheme], + ); + const nativeStyleJson = useMemo(() => JSON.stringify(NATIVE_REVIEW_DIFF_STYLE), []); + const nativeDiffHeight = useMemo( + () => + Math.min( + 360, + Math.max( + 112, + compactNativeRows.length * NATIVE_REVIEW_DIFF_ROW_HEIGHT + + NATIVE_REVIEW_DIFF_STYLE.fileHeaderVerticalMargin, + ), + ), + [compactNativeRows.length], + ); + const shouldRenderNativeDiff = NativeReviewDiffView != null && compactNativeRows.length > 0; + + return ( + + + + + + + + {compactFileName(props.comment.filePath)} + + + + {shouldRenderNativeDiff ? ( + + + + ) : props.comment.diff.trim().length > 0 ? ( + + + {props.comment.diff.trim()} + + + ) : null} + {props.comment.text.length > 0 ? ( + + + {props.comment.text} + + + ) : null} + + ); +}); + +function buildReviewCommentPatch(comment: ReviewInlineComment): string { + const diff = comment.diff.trim(); + if (!diff) { + return ""; + } + + if (diff.startsWith("diff --git ")) { + return diff; + } + + const normalizedPath = comment.filePath.replaceAll("\\", "/"); + return [ + `diff --git a/${normalizedPath} b/${normalizedPath}`, + `--- a/${normalizedPath}`, + `+++ b/${normalizedPath}`, + diff, + ].join("\n"); +} + +function compactFileName(filePath: string): string { + const normalized = filePath.replaceAll("\\", "/"); + const lastSlashIndex = normalized.lastIndexOf("/"); + return lastSlashIndex >= 0 ? normalized.slice(lastSlashIndex + 1) : normalized; +} + +const IOS_NAV_BAR_HEIGHT = 44; + +export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { + const listRef = useRef(null); + const copyFeedbackTimeoutRef = useRef | null>(null); + const { width: viewportWidth } = useWindowDimensions(); + const [copiedRowId, setCopiedRowId] = useState(null); + const [expandedWorkGroups, setExpandedWorkGroups] = useState>({}); + const [expandedImage, setExpandedImage] = useState<{ + uri: string; + headers?: Record; + } | null>(null); + const horizontalPadding = props.layoutVariant === "split" ? 20 : 16; + const contentWidth = Math.max(0, viewportWidth - horizontalPadding * 2); + const reviewCommentBubbleWidth = Math.min(Math.max(280, contentWidth * 0.85), contentWidth); + const insets = useSafeAreaInsets(); + const topContentInset = insets.top + IOS_NAV_BAR_HEIGHT; + const bottomContentInset = props.contentBottomInset ?? 18; + + const iconSubtleColor = useThemeColor("--color-icon-subtle"); + const userBubbleColor = useThemeColor("--color-user-bubble"); + const markdownStyles = useMarkdownStyles(); + const reviewCommentColors = useReviewCommentColors(); + + useEffect(() => { + setCopiedRowId(null); + setExpandedWorkGroups({}); + }, [props.threadId]); + + useEffect(() => { + return () => { + if (copyFeedbackTimeoutRef.current) { + clearTimeout(copyFeedbackTimeoutRef.current); + } + }; + }, []); + + const onCopyWorkRow = useCallback((rowId: string, value: string) => { + void Clipboard.setStringAsync(value); + void Haptics.selectionAsync(); + setCopiedRowId(rowId); + if (copyFeedbackTimeoutRef.current) { + clearTimeout(copyFeedbackTimeoutRef.current); + } + copyFeedbackTimeoutRef.current = setTimeout(() => { + setCopiedRowId((current) => (current === rowId ? null : current)); + copyFeedbackTimeoutRef.current = null; + }, 1200); + }, []); + + const onToggleWorkGroup = useCallback((groupId: string) => { + setExpandedWorkGroups((current) => ({ + ...current, + [groupId]: !(current[groupId] ?? false), + })); + }, []); + + const onPressImage = useCallback((uri: string, headers?: Record) => { + setExpandedImage({ uri, headers }); + }, []); + + const renderItem = useCallback( + (info: { item: ThreadFeedEntry; index: number }) => + renderFeedEntry(info, { + bearerToken: props.bearerToken, + copiedRowId, + httpBaseUrl: props.httpBaseUrl, + expandedWorkGroups, + onCopyWorkRow, + onToggleWorkGroup, + onPressImage, + iconSubtleColor, + userBubbleColor, + markdownStyles, + reviewCommentColors, + reviewCommentBubbleWidth, + }), + [ + copiedRowId, + expandedWorkGroups, + iconSubtleColor, + userBubbleColor, + markdownStyles, + reviewCommentColors, + reviewCommentBubbleWidth, + onCopyWorkRow, + onPressImage, + onToggleWorkGroup, + props.bearerToken, + props.httpBaseUrl, + ], + ); + + if (props.feed.length === 0) { + return ( + + + + ); + } + + return ( + <> + `${entry.type}:${entry.id}`} + getItemType={(entry) => + entry.type === "message" ? `message:${entry.message.role}` : entry.type + } + keyboardShouldPersistTaps="handled" + estimatedItemSize={180} + initialScrollAtEnd + maintainScrollAtEnd={{ + on: { layout: true, itemLayout: true, dataChange: true }, + }} + maintainScrollAtEndThreshold={0.1} + refreshing={props.refreshing ?? false} + onRefresh={props.onRefresh} + safeAreaInsetBottom={insets.bottom} + contentContainerStyle={{ + paddingTop: 12, + paddingHorizontal: horizontalPadding, + }} + /> + + setExpandedImage(null)} + swipeToCloseEnabled + doubleTapToZoomEnabled + /> + + ); +}); diff --git a/apps/mobile/src/features/threads/ThreadGitControls.tsx b/apps/mobile/src/features/threads/ThreadGitControls.tsx new file mode 100644 index 00000000000..4f2daecf16e --- /dev/null +++ b/apps/mobile/src/features/threads/ThreadGitControls.tsx @@ -0,0 +1,277 @@ +import type { + EnvironmentId, + GitRunStackedActionResult, + ProjectScript, + ThreadId, + VcsStatusResult, +} from "@t3tools/contracts"; +import { + type GitActionRequestInput, + requiresDefaultBranchConfirmation, + resolveQuickAction, +} from "@t3tools/client-runtime"; +import { useLocalSearchParams, useRouter } from "expo-router"; +import Stack from "expo-router/stack"; +import { useCallback, useMemo } from "react"; +import { Alert, Linking } from "react-native"; +import { buildThreadReviewRoutePath } from "../../lib/routes"; +import { + basename, + getTerminalStatusLabel, + projectScriptMenuIcon, + projectScriptMenuLabel, + type TerminalMenuSession, +} from "../terminal/terminalMenu"; + +function truncateMiddle(value: string, maxLength: number): string { + if (value.length <= maxLength) { + return value; + } + + const headLength = Math.ceil((maxLength - 1) / 2); + const tailLength = Math.floor((maxLength - 1) / 2); + return `${value.slice(0, headLength)}…${value.slice(value.length - tailLength)}`; +} + +function compactMenuBranchLabel(branch: string): string { + return truncateMiddle(branch, 24); +} + +function compactMenuStatus(gitStatus: VcsStatusResult | null): string { + if (!gitStatus) { + return "Checking status"; + } + if (!gitStatus.isRepo) { + return "Not a repo"; + } + + const parts: string[] = []; + if (gitStatus.hasWorkingTreeChanges) { + parts.push(`${gitStatus.workingTree.files.length} changed`); + } else if (gitStatus.aheadCount === 0 && gitStatus.behindCount === 0) { + parts.push("Clean"); + } + if (gitStatus.aheadCount > 0) { + parts.push(`${gitStatus.aheadCount} ahead`); + } + if (gitStatus.behindCount > 0) { + parts.push(`${gitStatus.behindCount} behind`); + } + if (gitStatus.pr?.state === "open") { + parts.push(`PR #${gitStatus.pr.number}`); + } + + return parts.join(" · "); +} + +export function ThreadGitControls(props: { + readonly currentBranch: string | null; + readonly gitStatus: VcsStatusResult | null; + readonly gitOperationLabel: string | null; + readonly canOpenTerminal: boolean; + readonly projectScripts: ReadonlyArray; + readonly terminalSessions: ReadonlyArray; + readonly onOpenTerminal: (terminalId?: string | null) => void; + readonly onOpenNewTerminal: () => void; + readonly onRunProjectScript: (script: ProjectScript) => Promise; + readonly onPull: () => Promise; + readonly onRunAction: (input: GitActionRequestInput) => Promise; +}) { + const router = useRouter(); + const { environmentId, threadId } = useLocalSearchParams<{ + environmentId: EnvironmentId; + threadId: ThreadId; + }>(); + const { gitStatus, gitOperationLabel, onPull, onRunAction } = props; + + const currentBranchLabel = gitStatus?.refName ?? props.currentBranch ?? "Detached HEAD"; + const busy = gitOperationLabel !== null; + const isRepo = gitStatus?.isRepo ?? true; + const hasOriginRemote = gitStatus?.hasPrimaryRemote ?? false; + const isDefaultBranch = gitStatus?.isDefaultRef ?? false; + + const quickAction = useMemo( + () => + isRepo + ? resolveQuickAction(gitStatus, busy, isDefaultBranch, hasOriginRemote) + : { + label: "Git unavailable", + disabled: true, + kind: "show_hint" as const, + hint: "This workspace is not a git repository.", + }, + [busy, gitStatus, hasOriginRemote, isDefaultBranch, isRepo], + ); + + const quickActionHint = quickAction.disabled + ? (quickAction.hint ?? "This action is unavailable.") + : null; + + const quickActionIcon = (() => { + if (quickAction.kind === "run_pull") return "arrow.down.circle"; + if (quickAction.kind === "open_pr") return "arrow.up.right.circle"; + if (quickAction.kind === "run_action") { + if (quickAction.action === "commit") return "checkmark.circle"; + if (quickAction.action === "push" || quickAction.action === "commit_push") + return "arrow.up.circle"; + } + return "arrow.up.right.circle"; + })(); + + const openExistingPr = useCallback(async () => { + const prUrl = gitStatus?.pr?.state === "open" ? gitStatus.pr.url : null; + if (!prUrl) { + Alert.alert("No open PR", "This branch does not have an open pull request."); + return; + } + try { + await Linking.openURL(prUrl); + } catch (error) { + Alert.alert( + "Unable to open PR", + error instanceof Error ? error.message : "An error occurred.", + ); + } + }, [gitStatus]); + + const runActionWithPrompt = useCallback( + async (input: GitActionRequestInput) => { + const confirmableAction = + input.action === "push" || + input.action === "create_pr" || + input.action === "commit_push" || + input.action === "commit_push_pr" + ? input.action + : null; + const branchName = gitStatus?.refName; + if ( + branchName && + confirmableAction && + !input.featureBranch && + requiresDefaultBranchConfirmation(input.action, isDefaultBranch) + ) { + router.push({ + pathname: "/threads/[environmentId]/[threadId]/git-confirm", + params: { + environmentId, + threadId, + confirmAction: confirmableAction, + branchName, + includesCommit: String( + input.action === "commit_push" || input.action === "commit_push_pr", + ), + }, + }); + return; + } + + await onRunAction(input); + }, + [environmentId, gitStatus, isDefaultBranch, onRunAction, router, threadId], + ); + + const runQuickAction = useCallback(async () => { + if (quickAction.kind === "open_pr") { + await openExistingPr(); + return; + } + if (quickAction.kind === "run_pull") { + await onPull(); + return; + } + if (quickAction.kind === "run_action" && quickAction.action) { + await runActionWithPrompt({ action: quickAction.action }); + } + }, [onPull, openExistingPr, quickAction, runActionWithPrompt]); + + return ( + + + {props.projectScripts.length > 0 ? ( + props.projectScripts.map((script) => ( + void props.onRunProjectScript(script)} + subtitle={script.command} + > + {projectScriptMenuLabel(script)} + + )) + ) : ( + {}} + subtitle="This project has no saved scripts yet" + > + No project scripts + + )} + {props.terminalSessions.map((session) => ( + props.onOpenTerminal(session.terminalId)} + subtitle={[ + getTerminalStatusLabel({ + status: session.status, + hasRunningSubprocess: session.hasRunningSubprocess, + }), + basename(session.cwd), + ] + .filter(Boolean) + .join(" · ")} + > + {session.displayLabel} + + ))} + + Open new terminal + + + + {}} + subtitle={compactMenuStatus(gitStatus)} + > + {compactMenuBranchLabel(currentBranchLabel)} + + void runQuickAction()} + subtitle={quickActionHint ?? undefined} + > + {quickAction.label} + + router.push(buildThreadReviewRoutePath({ environmentId, threadId }))} + subtitle="Turn diffs and worktree changes" + > + Review changes + + + router.push({ + pathname: "/threads/[environmentId]/[threadId]/git", + params: { environmentId, threadId }, + }) + } + subtitle="Commit, files, branches" + > + More + + + + ); +} diff --git a/apps/mobile/src/features/threads/ThreadNavigationDrawer.tsx b/apps/mobile/src/features/threads/ThreadNavigationDrawer.tsx new file mode 100644 index 00000000000..c6dbcb47a72 --- /dev/null +++ b/apps/mobile/src/features/threads/ThreadNavigationDrawer.tsx @@ -0,0 +1,254 @@ +import { SymbolView } from "expo-symbols"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Modal, Pressable, ScrollView, useWindowDimensions, View } from "react-native"; +import { Gesture, GestureDetector } from "react-native-gesture-handler"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import Animated, { + runOnJS, + useAnimatedStyle, + useSharedValue, + withTiming, +} from "react-native-reanimated"; +import { useThemeColor } from "../../lib/useThemeColor"; + +import { AppText as Text } from "../../components/AppText"; +import { StatusPill } from "../../components/StatusPill"; +import { groupProjectsByRepository } from "../../lib/repositoryGroups"; +import { scopedThreadKey } from "../../lib/scopedEntities"; +import { relativeTime } from "../../lib/time"; +import { threadStatusTone } from "./threadPresentation"; +import { + EnvironmentScopedProjectShell, + EnvironmentScopedThreadShell, +} from "@t3tools/client-runtime"; + +function compareThreadActivity( + left: EnvironmentScopedThreadShell, + right: EnvironmentScopedThreadShell, +): number { + return ( + new Date(right.updatedAt ?? right.createdAt).getTime() - + new Date(left.updatedAt ?? left.createdAt).getTime() || left.title.localeCompare(right.title) + ); +} + +export function ThreadNavigationDrawer(props: { + readonly visible: boolean; + readonly projects: ReadonlyArray; + readonly threads: ReadonlyArray; + readonly selectedThreadKey: string | null; + readonly onClose: () => void; + readonly onSelectThread: (thread: EnvironmentScopedThreadShell) => void; + readonly onStartNewTask: () => void; +}) { + const insets = useSafeAreaInsets(); + const { width } = useWindowDimensions(); + const drawerWidth = Math.min(width * 0.84, 360); + const [mounted, setMounted] = useState(props.visible); + const translateX = useSharedValue(-drawerWidth); + const overlayOpacity = useSharedValue(0); + + const backdropColor = useThemeColor("--color-backdrop"); + const drawerBg = useThemeColor("--color-drawer"); + const drawerShadow = useThemeColor("--color-drawer-shadow"); + const primaryForeground = useThemeColor("--color-primary-foreground"); + const borderSubtleColor = useThemeColor("--color-border-subtle"); + + const repositoryGroups = useMemo( + () => groupProjectsByRepository({ projects: props.projects, threads: props.threads }), + [props.projects, props.threads], + ); + const groupedThreads = useMemo( + () => + repositoryGroups.map((group) => ({ + key: group.key, + title: group.projects[0]?.project.title ?? group.title, + threads: group.projects + .flatMap((projectGroup) => projectGroup.threads) + .sort(compareThreadActivity), + })), + [repositoryGroups], + ); + + useEffect(() => { + if (props.visible) { + setMounted(true); + translateX.value = withTiming(0, { duration: 240 }); + overlayOpacity.value = withTiming(1, { duration: 220 }); + return; + } + + overlayOpacity.value = withTiming(0, { duration: 180 }); + translateX.value = withTiming(-drawerWidth, { duration: 220 }, (finished) => { + if (finished) { + runOnJS(setMounted)(false); + } + }); + }, [drawerWidth, overlayOpacity, props.visible, translateX]); + + const closeDrawer = useCallback(() => { + props.onClose(); + }, [props]); + + const panGesture = useMemo( + () => + Gesture.Pan() + .activeOffsetX([-12, 12]) + .failOffsetY([-24, 24]) + .onUpdate((event) => { + translateX.value = Math.min(0, event.translationX); + }) + .onEnd((event) => { + const shouldClose = event.translationX < -drawerWidth * 0.2 || event.velocityX < -500; + if (shouldClose) { + runOnJS(closeDrawer)(); + return; + } + + translateX.value = withTiming(0, { duration: 180 }); + }), + [closeDrawer, drawerWidth, translateX], + ); + + const drawerStyle = useAnimatedStyle(() => ({ + transform: [{ translateX: translateX.value }], + })); + + const backdropStyle = useAnimatedStyle(() => ({ + opacity: overlayOpacity.value, + })); + + if (!mounted) { + return null; + } + + return ( + + + + + + + + + Threads + { + props.onClose(); + props.onStartNewTask(); + }} + className="h-11 w-11 items-center justify-center rounded-full bg-primary" + > + + + + + + {groupedThreads.map((group) => ( + + + {group.title} + + + + {group.threads.length === 0 ? ( + + + No threads yet + + + ) : ( + group.threads.map((thread, index) => { + const threadKey = scopedThreadKey(thread.environmentId, thread.id); + const selected = props.selectedThreadKey === threadKey; + + return ( + { + props.onSelectThread(thread); + props.onClose(); + }} + style={{ + paddingHorizontal: 16, + paddingVertical: 15, + borderTopWidth: index === 0 ? 0 : 1, + borderTopColor: borderSubtleColor, + backgroundColor: selected ? undefined : "transparent", + }} + className={selected ? "bg-subtle" : undefined} + > + + + + {thread.title} + + + {relativeTime(thread.updatedAt ?? thread.createdAt)} + + + + + + ); + }) + )} + + + ))} + + + + + + ); +} diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx new file mode 100644 index 00000000000..cff4c33a333 --- /dev/null +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -0,0 +1,418 @@ +import { Stack, useLocalSearchParams, useRouter } from "expo-router"; +import { useCallback, useMemo, useState } from "react"; +import * as Arr from "effect/Array"; +import * as Option from "effect/Option"; +import { pipe } from "effect/Function"; +import { EnvironmentId, type ProjectScript } from "@t3tools/contracts"; +import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; +import { Pressable, ScrollView, Text as RNText, View, useColorScheme } from "react-native"; +import { useThemeColor } from "../../lib/useThemeColor"; +import { useVcsStatus, vcsStatusManager } from "../../state/use-vcs-status"; +import { dismissGitActionResult, useGitActionProgress } from "../../state/use-vcs-action-state"; + +import { EmptyState } from "../../components/EmptyState"; +import { LoadingScreen } from "../../components/LoadingScreen"; +import { buildThreadRoutePath, buildThreadTerminalNavigation } from "../../lib/routes"; +import { scopedThreadKey } from "../../lib/scopedEntities"; +import { connectionTone } from "../connection/connectionTone"; + +import { useRemoteCatalog } from "../../state/use-remote-catalog"; +import { + useRemoteConnectionStatus, + useRemoteEnvironmentState, +} from "../../state/use-remote-environment-registry"; +import { useKnownTerminalSessions } from "../../state/use-terminal-session"; +import { useSelectedThreadDetail } from "../../state/use-thread-detail"; +import { useThreadSelection } from "../../state/use-thread-selection"; +import { GitActionProgressOverlay } from "./GitActionProgressOverlay"; +import { + buildTerminalMenuSessions, + nextOpenTerminalId, + resolveProjectScriptTerminalId, +} from "../terminal/terminalMenu"; +import { + resolvePreferredThreadWorktreePath, + stagePendingTerminalLaunch, +} from "../terminal/terminalLaunchContext"; +import { terminalDebugLog } from "../terminal/terminalDebugLog"; +import { ThreadDetailScreen } from "./ThreadDetailScreen"; +import { ThreadGitControls } from "./ThreadGitControls"; +import { ThreadNavigationDrawer } from "./ThreadNavigationDrawer"; +import { useSelectedThreadCommands } from "../../state/use-selected-thread-commands"; +import { useSelectedThreadGitActions } from "../../state/use-selected-thread-git-actions"; +import { useSelectedThreadGitState } from "../../state/use-selected-thread-git-state"; +import { useSelectedThreadRequests } from "../../state/use-selected-thread-requests"; +import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree"; +import { useThreadComposerState } from "../../state/use-thread-composer-state"; + +function firstRouteParam(value: string | string[] | undefined): string | null { + if (Array.isArray(value)) { + return value[0] ?? null; + } + + return value ?? null; +} + +export function ThreadRouteScreen() { + const { isLoadingSavedConnection, environmentStateById, pendingConnectionError } = + useRemoteEnvironmentState(); + const { connectionState, connectionError: aggregateConnectionError } = + useRemoteConnectionStatus(); + const { projects, threads } = useRemoteCatalog(); + const { selectedThread, selectedThreadProject, selectedEnvironmentConnection } = + useThreadSelection(); + const selectedThreadDetail = useSelectedThreadDetail(); + const { selectedThreadCwd } = useSelectedThreadWorktree(); + const composer = useThreadComposerState(); + const gitState = useSelectedThreadGitState(); + const gitActions = useSelectedThreadGitActions(); + const requests = useSelectedThreadRequests(); + const commands = useSelectedThreadCommands({ + refreshSelectedThreadGitStatus: gitActions.refreshSelectedThreadGitStatus, + }); + const refreshSelectedThread = commands.onRefresh; + const router = useRouter(); + const params = useLocalSearchParams<{ + environmentId?: string | string[]; + threadId?: string | string[]; + }>(); + const [drawerVisible, setDrawerVisible] = useState(false); + const environmentIdRaw = firstRouteParam(params.environmentId); + const environmentId = environmentIdRaw ? EnvironmentId.make(environmentIdRaw) : null; + const threadId = firstRouteParam(params.threadId); + const routeEnvironmentRuntime = environmentId + ? (environmentStateById[environmentId] ?? null) + : null; + const routeConnectionState = routeEnvironmentRuntime?.connectionState ?? connectionState; + const routeConnectionError = + pendingConnectionError ?? routeEnvironmentRuntime?.connectionError ?? aggregateConnectionError; + + /* ─── Native header theming ──────────────────────────────────────── */ + const isDark = useColorScheme() === "dark"; + const iconColor = String(useThemeColor("--color-icon")); + const foregroundColor = String(useThemeColor("--color-foreground")); + const secondaryFg = isDark ? "#a3a3a3" : "#525252"; + + /* ─── Git status for native header trigger ───────────────────────── */ + const gitStatus = useVcsStatus({ + environmentId: selectedThread?.environmentId ?? null, + cwd: selectedThreadCwd, + }); + const knownTerminalSessions = useKnownTerminalSessions({ + environmentId: selectedThread?.environmentId ?? null, + threadId: selectedThread?.id ?? null, + }); + const terminalMenuSessions = useMemo( + () => + buildTerminalMenuSessions({ + knownSessions: knownTerminalSessions, + workspaceRoot: selectedThreadProject?.workspaceRoot ?? null, + }), + [knownTerminalSessions, selectedThreadProject?.workspaceRoot], + ); + const selectedThreadDetailWorktreePath = selectedThreadDetail?.worktreePath ?? null; + + /* ─── Git action progress (for overlay banner) ──────────────────── */ + const gitActionProgressTarget = useMemo( + () => ({ + environmentId: selectedThread?.environmentId ?? null, + cwd: selectedThreadCwd, + }), + [selectedThread?.environmentId, selectedThreadCwd], + ); + const gitActionProgress = useGitActionProgress(gitActionProgressTarget); + + const handleRefreshGitStatus = useCallback(async () => { + if (!selectedThread) return; + await vcsStatusManager.refresh({ + environmentId: selectedThread.environmentId, + cwd: selectedThreadCwd, + }); + }, [selectedThread, selectedThreadCwd]); + + /** Wraps thread refresh + git status refresh for pull-to-refresh */ + const handleRefreshAll = useCallback(async () => { + await refreshSelectedThread(); + await handleRefreshGitStatus(); + }, [handleRefreshGitStatus, refreshSelectedThread]); + + const handleOpenDrawer = useCallback(() => { + setDrawerVisible(true); + }, []); + + const handleOpenConnectionEditor = useCallback(() => { + void router.push("/connections"); + }, [router]); + + const handleOpenTerminal = useCallback( + (nextTerminalId?: string | null) => { + terminalDebugLog("terminal-menu:open-existing", { + terminalId: nextTerminalId ?? null, + hasThread: Boolean(selectedThread), + hasWorkspaceRoot: Boolean(selectedThreadProject?.workspaceRoot), + }); + + if (!selectedThread || !selectedThreadProject?.workspaceRoot) { + return; + } + + void router.push(buildThreadTerminalNavigation(selectedThread, nextTerminalId)); + }, + [router, selectedThread, selectedThreadProject?.workspaceRoot], + ); + + const handleOpenNewTerminal = useCallback(() => { + terminalDebugLog("terminal-menu:open-new", { + hasThread: Boolean(selectedThread), + hasWorkspaceRoot: Boolean(selectedThreadProject?.workspaceRoot), + listedTerminalIds: terminalMenuSessions.map((session) => session.terminalId), + }); + + if (!selectedThread || !selectedThreadProject?.workspaceRoot) { + return; + } + + const nextId = nextOpenTerminalId({ + listedTerminalIds: terminalMenuSessions.map((session) => session.terminalId), + }); + void router.push(buildThreadTerminalNavigation(selectedThread, nextId)); + }, [router, selectedThread, selectedThreadProject?.workspaceRoot, terminalMenuSessions]); + + const handleRunProjectScript = useCallback( + async (script: ProjectScript) => { + terminalDebugLog("project-script:press", { + scriptId: script.id, + command: script.command, + hasThread: Boolean(selectedThread), + hasWorkspaceRoot: Boolean(selectedThreadProject?.workspaceRoot), + }); + + if (!selectedThread || !selectedThreadProject?.workspaceRoot) { + terminalDebugLog("project-script:abort", { + scriptId: script.id, + reason: "no-thread-or-workspace", + }); + return; + } + + const targetTerminalId = resolveProjectScriptTerminalId({ + existingTerminalIds: terminalMenuSessions.map((session) => session.terminalId), + hasRunningTerminal: terminalMenuSessions.some( + (session) => session.status === "running" || session.status === "starting", + ), + }); + const preferredWorktreePath = resolvePreferredThreadWorktreePath({ + threadShellWorktreePath: selectedThread.worktreePath ?? null, + threadDetailWorktreePath: selectedThreadDetailWorktreePath, + }); + const cwd = projectScriptCwd({ + project: { cwd: selectedThreadProject.workspaceRoot }, + worktreePath: preferredWorktreePath, + }); + const env = projectScriptRuntimeEnv({ + project: { cwd: selectedThreadProject.workspaceRoot }, + worktreePath: preferredWorktreePath, + }); + stagePendingTerminalLaunch({ + target: { + environmentId: selectedThread.environmentId, + threadId: selectedThread.id, + terminalId: targetTerminalId, + }, + launch: { + cwd, + worktreePath: preferredWorktreePath, + env, + initialInput: `${script.command}\r`, + }, + }); + terminalDebugLog("project-script:staged", { + scriptId: script.id, + terminalId: targetTerminalId, + cwd, + worktreePath: preferredWorktreePath, + }); + + void router.push(buildThreadTerminalNavigation(selectedThread, targetTerminalId)); + }, + [ + router, + selectedThread, + selectedThreadDetailWorktreePath, + selectedThreadProject, + terminalMenuSessions, + ], + ); + + if (!environmentId || !threadId) { + return ; + } + + if (!selectedThread) { + const stillHydrating = + isLoadingSavedConnection || + routeConnectionState === "connecting" || + routeConnectionState === "reconnecting"; + + if (stillHydrating) { + return ; + } + + return ( + + + + ); + } + + if (!selectedThreadDetail) { + return ; + } + + const selectedThreadKey = scopedThreadKey(selectedThread.environmentId, selectedThread.id); + const serverConfig = + routeEnvironmentRuntime?.serverConfig ?? + pipe( + Object.values(environmentStateById), + Arr.map((runtime) => runtime.serverConfig), + Arr.findFirst((value) => value !== null), + Option.getOrNull, + ); + + const headerSubtitle = [ + selectedThreadProject?.title ?? null, + selectedEnvironmentConnection?.environmentLabel ?? null, + ] + .filter(Boolean) + .join(" · "); + + return ( + <> + ( + { + // TODO: trigger rename modal + }} + > + + {selectedThreadDetail.title} + + + {headerSubtitle} + + + ), + }} + /> + + + + + + + + + setDrawerVisible(false)} + onSelectThread={(thread) => { + router.replace(buildThreadRoutePath(thread)); + }} + onStartNewTask={() => router.push("/new")} + /> + + + ); +} diff --git a/apps/mobile/src/features/threads/claudeEffortOptions.ts b/apps/mobile/src/features/threads/claudeEffortOptions.ts new file mode 100644 index 00000000000..58a4032b0ba --- /dev/null +++ b/apps/mobile/src/features/threads/claudeEffortOptions.ts @@ -0,0 +1,10 @@ +export const CLAUDE_AGENT_EFFORT_OPTIONS = [ + "low", + "medium", + "high", + "xhigh", + "max", + "ultrathink", +] as const; + +export type ClaudeAgentEffort = (typeof CLAUDE_AGENT_EFFORT_OPTIONS)[number]; diff --git a/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx b/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx new file mode 100644 index 00000000000..10cf830bc01 --- /dev/null +++ b/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx @@ -0,0 +1,201 @@ +import { sanitizeFeatureBranchName } from "@t3tools/shared/git"; +import { useRouter } from "expo-router"; +import { 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, AppTextInput as TextInput } from "../../../components/AppText"; +import { useVcsStatus } from "../../../state/use-vcs-status"; +import { useThreadSelection } from "../../../state/use-thread-selection"; +import { useSelectedThreadGitActions } from "../../../state/use-selected-thread-git-actions"; +import { useSelectedThreadGitState } from "../../../state/use-selected-thread-git-state"; +import { useSelectedThreadWorktree } from "../../../state/use-selected-thread-worktree"; +import { SheetActionButton } from "./gitSheetComponents"; + +export function GitBranchesSheet() { + const router = useRouter(); + const insets = useSafeAreaInsets(); + const { selectedThread } = useThreadSelection(); + const { selectedThreadCwd, selectedThreadWorktreePath } = useSelectedThreadWorktree(); + const gitState = useSelectedThreadGitState(); + const gitActions = useSelectedThreadGitActions(); + + const borderColor = useThemeColor("--color-border"); + const inputBorderColor = useThemeColor("--color-input-border"); + const inputBg = useThemeColor("--color-input"); + const foregroundColor = useThemeColor("--color-foreground"); + const subtleStrongColor = useThemeColor("--color-subtle-strong"); + + const gitStatus = useVcsStatus({ + environmentId: selectedThread?.environmentId ?? null, + cwd: selectedThreadCwd, + }); + + const currentBranchLabel = gitStatus.data?.refName ?? selectedThread?.branch ?? "Detached HEAD"; + const currentWorktreePath = selectedThreadWorktreePath; + const availableBranches = gitState.selectedThreadBranches; + const branchesLoading = gitState.selectedThreadBranchesLoading; + const busy = gitState.gitOperationLabel !== null; + + const [newBranchName, setNewBranchName] = useState(""); + const [worktreeBaseBranch, setWorktreeBaseBranch] = useState( + currentBranchLabel === "Detached HEAD" ? "main" : currentBranchLabel, + ); + const [worktreeBranchName, setWorktreeBranchName] = useState(""); + + const disabledExistingBranches = new Set( + availableBranches + .filter( + (branch) => branch.worktreePath !== null && branch.worktreePath !== currentWorktreePath, + ) + .map((branch) => branch.name), + ); + + return ( + + + + New branch + + + { + const branch = sanitizeFeatureBranchName(newBranchName.trim()); + if (branch.length === 0) return; + void gitActions.onCreateSelectedThreadBranch(branch).then(() => { + setNewBranchName(""); + router.dismiss(); + }); + }} + /> + + + + + New worktree + + + + { + const baseBranch = worktreeBaseBranch.trim(); + const newBranch = worktreeBranchName.trim(); + if (baseBranch.length === 0 || newBranch.length === 0) return; + void gitActions.onCreateSelectedThreadWorktree({ baseBranch, newBranch }).then(() => { + setWorktreeBranchName(""); + router.dismiss(); + }); + }} + /> + + + + + Existing branches + + {branchesLoading ? ( + + Loading branches... + + ) : null} + {!branchesLoading && availableBranches.length === 0 ? ( + + No local branches found. + + ) : null} + {availableBranches.map((branch) => { + const disabled = disabledExistingBranches.has(branch.name); + const subtitle = branch.worktreePath + ? branch.worktreePath === currentWorktreePath + ? "Checked out in this thread" + : "Checked out in another worktree" + : branch.isDefault + ? "Default branch" + : "Local branch"; + + return ( + { + void gitActions.onCheckoutSelectedThreadBranch(branch.name).then(() => { + router.dismiss(); + }); + }} + > + + {branch.name} + {subtitle} + + ); + })} + + + ); +} diff --git a/apps/mobile/src/features/threads/git/GitCommitSheet.tsx b/apps/mobile/src/features/threads/git/GitCommitSheet.tsx new file mode 100644 index 00000000000..c3c75c59a01 --- /dev/null +++ b/apps/mobile/src/features/threads/git/GitCommitSheet.tsx @@ -0,0 +1,241 @@ +import { useRouter } from "expo-router"; +import { useCallback, useState } from "react"; +import { Pressable, ScrollView, View, useColorScheme } 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 { useVcsStatus } from "../../../state/use-vcs-status"; +import { useThreadSelection } from "../../../state/use-thread-selection"; +import { useSelectedThreadGitActions } from "../../../state/use-selected-thread-git-actions"; +import { useSelectedThreadGitState } from "../../../state/use-selected-thread-git-state"; +import { useSelectedThreadWorktree } from "../../../state/use-selected-thread-worktree"; +import { SheetActionButton } from "./gitSheetComponents"; + +export function GitCommitSheet() { + const router = useRouter(); + const insets = useSafeAreaInsets(); + const isDarkMode = useColorScheme() === "dark"; + const { selectedThread } = useThreadSelection(); + const { selectedThreadCwd } = useSelectedThreadWorktree(); + const gitState = useSelectedThreadGitState(); + const gitActions = useSelectedThreadGitActions(); + + const borderColor = useThemeColor("--color-border"); + const borderSubtleColor = useThemeColor("--color-border-subtle"); + const inputBorderColor = useThemeColor("--color-input-border"); + const inputBg = useThemeColor("--color-input"); + const foregroundColor = useThemeColor("--color-foreground"); + + const gitStatus = useVcsStatus({ + environmentId: selectedThread?.environmentId ?? null, + cwd: selectedThreadCwd, + }); + + const busy = gitState.gitOperationLabel !== null; + const isDefaultBranch = gitStatus.data?.isDefaultRef ?? false; + const allFiles = gitStatus.data?.workingTree?.files ?? []; + + const [dialogCommitMessage, setDialogCommitMessage] = useState(""); + const [excludedFiles, setExcludedFiles] = useState>(new Set()); + const [isEditingFiles, setIsEditingFiles] = useState(false); + + const selectedFiles = allFiles.filter((file) => !excludedFiles.has(file.path)); + const allSelected = excludedFiles.size === 0; + const noneSelected = selectedFiles.length === 0; + const selectedInsertions = selectedFiles.reduce((sum, file) => sum + file.insertions, 0); + const selectedDeletions = selectedFiles.reduce((sum, file) => sum + file.deletions, 0); + const selectedFilePreview = selectedFiles.slice(0, 3); + + const runCommitAction = useCallback( + async (featureBranch: boolean) => { + const commitMessage = dialogCommitMessage.trim(); + router.dismiss(); + await gitActions.onRunSelectedThreadGitAction({ + action: "commit", + featureBranch, + ...(commitMessage ? { commitMessage } : {}), + ...(!allSelected ? { filePaths: selectedFiles.map((file) => file.path) } : {}), + }); + }, + [allSelected, dialogCommitMessage, gitActions, router, selectedFiles], + ); + + return ( + + + + Branch + + {gitStatus.data?.refName ?? "(detached HEAD)"} + + + {isDefaultBranch ? ( + + Warning: this is the default branch. + + ) : null} + + + + + + Files + + {selectedFiles.length} selected · +{selectedInsertions} / -{selectedDeletions} + + + + {!allSelected && isEditingFiles ? ( + setExcludedFiles(new Set())} + > + Reset + + ) : null} + setIsEditingFiles((current) => !current)} + > + + {isEditingFiles ? "Done" : "Edit"} + + + + + + {allFiles.length === 0 ? ( + + No changed files are available to commit. + + ) : !isEditingFiles ? ( + + {selectedFilePreview.map((file) => ( + + + {file.path} + + + +{file.insertions} + + + -{file.deletions} + + + ))} + {selectedFiles.length > selectedFilePreview.length ? ( + + +{selectedFiles.length - selectedFilePreview.length} more files + + ) : null} + + ) : ( + + {allFiles.map((file) => { + const included = !excludedFiles.has(file.path); + return ( + { + setExcludedFiles((current) => { + const next = new Set(current); + if (next.has(file.path)) { + next.delete(file.path); + } else { + next.add(file.path); + } + return next; + }); + }} + > + + + + + {file.path} + + {!included ? ( + + Excluded from this commit + + ) : null} + + + + +{file.insertions} + + + -{file.deletions} + + + + + ); + })} + + )} + + + + Commit message + + + + + + void runCommitAction(true)} + /> + + + void runCommitAction(false)} + /> + + + + ); +} diff --git a/apps/mobile/src/features/threads/git/GitConfirmSheet.tsx b/apps/mobile/src/features/threads/git/GitConfirmSheet.tsx new file mode 100644 index 00000000000..6e792f60910 --- /dev/null +++ b/apps/mobile/src/features/threads/git/GitConfirmSheet.tsx @@ -0,0 +1,120 @@ +import { resolveDefaultBranchActionDialogCopy } from "@t3tools/client-runtime"; +import { resolveAutoFeatureBranchName } from "@t3tools/shared/git"; +import { useLocalSearchParams, useRouter } from "expo-router"; +import { useCallback, useMemo } from "react"; +import { View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { AppText as Text } from "../../../components/AppText"; +import { useSelectedThreadGitActions } from "../../../state/use-selected-thread-git-actions"; +import { useSelectedThreadGitState } from "../../../state/use-selected-thread-git-state"; +import { SheetActionButton } from "./gitSheetComponents"; + +export function GitConfirmSheet() { + const router = useRouter(); + const insets = useSafeAreaInsets(); + const gitState = useSelectedThreadGitState(); + const gitActions = useSelectedThreadGitActions(); + + const params = useLocalSearchParams<{ + confirmAction?: string; + branchName?: string; + includesCommit?: string; + commitMessage?: string; + filePaths?: string; + }>(); + + const confirmAction = params.confirmAction as + | "push" + | "create_pr" + | "commit_push" + | "commit_push_pr" + | undefined; + const branchName = params.branchName ?? ""; + const includesCommit = params.includesCommit === "true"; + + const copy = useMemo( + () => + confirmAction + ? resolveDefaultBranchActionDialogCopy({ + action: confirmAction, + branchName, + includesCommit, + }) + : null, + [branchName, confirmAction, includesCommit], + ); + + const continuePendingAction = useCallback(async () => { + if (!confirmAction) return; + router.dismissAll(); + await gitActions.onRunSelectedThreadGitAction({ + action: confirmAction, + ...(params.commitMessage ? { commitMessage: params.commitMessage } : {}), + ...(params.filePaths ? { filePaths: params.filePaths.split(",") } : {}), + }); + }, [confirmAction, gitActions, params, router]); + + const movePendingActionToFeatureBranch = useCallback(async () => { + if (!confirmAction) return; + router.dismissAll(); + + if (includesCommit) { + await gitActions.onRunSelectedThreadGitAction({ + action: confirmAction, + featureBranch: true, + ...(params.commitMessage ? { commitMessage: params.commitMessage } : {}), + ...(params.filePaths ? { filePaths: params.filePaths.split(",") } : {}), + }); + return; + } + + const branches = + gitState.selectedThreadBranches.length > 0 + ? gitState.selectedThreadBranches + : await gitActions.refreshSelectedThreadBranches(); + const newBranchName = resolveAutoFeatureBranchName( + branches.filter((branch) => !branch.isRemote).map((branch) => branch.name), + ); + await gitActions.onCreateSelectedThreadBranch(newBranchName); + await gitActions.onRunSelectedThreadGitAction({ action: confirmAction }); + }, [confirmAction, gitActions, gitState.selectedThreadBranches, includesCommit, params, router]); + + return ( + + + + + + Confirm + + + {copy?.title ?? "Run action on default branch?"} + + + {copy?.description ?? "Choose how to continue."} + + + + + void continuePendingAction()} + /> + void movePendingActionToFeatureBranch()} + /> + + + ); +} diff --git a/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx b/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx new file mode 100644 index 00000000000..a153d16fbdb --- /dev/null +++ b/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx @@ -0,0 +1,248 @@ +import { + type GitActionRequestInput, + buildMenuItems, + getGitActionDisabledReason, + requiresDefaultBranchConfirmation, +} from "@t3tools/client-runtime"; +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { useLocalSearchParams, useRouter } from "expo-router"; +import { SymbolView } from "expo-symbols"; +import { useCallback, useEffect, useMemo } from "react"; +import { Alert, Linking, 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 { buildThreadReviewRoutePath } from "../../../lib/routes"; +import { useVcsStatus } from "../../../state/use-vcs-status"; +import { useThreadSelection } from "../../../state/use-thread-selection"; +import { useSelectedThreadGitActions } from "../../../state/use-selected-thread-git-actions"; +import { useSelectedThreadGitState } from "../../../state/use-selected-thread-git-state"; +import { useSelectedThreadWorktree } from "../../../state/use-selected-thread-worktree"; +import { MetaCard, SheetListRow, menuItemIconName, statusSummary } from "./gitSheetComponents"; + +export function GitOverviewSheet() { + const router = useRouter(); + const insets = useSafeAreaInsets(); + const { environmentId, threadId } = useLocalSearchParams<{ + environmentId: EnvironmentId; + threadId: ThreadId; + }>(); + const { selectedThread } = useThreadSelection(); + const { selectedThreadCwd, selectedThreadWorktreePath } = useSelectedThreadWorktree(); + const gitState = useSelectedThreadGitState(); + const gitActions = useSelectedThreadGitActions(); + + const iconColor = useThemeColor("--color-icon"); + const borderColor = useThemeColor("--color-border"); + + const gitStatus = useVcsStatus({ + environmentId: selectedThread?.environmentId ?? null, + cwd: selectedThreadCwd, + }); + + const currentBranchLabel = gitStatus.data?.refName ?? selectedThread?.branch ?? "Detached HEAD"; + const currentWorktreePath = selectedThreadWorktreePath; + const gitOperationLabel = gitState.gitOperationLabel; + const busy = gitOperationLabel !== null; + const isRepo = gitStatus.data?.isRepo ?? true; + const hasPrimaryRemote = gitStatus.data?.hasPrimaryRemote ?? false; + const isDefaultBranch = gitStatus.data?.isDefaultRef ?? false; + + const menuItems = useMemo( + () => (isRepo ? buildMenuItems(gitStatus.data, busy, hasPrimaryRemote) : []), + [busy, gitStatus.data, hasPrimaryRemote, isRepo], + ); + + const sheetMenuItems = useMemo( + () => + menuItems.map((item) => ({ + item, + disabledReason: getGitActionDisabledReason({ + item, + gitStatus: gitStatus.data, + isBusy: busy, + hasPrimaryRemote, + }), + })), + [busy, gitStatus.data, hasPrimaryRemote, menuItems], + ); + + useEffect(() => { + void gitActions.refreshSelectedThreadGitStatus({ quiet: true }); + }, [gitActions]); + + const openExistingPr = useCallback(async () => { + const prUrl = gitStatus.data?.pr?.state === "open" ? gitStatus.data.pr.url : null; + if (!prUrl) { + Alert.alert("No open PR", "This branch does not have an open pull request."); + return; + } + try { + await Linking.openURL(prUrl); + } catch (error) { + Alert.alert( + "Unable to open PR", + error instanceof Error ? error.message : "An error occurred.", + ); + } + }, [gitStatus.data]); + + const runActionWithPrompt = useCallback( + async (input: GitActionRequestInput) => { + const confirmableAction = + input.action === "push" || + input.action === "create_pr" || + input.action === "commit_push" || + input.action === "commit_push_pr" + ? input.action + : null; + const branchName = gitStatus.data?.refName; + if ( + branchName && + confirmableAction && + !input.featureBranch && + requiresDefaultBranchConfirmation(input.action, isDefaultBranch) + ) { + router.push({ + pathname: "/threads/[environmentId]/[threadId]/git-confirm", + params: { + environmentId, + threadId, + confirmAction: confirmableAction, + branchName, + includesCommit: String( + input.action === "commit_push" || input.action === "commit_push_pr", + ), + }, + }); + return; + } + + router.dismiss(); + await gitActions.onRunSelectedThreadGitAction(input); + }, + [environmentId, gitActions, gitStatus.data, isDefaultBranch, router, threadId], + ); + + const onPressMenuItem = useCallback( + async (item: (typeof menuItems)[number]) => { + if (item.disabled) return; + if (item.kind === "open_pr") { + await openExistingPr(); + return; + } + if (item.dialogAction === "commit") { + router.push({ + pathname: "/threads/[environmentId]/[threadId]/git/commit", + params: { environmentId, threadId }, + }); + return; + } + if (item.dialogAction === "push") { + await runActionWithPrompt({ action: "push" }); + return; + } + if (item.dialogAction === "create_pr") { + await runActionWithPrompt({ action: "create_pr" }); + } + }, + [environmentId, openExistingPr, router, runActionWithPrompt, threadId], + ); + + return ( + + + + + void gitActions.refreshSelectedThreadGitStatus()} + > + + + + Branch + + {currentBranchLabel} + + {statusSummary(gitStatus.data)} + + + + + + {sheetMenuItems.map(({ item, disabledReason }, index) => ( + + {index > 0 ? ( + + ) : null} + void onPressMenuItem(item)} + /> + + ))} + {(gitStatus.data?.behindCount ?? 0) > 0 ? ( + <> + + void gitActions.onPullSelectedThreadBranch()} + /> + + ) : null} + + router.push(buildThreadReviewRoutePath({ environmentId, threadId }))} + /> + + + router.push({ + pathname: "/threads/[environmentId]/[threadId]/git/branches", + params: { environmentId, threadId }, + }) + } + /> + + + {currentWorktreePath ? : null} + + + ); +} diff --git a/apps/mobile/src/features/threads/git/gitSheetComponents.tsx b/apps/mobile/src/features/threads/git/gitSheetComponents.tsx new file mode 100644 index 00000000000..b13f6a3020c --- /dev/null +++ b/apps/mobile/src/features/threads/git/gitSheetComponents.tsx @@ -0,0 +1,161 @@ +import { SymbolView } from "expo-symbols"; +import type { ComponentProps } from "react"; +import { Pressable, View } from "react-native"; +import { useThemeColor } from "../../../lib/useThemeColor"; +import { AppText as Text } from "../../../components/AppText"; + +/* ─── Shared sheet components ──────────────────────────────────────── */ + +export function SheetActionButton(props: { + readonly icon: ComponentProps["name"]; + readonly label: string; + readonly disabled?: boolean; + readonly tone?: "primary" | "secondary" | "danger"; + readonly onPress: () => void; +}) { + const primaryBg = useThemeColor("--color-primary"); + const primaryFg = useThemeColor("--color-primary-foreground"); + const dangerBg = useThemeColor("--color-danger"); + const dangerBorder = useThemeColor("--color-danger-border"); + const dangerFg = useThemeColor("--color-danger-foreground"); + const secondaryBg = useThemeColor("--color-secondary"); + const secondaryBorder = useThemeColor("--color-secondary-border"); + const secondaryFg = useThemeColor("--color-secondary-foreground"); + + const tone = props.tone ?? "secondary"; + const colors = + tone === "primary" + ? { + backgroundColor: primaryBg, + borderColor: "transparent", + textColor: primaryFg, + } + : tone === "danger" + ? { + backgroundColor: dangerBg, + borderColor: dangerBorder, + textColor: dangerFg, + } + : { + backgroundColor: secondaryBg, + borderColor: secondaryBorder, + textColor: secondaryFg, + }; + + return ( + + + + {props.label} + + + ); +} + +export function MetaCard(props: { readonly label: string; readonly value: string }) { + return ( + + + {props.label} + + + {props.value} + + + ); +} + +export function SheetListRow(props: { + readonly icon: ComponentProps["name"]; + readonly title: string; + readonly subtitle?: string | null; + readonly disabled?: boolean; + readonly onPress: () => void; +}) { + const iconColor = useThemeColor("--color-icon"); + const iconSubtleColor = useThemeColor("--color-icon-subtle"); + + return ( + + + + + + {props.title} + {props.subtitle ? ( + {props.subtitle} + ) : null} + + + + ); +} + +/* ─── Shared utilities ──────────────────────────────────────────────── */ + +export function menuItemIconName( + icon: "commit" | "push" | "pr", +): ComponentProps["name"] { + if (icon === "commit") return "checkmark.circle"; + if (icon === "push") return "arrow.up.circle"; + return "arrow.up.right.circle"; +} + +export function statusSummary( + gitStatus: { + readonly isRepo?: boolean; + readonly hasWorkingTreeChanges?: boolean; + readonly workingTree?: { readonly files: readonly { readonly path: string }[] }; + readonly aheadCount?: number; + readonly behindCount?: number; + readonly pr?: { readonly state?: string; readonly number?: number } | null; + } | null, +): string { + if (!gitStatus) { + return "Loading branch status\u2026"; + } + + if (!gitStatus.isRepo) { + return "Not a git repository"; + } + + const parts: string[] = []; + if (gitStatus.hasWorkingTreeChanges) { + const fileCount = gitStatus.workingTree?.files.length ?? 0; + parts.push(`${fileCount} file${fileCount === 1 ? "" : "s"} changed`); + } else { + parts.push("Clean"); + } + if ((gitStatus.aheadCount ?? 0) > 0) { + parts.push(`${gitStatus.aheadCount} ahead`); + } + if ((gitStatus.behindCount ?? 0) > 0) { + parts.push(`${gitStatus.behindCount} behind`); + } + if (gitStatus.pr?.state === "open") { + parts.push(`PR #${gitStatus.pr.number} open`); + } + + return parts.join(" \u00b7 "); +} diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx new file mode 100644 index 00000000000..150fd7944dd --- /dev/null +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -0,0 +1,513 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import type { + EnvironmentId, + ModelSelection, + ProviderInteractionMode, + RuntimeMode, +} from "@t3tools/contracts"; +import { DEFAULT_PROVIDER_INTERACTION_MODE, DEFAULT_RUNTIME_MODE } from "@t3tools/contracts"; +import * as Arr from "effect/Array"; +import { pipe } from "effect/Function"; + +import type { DraftComposerImageAttachment } from "../../lib/composerImages"; +import type { ModelOption, ProviderGroup } from "../../lib/modelOptions"; +import { buildModelOptions, groupByProvider } from "../../lib/modelOptions"; +import { groupProjectsByRepository } from "../../lib/repositoryGroups"; +import { scopedProjectKey } from "../../lib/scopedEntities"; +import { vcsRefManager, useVcsRefs } from "../../state/use-vcs-refs"; +import { useRemoteCatalog } from "../../state/use-remote-catalog"; +import { + setPendingConnectionError, + useRemoteEnvironmentState, +} from "../../state/use-remote-environment-registry"; +import { EnvironmentScopedProjectShell, type VcsRef } from "@t3tools/client-runtime"; +import type { ClaudeAgentEffort } from "./claudeEffortOptions"; + +type WorkspaceMode = "local" | "worktree"; + +function normalizeSelectedWorktreePath( + project: EnvironmentScopedProjectShell, + branch: VcsRef, +): string | null { + if (!branch.worktreePath) { + return null; + } + + return branch.worktreePath === project.workspaceRoot ? null : branch.worktreePath; +} + +export function branchBadgeLabel(input: { + readonly branch: VcsRef; + readonly project: EnvironmentScopedProjectShell | null; +}): string | null { + if (input.branch.current) { + return "current"; + } + if (input.branch.worktreePath && input.branch.worktreePath !== input.project?.workspaceRoot) { + return "worktree"; + } + if (input.branch.isDefault) { + return "default"; + } + if (input.branch.isRemote) { + return "remote"; + } + return null; +} + +type NewTaskFlowContextValue = { + readonly logicalProjects: ReadonlyArray<{ + readonly key: string; + readonly project: EnvironmentScopedProjectShell; + }>; + readonly selectedEnvironmentId: EnvironmentId | null; + readonly selectedProjectKey: string | null; + readonly selectedModelKey: string | null; + readonly workspaceMode: WorkspaceMode; + readonly selectedBranchName: string | null; + readonly selectedWorktreePath: string | null; + readonly prompt: string; + readonly attachments: ReadonlyArray; + readonly submitting: boolean; + readonly branchQuery: string; + readonly branchesLoading: boolean; + readonly availableBranches: ReadonlyArray; + readonly runtimeMode: RuntimeMode; + readonly interactionMode: ProviderInteractionMode; + readonly effort: ClaudeAgentEffort; + readonly fastMode: boolean; + readonly contextWindow: string; + readonly expandedProvider: string | null; + readonly environments: ReadonlyArray<{ + readonly environmentId: EnvironmentId; + readonly environmentLabel: string; + }>; + readonly selectedProject: EnvironmentScopedProjectShell | null; + readonly modelOptions: ReadonlyArray; + readonly selectedModel: ModelSelection | null; + readonly selectedModelOption: ModelOption | null; + readonly providerGroups: ReadonlyArray; + readonly filteredBranches: ReadonlyArray; + readonly reset: () => void; + readonly setProject: (project: EnvironmentScopedProjectShell) => void; + readonly selectEnvironment: (environmentId: EnvironmentId) => void; + readonly setSelectedModelKey: (key: string | null) => void; + readonly setWorkspaceMode: (mode: WorkspaceMode) => void; + readonly selectBranch: (branch: VcsRef) => void; + readonly setPrompt: (value: string) => void; + readonly replaceAttachments: (attachments: ReadonlyArray) => void; + readonly appendAttachments: (attachments: ReadonlyArray) => void; + readonly removeAttachment: (imageId: string) => void; + readonly clearAttachments: () => void; + readonly setSubmitting: (value: boolean) => void; + readonly setBranchQuery: (value: string) => void; + readonly loadBranches: () => Promise; + readonly setRuntimeMode: (value: RuntimeMode) => void; + readonly setInteractionMode: (value: ProviderInteractionMode) => void; + readonly setEffort: (value: ClaudeAgentEffort) => void; + readonly setFastMode: (value: boolean) => void; + readonly setContextWindow: (value: string) => void; + readonly setExpandedProvider: (value: string | null) => void; +}; + +const NewTaskFlowContext = React.createContext(null); + +export function NewTaskFlowProvider(props: React.PropsWithChildren) { + const { projects, serverConfigByEnvironmentId, threads } = useRemoteCatalog(); + const { savedConnectionsById } = useRemoteEnvironmentState(); + + const repositoryGroups = useMemo( + () => groupProjectsByRepository({ projects, threads }), + [projects, threads], + ); + const logicalProjects = useMemo( + () => + pipe( + repositoryGroups, + Arr.map((group) => { + const primaryProject = group.projects[0]?.project; + if (!primaryProject) { + return null; + } + return { key: group.key, project: primaryProject }; + }), + Arr.filter( + ( + entry, + ): entry is { + readonly key: string; + readonly project: EnvironmentScopedProjectShell; + } => entry !== null, + ), + ), + [repositoryGroups], + ); + + const [selectedEnvironmentId, setSelectedEnvironmentId] = useState( + projects[0]?.environmentId ?? null, + ); + const [selectedProjectKey, setSelectedProjectKey] = useState(null); + const [selectedModelKey, setSelectedModelKey] = useState(null); + const [workspaceMode, setWorkspaceMode] = useState("local"); + const [selectedBranchName, setSelectedBranchName] = useState(null); + const [selectedWorktreePath, setSelectedWorktreePath] = useState(null); + const branchLoadVersionRef = useRef(0); + const [prompt, setPrompt] = useState(""); + const [attachments, setAttachments] = useState>([]); + const [submitting, setSubmitting] = useState(false); + const [branchQuery, setBranchQuery] = useState(""); + const [runtimeMode, setRuntimeMode] = useState(DEFAULT_RUNTIME_MODE); + const [interactionMode, setInteractionMode] = useState( + DEFAULT_PROVIDER_INTERACTION_MODE, + ); + const [effort, setEffort] = useState("high"); + const [fastMode, setFastMode] = useState(false); + const [contextWindow, setContextWindow] = useState("1M"); + const [expandedProvider, setExpandedProvider] = useState(null); + + const replaceAttachments = useCallback( + (nextAttachments: ReadonlyArray) => { + setAttachments(nextAttachments); + }, + [], + ); + + const appendAttachments = useCallback( + (nextAttachments: ReadonlyArray) => { + setAttachments((current) => [...current, ...nextAttachments]); + }, + [], + ); + + const removeAttachment = useCallback((imageId: string) => { + setAttachments((current) => current.filter((candidate) => candidate.id !== imageId)); + }, []); + + const clearAttachments = useCallback(() => { + setAttachments([]); + }, []); + + const reset = useCallback(() => { + console.log("[new task flow] reset", { + defaultEnvironmentId: projects[0]?.environmentId ?? null, + projectCount: projects.length, + }); + setSelectedEnvironmentId(projects[0]?.environmentId ?? null); + setSelectedProjectKey(null); + setSelectedModelKey(null); + setWorkspaceMode("local"); + setSelectedBranchName(null); + setSelectedWorktreePath(null); + setPrompt(""); + clearAttachments(); + setSubmitting(false); + setBranchQuery(""); + setRuntimeMode(DEFAULT_RUNTIME_MODE); + setInteractionMode(DEFAULT_PROVIDER_INTERACTION_MODE); + setEffort("high"); + setFastMode(false); + setContextWindow("1M"); + setExpandedProvider(null); + }, [clearAttachments, projects]); + + useEffect(() => { + if (selectedEnvironmentId !== null || projects.length === 0) { + return; + } + + console.log("[new task flow] initializing environment", { + environmentId: projects[0]!.environmentId, + }); + setSelectedEnvironmentId(projects[0]!.environmentId); + }, [projects, selectedEnvironmentId]); + + const environments = useMemo( + () => + pipe( + [ + ...new Set( + pipe( + projects, + Arr.map((project) => project.environmentId), + ), + ), + ], + Arr.map((environmentId) => { + const environment = savedConnectionsById[environmentId]; + if (!environment) { + return null; + } + + return { + environmentId, + environmentLabel: environment.environmentLabel, + }; + }), + Arr.filter( + ( + entry, + ): entry is { + readonly environmentId: EnvironmentId; + readonly environmentLabel: string; + } => entry !== null, + ), + ), + [projects, savedConnectionsById], + ); + + const projectsForEnvironment = useMemo( + () => + pipe( + projects, + Arr.filter((project) => project.environmentId === selectedEnvironmentId), + ), + [projects, selectedEnvironmentId], + ); + + const selectedProject = + projectsForEnvironment.find( + (project) => scopedProjectKey(project.environmentId, project.id) === selectedProjectKey, + ) ?? + projectsForEnvironment[0] ?? + null; + + const modelOptions = useMemo( + () => + buildModelOptions( + selectedProject + ? (serverConfigByEnvironmentId[selectedProject.environmentId] ?? null) + : null, + selectedProject?.defaultModelSelection ?? null, + ), + [selectedProject, serverConfigByEnvironmentId], + ); + + const selectedModel = + modelOptions.find((option) => option.key === selectedModelKey)?.selection ?? + selectedProject?.defaultModelSelection ?? + modelOptions[0]?.selection ?? + null; + + const selectedModelOption = + modelOptions.find( + (option) => + selectedModel && + option.selection.instanceId === selectedModel.instanceId && + option.selection.model === selectedModel.model, + ) ?? null; + + const providerGroups = useMemo(() => groupByProvider(modelOptions), [modelOptions]); + const branchTarget = useMemo( + () => ({ + environmentId: selectedProject?.environmentId ?? null, + cwd: selectedProject?.workspaceRoot ?? null, + query: null, + }), + [selectedProject?.environmentId, selectedProject?.workspaceRoot], + ); + const branchState = useVcsRefs(branchTarget); + const branchesLoading = branchState.isPending; + const availableBranches = useMemo( + () => + pipe( + branchState.data?.refs ?? [], + Arr.filter((branch) => !branch.isRemote), + ), + [branchState.data?.refs], + ); + + const filteredBranches = useMemo(() => { + const query = branchQuery.trim().toLowerCase(); + if (query.length === 0) { + return availableBranches; + } + + return pipe( + availableBranches, + Arr.filter((branch) => branch.name.toLowerCase().includes(query)), + ); + }, [availableBranches, branchQuery]); + + const setProject = useCallback((project: EnvironmentScopedProjectShell) => { + const nextProjectKey = scopedProjectKey(project.environmentId, project.id); + branchLoadVersionRef.current += 1; + setSelectedEnvironmentId(project.environmentId); + setSelectedProjectKey(nextProjectKey); + setSelectedBranchName(null); + setSelectedWorktreePath(null); + }, []); + + const selectEnvironment = useCallback((environmentId: EnvironmentId) => { + branchLoadVersionRef.current += 1; + setSelectedEnvironmentId(environmentId); + setSelectedProjectKey(null); + setSelectedBranchName(null); + setSelectedWorktreePath(null); + }, []); + + const selectBranch = useCallback( + (branch: VcsRef) => { + setSelectedBranchName(branch.name); + setSelectedWorktreePath( + selectedProject ? normalizeSelectedWorktreePath(selectedProject, branch) : null, + ); + }, + [selectedProject], + ); + + const loadBranches = useCallback(async () => { + if (!selectedProject) { + return; + } + + const loadVersion = ++branchLoadVersionRef.current; + const projectKey = scopedProjectKey(selectedProject.environmentId, selectedProject.id); + try { + const result = await vcsRefManager.load({ + environmentId: selectedProject.environmentId, + cwd: selectedProject.workspaceRoot, + query: null, + }); + if (loadVersion !== branchLoadVersionRef.current || selectedProjectKey !== projectKey) { + return; + } + setPendingConnectionError(null); + const branches = pipe( + result?.refs ?? [], + Arr.filter((branch) => !branch.isRemote), + ); + + if (workspaceMode === "worktree" && !selectedBranchName) { + const preferredBranch = + branches.find((branch) => branch.current)?.name ?? + branches.find((branch) => branch.isDefault)?.name ?? + null; + if (preferredBranch) { + setSelectedBranchName(preferredBranch); + } + } + } catch { + if (loadVersion !== branchLoadVersionRef.current) { + return; + } + setPendingConnectionError("Failed to load branches."); + } + }, [selectedBranchName, selectedProject, selectedProjectKey, workspaceMode]); + + const value = useMemo( + () => ({ + logicalProjects, + selectedEnvironmentId, + selectedProjectKey, + selectedModelKey, + workspaceMode, + selectedBranchName, + selectedWorktreePath, + prompt, + attachments, + submitting, + branchQuery, + branchesLoading, + availableBranches, + runtimeMode, + interactionMode, + effort, + fastMode, + contextWindow, + expandedProvider, + environments, + selectedProject, + modelOptions, + selectedModel, + selectedModelOption, + providerGroups, + filteredBranches, + reset, + setProject, + selectEnvironment, + setSelectedModelKey, + setWorkspaceMode, + selectBranch, + setPrompt, + replaceAttachments, + appendAttachments, + removeAttachment, + clearAttachments, + setSubmitting, + setBranchQuery, + loadBranches, + setRuntimeMode, + setInteractionMode, + setEffort, + setFastMode, + setContextWindow, + setExpandedProvider, + }), + [ + attachments, + availableBranches, + branchQuery, + branchesLoading, + contextWindow, + effort, + environments, + expandedProvider, + fastMode, + filteredBranches, + interactionMode, + loadBranches, + logicalProjects, + modelOptions, + prompt, + providerGroups, + replaceAttachments, + reset, + runtimeMode, + selectedBranchName, + selectedEnvironmentId, + selectedModel, + selectedModelKey, + selectedModelOption, + selectedProject, + selectedProjectKey, + selectedWorktreePath, + setProject, + selectBranch, + selectEnvironment, + submitting, + workspaceMode, + appendAttachments, + clearAttachments, + removeAttachment, + ], + ); + + useEffect(() => { + console.log("[new task flow] state", { + availableBranchCount: availableBranches.length, + environmentCount: environments.length, + logicalProjectCount: logicalProjects.length, + selectedEnvironmentId, + selectedProjectKey, + selectedProjectTitle: selectedProject?.title ?? null, + }); + }, [ + availableBranches.length, + environments.length, + logicalProjects.length, + selectedEnvironmentId, + selectedProject?.title, + selectedProjectKey, + ]); + + return {props.children}; +} + +export function useNewTaskFlow() { + const value = React.use(NewTaskFlowContext); + if (value === null) { + throw new Error("useNewTaskFlow must be used within NewTaskFlowProvider."); + } + return value; +} diff --git a/apps/mobile/src/features/threads/threadPresentation.ts b/apps/mobile/src/features/threads/threadPresentation.ts new file mode 100644 index 00000000000..4253cedbc7e --- /dev/null +++ b/apps/mobile/src/features/threads/threadPresentation.ts @@ -0,0 +1,53 @@ +import type { StatusTone } from "../../components/StatusPill"; +import { EnvironmentScopedThreadShell } from "@t3tools/client-runtime"; + +export function threadSortValue(thread: EnvironmentScopedThreadShell): number { + const candidate = Date.parse(thread.updatedAt ?? thread.createdAt); + return Number.isNaN(candidate) ? 0 : candidate; +} + +export function threadStatusTone(thread: EnvironmentScopedThreadShell): StatusTone { + const status = thread.session?.status; + if (status === "running") { + return { + label: "Running", + pillClassName: "bg-orange-500/12 dark:bg-orange-500/16", + textClassName: "text-orange-700 dark:text-orange-300", + }; + } + if (status === "ready") { + return { + label: "Ready", + pillClassName: "bg-emerald-500/12 dark:bg-emerald-500/16", + textClassName: "text-emerald-700 dark:text-emerald-300", + }; + } + if (status === "starting") { + return { + label: "Starting", + pillClassName: "bg-sky-500/12 dark:bg-sky-500/16", + textClassName: "text-sky-700 dark:text-sky-300", + }; + } + if (status === "error") { + return { + label: "Error", + pillClassName: "bg-rose-500/12 dark:bg-rose-500/16", + textClassName: "text-rose-700 dark:text-rose-300", + }; + } + return { + label: "Idle", + pillClassName: "bg-neutral-500/10 dark:bg-neutral-500/16", + textClassName: "text-neutral-600 dark:text-neutral-300", + }; +} + +export function messageImageUrl(httpBaseUrl: string | null, attachmentId: string): string | null { + if (!httpBaseUrl) { + return null; + } + + const url = new URL(`/attachments/${encodeURIComponent(attachmentId)}`, httpBaseUrl); + return url.toString(); +} diff --git a/apps/mobile/src/features/threads/use-project-actions.ts b/apps/mobile/src/features/threads/use-project-actions.ts new file mode 100644 index 00000000000..58a0afbb2be --- /dev/null +++ b/apps/mobile/src/features/threads/use-project-actions.ts @@ -0,0 +1,260 @@ +import { useCallback } from "react"; + +import { EnvironmentScopedProjectShell, type VcsRef } from "@t3tools/client-runtime"; +import { + CommandId, + DEFAULT_PROVIDER_INTERACTION_MODE, + DEFAULT_RUNTIME_MODE, + type EnvironmentId, + MessageId, + ThreadId, + type ModelSelection, + type ProviderInteractionMode, + type RuntimeMode, +} from "@t3tools/contracts"; +import { buildTemporaryWorktreeBranchName, sanitizeFeatureBranchName } from "@t3tools/shared/git"; + +import type { DraftComposerImageAttachment } from "../../lib/composerImages"; +import { uuidv4 } from "../../lib/uuid"; +import { getEnvironmentClient } from "../../state/environment-session-registry"; +import { environmentRuntimeManager } from "../../state/use-environment-runtime"; +import { vcsRefManager } from "../../state/use-vcs-refs"; +import { useRemoteCatalog } from "../../state/use-remote-catalog"; +import { + setPendingConnectionError, + useRemoteEnvironmentState, +} from "../../state/use-remote-environment-registry"; + +function useRefreshRemoteData() { + const { savedConnectionsById } = useRemoteEnvironmentState(); + + return useCallback( + async (environmentIds?: ReadonlyArray) => { + const targets = + environmentIds ?? + Object.values(savedConnectionsById).map((connection) => connection.environmentId); + + await Promise.all( + targets.map(async (environmentId) => { + const client = getEnvironmentClient(environmentId); + if (!client) { + return; + } + + try { + const serverConfig = await client.server.getConfig(); + environmentRuntimeManager.patch({ environmentId }, (current) => ({ + ...current, + serverConfig, + connectionError: null, + })); + } catch (error) { + environmentRuntimeManager.patch({ environmentId }, (current) => ({ + ...current, + connectionError: + error instanceof Error ? error.message : "Failed to refresh remote data.", + })); + } + }), + ); + }, + [savedConnectionsById], + ); +} + +function deriveThreadTitleFromPrompt(value: string): string { + const trimmed = value.trim(); + if (trimmed.length === 0) { + return "New thread"; + } + + const compact = trimmed.replace(/\s+/g, " "); + return compact.length <= 72 ? compact : `${compact.slice(0, 69).trimEnd()}...`; +} + +export function useProjectActions() { + const { threads } = useRemoteCatalog(); + const refreshRemoteData = useRefreshRemoteData(); + + const onCreateThreadWithOptions = useCallback( + async (input: { + readonly project: EnvironmentScopedProjectShell; + readonly modelSelection: ModelSelection; + readonly envMode: "local" | "worktree"; + readonly branch: string | null; + readonly worktreePath: string | null; + readonly runtimeMode: RuntimeMode; + readonly interactionMode: ProviderInteractionMode; + readonly initialMessageText: string; + readonly initialAttachments: ReadonlyArray; + }) => { + const client = getEnvironmentClient(input.project.environmentId); + if (!client) { + return null; + } + + const threadId = ThreadId.make(uuidv4()); + const createdAt = new Date().toISOString(); + const initialMessageText = input.initialMessageText.trim(); + const nextTitle = deriveThreadTitleFromPrompt(input.initialMessageText); + + if (initialMessageText.length === 0) { + return null; + } + if (input.envMode === "worktree" && !input.branch) { + return null; + } + + const isWorktree = input.envMode === "worktree"; + + await client.orchestration.dispatchCommand({ + type: "thread.turn.start", + commandId: CommandId.make(uuidv4()), + threadId, + message: { + messageId: MessageId.make(uuidv4()), + role: "user", + text: initialMessageText, + attachments: input.initialAttachments, + }, + modelSelection: input.modelSelection, + titleSeed: nextTitle, + runtimeMode: input.runtimeMode, + interactionMode: input.interactionMode, + bootstrap: { + createThread: { + projectId: input.project.id, + title: nextTitle, + modelSelection: input.modelSelection, + runtimeMode: input.runtimeMode, + interactionMode: input.interactionMode, + branch: input.branch, + worktreePath: isWorktree ? null : input.worktreePath, + createdAt, + }, + ...(isWorktree + ? { + prepareWorktree: { + projectCwd: input.project.workspaceRoot, + baseBranch: input.branch!, + branch: buildTemporaryWorktreeBranchName(), + }, + runSetupScript: true, + } + : {}), + }, + createdAt: new Date().toISOString(), + }); + + await refreshRemoteData([input.project.environmentId]); + return { + environmentId: input.project.environmentId, + threadId, + }; + }, + [refreshRemoteData], + ); + + const onCreateThread = useCallback( + async (project: EnvironmentScopedProjectShell) => { + const latestProjectThread = + threads.find( + (thread) => + thread.environmentId === project.environmentId && thread.projectId === project.id, + ) ?? null; + const modelSelection = + project.defaultModelSelection ?? latestProjectThread?.modelSelection ?? null; + if (!modelSelection) { + setPendingConnectionError("This project does not have a default model configured yet."); + return null; + } + + return await onCreateThreadWithOptions({ + project, + modelSelection, + envMode: "local", + branch: null, + worktreePath: null, + runtimeMode: DEFAULT_RUNTIME_MODE, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + initialMessageText: "", + initialAttachments: [], + }); + }, + [onCreateThreadWithOptions, threads], + ); + + const onListProjectBranches = useCallback( + async (project: EnvironmentScopedProjectShell): Promise> => { + const client = getEnvironmentClient(project.environmentId); + if (!client) { + return []; + } + + try { + const result = await vcsRefManager.load( + { environmentId: project.environmentId, cwd: project.workspaceRoot, query: null }, + client.vcs, + { limit: 100 }, + ); + return (result?.refs ?? []).filter((branch) => !branch.isRemote); + } catch (error) { + setPendingConnectionError( + error instanceof Error ? error.message : "Failed to load branches.", + ); + return []; + } + }, + [], + ); + + const onCreateProjectWorktree = useCallback( + async ( + project: EnvironmentScopedProjectShell, + nextWorktree: { + readonly baseBranch: string; + readonly newBranch: string; + }, + ): Promise<{ + readonly branch: string; + readonly worktreePath: string; + } | null> => { + const client = getEnvironmentClient(project.environmentId); + if (!client) { + return null; + } + + try { + const result = await client.vcs.createWorktree({ + cwd: project.workspaceRoot, + refName: nextWorktree.baseBranch, + newRefName: sanitizeFeatureBranchName(nextWorktree.newBranch), + path: null, + }); + vcsRefManager.invalidate({ + environmentId: project.environmentId, + cwd: project.workspaceRoot, + query: null, + }); + return { + branch: result.worktree.refName, + worktreePath: result.worktree.path, + }; + } catch (error) { + setPendingConnectionError( + error instanceof Error ? error.message : "Failed to create worktree.", + ); + return null; + } + }, + [], + ); + + return { + onCreateThread, + onCreateThreadWithOptions, + onListProjectBranches, + onCreateProjectWorktree, + onRefreshProjects: refreshRemoteData, + }; +} diff --git a/apps/mobile/src/lib/cn.ts b/apps/mobile/src/lib/cn.ts new file mode 100644 index 00000000000..365058cebd7 --- /dev/null +++ b/apps/mobile/src/lib/cn.ts @@ -0,0 +1,6 @@ +import { type ClassValue, clsx } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/apps/mobile/src/lib/composerImages.ts b/apps/mobile/src/lib/composerImages.ts new file mode 100644 index 00000000000..871982442e6 --- /dev/null +++ b/apps/mobile/src/lib/composerImages.ts @@ -0,0 +1,249 @@ +import { + PROVIDER_SEND_TURN_MAX_ATTACHMENTS, + PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, + type UploadChatImageAttachment, +} from "@t3tools/contracts"; +import { uuidv4 } from "./uuid"; + +export interface DraftComposerImageAttachment extends UploadChatImageAttachment { + readonly id: string; + readonly previewUri: string; +} + +function estimateBase64ByteSize(base64: string): number { + const padding = base64.endsWith("==") ? 2 : base64.endsWith("=") ? 1 : 0; + return Math.floor((base64.length * 3) / 4) - padding; +} + +async function loadImagePicker() { + try { + return await import("expo-image-picker"); + } catch (error) { + throw new Error("Image attachments are unavailable right now.", { cause: error }); + } +} + +async function loadClipboard() { + try { + return await import("expo-clipboard"); + } catch (error) { + throw new Error("Clipboard paste is unavailable right now.", { cause: error }); + } +} + +export async function pickComposerImages(input: { readonly existingCount: number }): Promise<{ + readonly images: ReadonlyArray; + readonly error: string | null; +}> { + const remainingSlots = PROVIDER_SEND_TURN_MAX_ATTACHMENTS - input.existingCount; + if (remainingSlots <= 0) { + return { + images: [], + error: `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} images per message.`, + }; + } + + let imagePicker: Awaited>; + try { + imagePicker = await loadImagePicker(); + } catch (error) { + return { + images: [], + error: + error instanceof Error ? error.message : "Image attachments are unavailable right now.", + }; + } + + const permission = await imagePicker.requestMediaLibraryPermissionsAsync(); + if (!permission.granted) { + return { + images: [], + error: "Allow photo library access to attach images.", + }; + } + + const result = await imagePicker.launchImageLibraryAsync({ + mediaTypes: ["images"], + allowsMultipleSelection: true, + selectionLimit: remainingSlots, + base64: true, + quality: 1, + }); + + if (result.canceled) { + return { + images: [], + error: null, + }; + } + + const nextImages: DraftComposerImageAttachment[] = []; + let error: string | null = null; + + for (const asset of result.assets) { + const mimeType = asset.mimeType?.toLowerCase(); + if (!mimeType?.startsWith("image/")) { + error = `Unsupported file type for '${asset.fileName ?? "image"}'.`; + continue; + } + + const base64 = asset.base64; + if (!base64) { + error = `Failed to read '${asset.fileName ?? "image"}'.`; + continue; + } + + const sizeBytes = asset.fileSize ?? estimateBase64ByteSize(base64); + if (sizeBytes <= 0 || sizeBytes > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) { + error = `'${asset.fileName ?? "image"}' exceeds the 10 MB attachment limit.`; + continue; + } + + nextImages.push({ + id: uuidv4(), + type: "image", + name: asset.fileName ?? "image", + mimeType, + sizeBytes, + dataUrl: `data:${mimeType};base64,${base64}`, + previewUri: asset.uri, + }); + } + + return { + images: nextImages, + error, + }; +} + +export async function pasteComposerClipboard(input: { readonly existingCount: number }): Promise<{ + readonly images: ReadonlyArray; + readonly text: string | null; + readonly error: string | null; +}> { + let clipboard: Awaited>; + try { + clipboard = await loadClipboard(); + } catch (error) { + return { + images: [], + text: null, + error: error instanceof Error ? error.message : "Clipboard paste is unavailable right now.", + }; + } + + const remainingSlots = PROVIDER_SEND_TURN_MAX_ATTACHMENTS - input.existingCount; + + if (await clipboard.hasImageAsync()) { + if (remainingSlots <= 0) { + return { + images: [], + text: null, + error: `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} images per message.`, + }; + } + const image = await clipboard.getImageAsync({ format: "png" }); + if (!image) { + return { + images: [], + text: null, + error: "Clipboard image is unavailable.", + }; + } + + const base64 = image.data.split(",")[1] ?? ""; + const sizeBytes = estimateBase64ByteSize(base64); + if (sizeBytes <= 0 || sizeBytes > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) { + return { + images: [], + text: null, + error: "Clipboard image exceeds the 10 MB attachment limit.", + }; + } + + return { + images: [ + { + id: uuidv4(), + type: "image", + name: "pasted-image.png", + mimeType: "image/png", + sizeBytes, + dataUrl: image.data, + previewUri: image.data, + }, + ], + text: null, + error: null, + }; + } + + if (await clipboard.hasStringAsync()) { + const text = await clipboard.getStringAsync(); + return { + images: [], + text: text.length > 0 ? text : null, + error: text.length > 0 ? null : "Clipboard is empty.", + }; + } + + return { + images: [], + text: null, + error: "Clipboard does not contain pasteable text or image content.", + }; +} + +function mimeTypeFromUri(uri: string): string { + const ext = uri.split(".").pop()?.toLowerCase(); + switch (ext) { + case "png": + return "image/png"; + case "jpg": + case "jpeg": + return "image/jpeg"; + case "gif": + return "image/gif"; + case "webp": + return "image/webp"; + case "heic": + return "image/heic"; + default: + return "image/png"; + } +} + +export async function convertPastedImagesToAttachments(input: { + readonly uris: ReadonlyArray; + readonly existingCount: number; +}): Promise> { + const { File } = await import("expo-file-system"); + const remainingSlots = PROVIDER_SEND_TURN_MAX_ATTACHMENTS - input.existingCount; + const uris = input.uris.slice(0, Math.max(0, remainingSlots)); + const results: DraftComposerImageAttachment[] = []; + + for (const uri of uris) { + try { + const file = new File(uri); + const base64 = await file.base64(); + const sizeBytes = estimateBase64ByteSize(base64); + if (sizeBytes <= 0 || sizeBytes > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) { + continue; + } + const mimeType = mimeTypeFromUri(uri); + results.push({ + id: uuidv4(), + type: "image", + name: `pasted-image.${mimeType.split("/")[1] ?? "png"}`, + mimeType, + sizeBytes, + dataUrl: `data:${mimeType};base64,${base64}`, + previewUri: uri, + }); + } catch (error) { + console.warn("Failed to read pasted image", uri, error); + } + } + + return results; +} diff --git a/apps/mobile/src/lib/connection.ts b/apps/mobile/src/lib/connection.ts new file mode 100644 index 00000000000..3127c64aac4 --- /dev/null +++ b/apps/mobile/src/lib/connection.ts @@ -0,0 +1,54 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { + bootstrapRemoteBearerSession, + fetchRemoteEnvironmentDescriptor, + resolveRemotePairingTarget, +} from "@t3tools/shared/remote"; + +export interface RemoteConnectionInput { + readonly pairingUrl: string; +} + +export interface SavedRemoteConnection { + readonly environmentId: EnvironmentId; + readonly environmentLabel: string; + readonly pairingUrl: string; + readonly displayUrl: string; + readonly httpBaseUrl: string; + readonly wsBaseUrl: string; + readonly bearerToken: string; +} + +export type RemoteClientConnectionState = + | "idle" + | "connecting" + | "ready" + | "reconnecting" + | "disconnected"; + +export async function bootstrapRemoteConnection( + input: RemoteConnectionInput, +): Promise { + const target = resolveRemotePairingTarget({ + pairingUrl: input.pairingUrl, + }); + + const descriptor = await fetchRemoteEnvironmentDescriptor({ + httpBaseUrl: target.httpBaseUrl, + }); + + const bootstrap = await bootstrapRemoteBearerSession({ + httpBaseUrl: target.httpBaseUrl, + credential: target.credential, + }); + + return { + environmentId: descriptor.environmentId, + environmentLabel: descriptor.label, + pairingUrl: input.pairingUrl.trim(), + displayUrl: target.httpBaseUrl, + httpBaseUrl: target.httpBaseUrl, + wsBaseUrl: target.wsBaseUrl, + bearerToken: bootstrap.sessionToken, + }; +} diff --git a/apps/mobile/src/lib/mobileLayout.ts b/apps/mobile/src/lib/mobileLayout.ts new file mode 100644 index 00000000000..0ae284e463f --- /dev/null +++ b/apps/mobile/src/lib/mobileLayout.ts @@ -0,0 +1,37 @@ +function clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max); +} + +export type MobileLayoutVariant = "compact" | "split"; + +export interface MobileLayout { + readonly variant: MobileLayoutVariant; + readonly usesSplitView: boolean; + readonly listPaneWidth: number | null; + readonly shellPadding: number; +} + +export function deriveMobileLayout(input: { + readonly width: number; + readonly height: number; +}): MobileLayout { + const { width, height } = input; + const shortestEdge = Math.min(width, height); + const wideEnoughForSplit = width >= 900 || (width >= 700 && shortestEdge >= 700); + + if (!wideEnoughForSplit) { + return { + variant: "compact", + usesSplitView: false, + listPaneWidth: null, + shellPadding: 0, + }; + } + + return { + variant: "split", + usesSplitView: true, + listPaneWidth: clamp(Math.round(width * 0.34), 320, 420), + shellPadding: width >= 1180 ? 20 : 14, + }; +} diff --git a/apps/mobile/src/lib/modelOptions.ts b/apps/mobile/src/lib/modelOptions.ts new file mode 100644 index 00000000000..7ae301f6b35 --- /dev/null +++ b/apps/mobile/src/lib/modelOptions.ts @@ -0,0 +1,98 @@ +import type { ModelSelection, ServerConfig as T3ServerConfig } from "@t3tools/contracts"; + +export type ModelOption = { + readonly key: string; + readonly label: string; + readonly subtitle: string; + readonly providerKey: string; + readonly providerDriver: string; + readonly providerLabel: string; + readonly selection: ModelSelection; +}; + +export type ProviderGroup = { + readonly providerKey: string; + readonly providerDriver: string; + readonly providerLabel: string; + readonly models: ReadonlyArray; +}; + +function providerDisplayLabel(provider: string): string { + if (provider === "codex") return "Codex"; + if (provider === "claudeAgent") return "Claude"; + return provider; +} + +export function buildModelOptions( + config: T3ServerConfig | null | undefined, + fallbackModelSelection: ModelSelection | null, +): ReadonlyArray { + const options = new Map(); + + for (const provider of config?.providers ?? []) { + if (!provider.enabled || !provider.installed || provider.auth.status === "unauthenticated") { + continue; + } + + const providerLabel = provider.displayName ?? providerDisplayLabel(provider.driver); + for (const model of provider.models) { + const key = `${provider.instanceId}:${model.slug}`; + options.set(key, { + key, + label: model.name, + subtitle: providerLabel, + providerKey: provider.instanceId, + providerDriver: provider.driver, + providerLabel, + selection: { + instanceId: provider.instanceId, + model: model.slug, + }, + }); + } + } + + if (fallbackModelSelection) { + const key = `${fallbackModelSelection.instanceId}:${fallbackModelSelection.model}`; + if (!options.has(key)) { + const providerLabel = providerDisplayLabel(fallbackModelSelection.instanceId); + options.set(key, { + key, + label: fallbackModelSelection.model, + subtitle: providerLabel, + providerKey: fallbackModelSelection.instanceId, + providerDriver: fallbackModelSelection.instanceId, + providerLabel, + selection: fallbackModelSelection, + }); + } + } + + return [...options.values()]; +} + +export function groupByProvider(options: ReadonlyArray): ReadonlyArray { + const groups = new Map< + string, + { providerDriver: string; providerLabel: string; models: ModelOption[] } + >(); + for (const option of options) { + const existing = groups.get(option.providerKey); + if (existing) { + existing.models.push(option); + } else { + groups.set(option.providerKey, { + providerDriver: option.providerDriver, + providerLabel: option.providerLabel, + models: [option], + }); + } + } + + return [...groups.entries()].map(([providerKey, group]) => ({ + providerKey, + providerDriver: group.providerDriver, + providerLabel: group.providerLabel, + models: group.models, + })); +} diff --git a/apps/mobile/src/lib/repositoryGroups.test.ts b/apps/mobile/src/lib/repositoryGroups.test.ts new file mode 100644 index 00000000000..ddc9a5e8ac3 --- /dev/null +++ b/apps/mobile/src/lib/repositoryGroups.test.ts @@ -0,0 +1,197 @@ +import { describe, expect, it } from "vitest"; + +import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; + +import { groupProjectsByRepository } from "./repositoryGroups"; +import { + EnvironmentScopedProjectShell, + EnvironmentScopedThreadShell, +} from "@t3tools/client-runtime"; + +function makeProject( + input: Partial & + Pick, +): EnvironmentScopedProjectShell { + return { + workspaceRoot: `/workspaces/${input.id}`, + repositoryIdentity: null, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-04-01T00:00:00.000Z", + updatedAt: "2026-04-01T00:00:00.000Z", + ...input, + }; +} + +function makeThread( + input: Partial & + Pick< + EnvironmentScopedThreadShell, + "environmentId" | "id" | "projectId" | "title" | "modelSelection" + >, +): EnvironmentScopedThreadShell { + return { + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: "2026-04-01T00:00:00.000Z", + updatedAt: "2026-04-01T00:00:00.000Z", + archivedAt: null, + session: null, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + ...input, + }; +} + +describe("groupProjectsByRepository", () => { + it("groups projects across environments by repository identity", () => { + const repoIdentity = { + canonicalKey: "github.com/t3tools/t3code", + locator: { + source: "git-remote" as const, + remoteName: "origin", + remoteUrl: "git@github.com:t3tools/t3code.git", + }, + provider: "github", + owner: "t3tools", + name: "t3code", + displayName: "T3 Code", + }; + + const projects = [ + makeProject({ + environmentId: EnvironmentId.make("env-local"), + id: ProjectId.make("project-local"), + title: "T3 Code", + repositoryIdentity: repoIdentity, + }), + makeProject({ + environmentId: EnvironmentId.make("env-staging"), + id: ProjectId.make("project-staging"), + title: "T3 Code", + repositoryIdentity: repoIdentity, + }), + ]; + + const threads = [ + makeThread({ + environmentId: EnvironmentId.make("env-staging"), + id: ThreadId.make("thread-2"), + projectId: ProjectId.make("project-staging"), + title: "Fix reconnect flow", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + updatedAt: "2026-04-02T12:00:00.000Z", + }), + makeThread({ + environmentId: EnvironmentId.make("env-local"), + id: ThreadId.make("thread-1"), + projectId: ProjectId.make("project-local"), + title: "Polish mobile shell", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + updatedAt: "2026-04-03T12:00:00.000Z", + }), + ]; + + const groups = groupProjectsByRepository({ projects, threads }); + + expect(groups).toHaveLength(1); + expect(groups[0]).toMatchObject({ + key: "github.com/t3tools/t3code", + title: "T3 Code", + subtitle: "t3tools/t3code", + projectCount: 2, + threadCount: 2, + }); + expect( + groups[0]?.projects.map((entry) => ({ + environmentId: entry.project.environmentId, + latestActivityAt: entry.latestActivityAt, + threads: entry.threads.map((thread) => thread.id), + })), + ).toEqual([ + { + environmentId: "env-local", + latestActivityAt: "2026-04-03T12:00:00.000Z", + threads: ["thread-1"], + }, + { + environmentId: "env-staging", + latestActivityAt: "2026-04-02T12:00:00.000Z", + threads: ["thread-2"], + }, + ]); + expect(groups[0]?.latestActivityAt).toBe("2026-04-03T12:00:00.000Z"); + }); + + it("orders threads, projects, and repository groups by latest activity", () => { + const projects = [ + makeProject({ + environmentId: EnvironmentId.make("env-local"), + id: ProjectId.make("older-project"), + title: "Older", + }), + makeProject({ + environmentId: EnvironmentId.make("env-local"), + id: ProjectId.make("newer-project"), + title: "Newer", + }), + ]; + + const threads = [ + makeThread({ + environmentId: EnvironmentId.make("env-local"), + id: ThreadId.make("older-thread"), + projectId: ProjectId.make("older-project"), + title: "Older thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + updatedAt: "2026-04-02T12:00:00.000Z", + }), + makeThread({ + environmentId: EnvironmentId.make("env-local"), + id: ThreadId.make("newer-thread"), + projectId: ProjectId.make("older-project"), + title: "Newer thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + updatedAt: "2026-04-04T12:00:00.000Z", + }), + makeThread({ + environmentId: EnvironmentId.make("env-local"), + id: ThreadId.make("newest-thread"), + projectId: ProjectId.make("newer-project"), + title: "Newest thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + updatedAt: "2026-04-05T12:00:00.000Z", + }), + ]; + + const groups = groupProjectsByRepository({ projects, threads }); + + expect(groups.map((group) => group.title)).toEqual(["Newer", "Older"]); + expect(groups[1]?.projects[0]?.threads.map((thread) => thread.id)).toEqual([ + "newer-thread", + "older-thread", + ]); + }); + + it("falls back to a scoped project key when repository identity is unavailable", () => { + const projects = [ + makeProject({ + environmentId: EnvironmentId.make("env-local"), + id: ProjectId.make("project-local"), + title: "Scratchpad", + }), + ]; + + const groups = groupProjectsByRepository({ projects, threads: [] }); + + expect(groups).toHaveLength(1); + expect(groups[0]?.key).toBe("env-local:project-local"); + expect(groups[0]?.title).toBe("Scratchpad"); + expect(groups[0]?.subtitle).toBeNull(); + }); +}); diff --git a/apps/mobile/src/lib/repositoryGroups.ts b/apps/mobile/src/lib/repositoryGroups.ts new file mode 100644 index 00000000000..5238411a643 --- /dev/null +++ b/apps/mobile/src/lib/repositoryGroups.ts @@ -0,0 +1,134 @@ +import * as Order from "effect/Order"; +import * as Arr from "effect/Array"; +import type { RepositoryIdentity } from "@t3tools/contracts"; + +import { scopedProjectKey } from "./scopedEntities"; +import { + EnvironmentScopedProjectShell, + EnvironmentScopedThreadShell, +} from "@t3tools/client-runtime"; + +const DateDescending = Order.flip(Order.Date); + +export interface MobileRepositoryProjectGroup { + readonly key: string; + readonly project: EnvironmentScopedProjectShell; + readonly threads: ReadonlyArray; + readonly latestActivityAt: string; +} + +export interface MobileRepositoryGroup { + readonly key: string; + readonly title: string; + readonly subtitle: string | null; + readonly repositoryIdentity: RepositoryIdentity | null; + readonly projectCount: number; + readonly threadCount: number; + readonly latestActivityAt: string; + readonly projects: ReadonlyArray; +} + +function compareIsoDateDescending(left: string, right: string): number { + return new Date(right).getTime() - new Date(left).getTime(); +} + +function deriveRepositoryGroupKey(project: EnvironmentScopedProjectShell): string { + return ( + project.repositoryIdentity?.canonicalKey ?? scopedProjectKey(project.environmentId, project.id) + ); +} + +function deriveRepositoryTitle(project: EnvironmentScopedProjectShell): string { + const identity = project.repositoryIdentity; + return identity?.displayName ?? identity?.name ?? project.title; +} + +function deriveRepositorySubtitle(identity: RepositoryIdentity | null | undefined): string | null { + if (!identity) { + return null; + } + if (identity.owner && identity.name) { + return `${identity.owner}/${identity.name}`; + } + return identity.canonicalKey; +} + +function deriveProjectLatestActivity( + project: EnvironmentScopedProjectShell, + threads: ReadonlyArray, +): string { + const latestThread = threads[0]; + return latestThread?.updatedAt ?? latestThread?.createdAt ?? project.updatedAt; +} + +export function groupProjectsByRepository(input: { + readonly projects: ReadonlyArray; + readonly threads: ReadonlyArray; +}): ReadonlyArray { + const threadsByProjectKey = new Map(); + + for (const thread of input.threads) { + const key = scopedProjectKey(thread.environmentId, thread.projectId); + const existing = threadsByProjectKey.get(key); + if (existing) { + existing.push(thread); + } else { + threadsByProjectKey.set(key, [thread]); + } + } + + const grouped = new Map(); + + for (const project of input.projects) { + const key = deriveRepositoryGroupKey(project); + const projectKey = scopedProjectKey(project.environmentId, project.id); + const threads = Arr.sortWith( + threadsByProjectKey.get(projectKey) ?? [], + (s) => new Date(s.updatedAt ?? s.createdAt), + DateDescending, + ); + + const latestActivityAt = deriveProjectLatestActivity(project, threads); + const projectGroup: MobileRepositoryProjectGroup = { + key: projectKey, + project, + threads, + latestActivityAt, + }; + + const existing = grouped.get(key); + if (!existing) { + grouped.set(key, { + key, + title: deriveRepositoryTitle(project), + subtitle: deriveRepositorySubtitle(project.repositoryIdentity), + repositoryIdentity: project.repositoryIdentity ?? null, + projectCount: 1, + threadCount: threads.length, + latestActivityAt, + projects: [projectGroup], + }); + continue; + } + + grouped.set(key, { + ...existing, + title: existing.repositoryIdentity ? existing.title : deriveRepositoryTitle(project), + subtitle: existing.subtitle ?? deriveRepositorySubtitle(project.repositoryIdentity), + repositoryIdentity: existing.repositoryIdentity ?? project.repositoryIdentity ?? null, + projectCount: existing.projectCount + 1, + threadCount: existing.threadCount + threads.length, + latestActivityAt: + compareIsoDateDescending(existing.latestActivityAt, latestActivityAt) > 0 + ? latestActivityAt + : existing.latestActivityAt, + projects: Arr.sortWith( + [...existing.projects, projectGroup], + (s) => new Date(s.latestActivityAt), + DateDescending, + ), + }); + } + + return Arr.sortWith(grouped.values(), (s) => new Date(s.latestActivityAt), DateDescending); +} diff --git a/apps/mobile/src/lib/routes.ts b/apps/mobile/src/lib/routes.ts new file mode 100644 index 00000000000..056af7abdb0 --- /dev/null +++ b/apps/mobile/src/lib/routes.ts @@ -0,0 +1,79 @@ +import type { Href, Router } from "expo-router"; +import type { EnvironmentScopedThreadShell } from "@t3tools/client-runtime"; +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; + +import type { SelectedThreadRef } from "../state/remote-runtime-types"; + +type ThreadRouteInput = + | Pick + | Pick; +type PlainThreadRouteInput = + | { + environmentId: EnvironmentId; + threadId: ThreadId; + } + | { + environmentId: EnvironmentId; + id: ThreadId; + }; + +export function buildThreadRoutePath(input: ThreadRouteInput | PlainThreadRouteInput): string { + const environmentId = input.environmentId; + const threadId = "threadId" in input ? input.threadId : input.id; + + return `/threads/${encodeURIComponent(environmentId)}/${encodeURIComponent(threadId)}`; +} + +export function buildThreadReviewRoutePath( + input: ThreadRouteInput | PlainThreadRouteInput, +): string { + return `${buildThreadRoutePath(input)}/review`; +} + +export function buildThreadTerminalRoutePath( + input: ThreadRouteInput | PlainThreadRouteInput, + terminalId?: string | null, +): string { + const basePath = `${buildThreadRoutePath(input)}/terminal`; + if (!terminalId) { + return basePath; + } + + return `${basePath}?terminalId=${encodeURIComponent(terminalId)}`; +} + +/** + * Prefer this over {@link buildThreadTerminalRoutePath} with `router.push(string)` — Expo Router + * often does not merge query strings into `useLocalSearchParams`, which breaks terminal bootstrap + * (`requestedTerminalId` stays null while the UI assumes `default`). + */ +export function buildThreadTerminalNavigation( + input: ThreadRouteInput | PlainThreadRouteInput, + terminalId?: string | null, +): Href { + const environmentId = String(input.environmentId); + const threadId = String("threadId" in input ? input.threadId : input.id); + + const params: { environmentId: string; threadId: string; terminalId?: string } = { + environmentId, + threadId, + }; + + if (terminalId != null && terminalId !== "") { + params.terminalId = terminalId; + } + + return { + pathname: "/threads/[environmentId]/[threadId]/terminal", + params, + }; +} + +export function dismissRoute(router: Router) { + if (router.canGoBack()) { + router.back(); + return; + } + + router.replace("/"); +} diff --git a/apps/mobile/src/lib/scopedEntities.ts b/apps/mobile/src/lib/scopedEntities.ts new file mode 100644 index 00000000000..34709957fd4 --- /dev/null +++ b/apps/mobile/src/lib/scopedEntities.ts @@ -0,0 +1,16 @@ +import { ApprovalRequestId, EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; + +export function scopedProjectKey(environmentId: EnvironmentId, projectId: ProjectId): string { + return `${environmentId}:${projectId}`; +} + +export function scopedThreadKey(environmentId: EnvironmentId, threadId: ThreadId): string { + return `${environmentId}:${threadId}`; +} + +export function scopedRequestKey( + environmentId: EnvironmentId, + requestId: ApprovalRequestId, +): string { + return `${environmentId}:${requestId}`; +} diff --git a/apps/mobile/src/lib/storage.ts b/apps/mobile/src/lib/storage.ts new file mode 100644 index 00000000000..c13caa9a368 --- /dev/null +++ b/apps/mobile/src/lib/storage.ts @@ -0,0 +1,94 @@ +import * as Arr from "effect/Array"; +import { pipe } from "effect/Function"; +import * as SecureStore from "expo-secure-store"; +import type { EnvironmentId } from "@t3tools/contracts"; + +import type { SavedRemoteConnection } from "./connection"; + +const CONNECTIONS_KEY = "t3code.connections"; +const PREFERENCES_KEY = "t3code.preferences"; + +export interface MobilePreferences { + readonly terminalFontSize?: number; +} + +async function readStorageItem(key: string): Promise { + return await SecureStore.getItemAsync(key); +} + +async function writeStorageItem(key: string, value: string): Promise { + await SecureStore.setItemAsync(key, value); +} + +async function readJsonStorageItem(key: string): Promise { + const raw = (await readStorageItem(key)) ?? ""; + if (!raw.trim()) { + return null; + } + + try { + return JSON.parse(raw) as T; + } catch { + return null; + } +} + +export async function loadSavedConnections(): Promise> { + const parsed = await readJsonStorageItem<{ + readonly connections?: ReadonlyArray; + }>(CONNECTIONS_KEY); + if (!parsed) { + return []; + } + + return pipe( + parsed.connections ?? [], + Arr.filter((c) => !!c.environmentId && !!c.bearerToken?.trim()), + ); +} + +export async function saveConnection(connection: SavedRemoteConnection): Promise { + const current = await loadSavedConnections(); + const next = current.some((entry) => entry.environmentId === connection.environmentId) + ? pipe( + current, + Arr.map((entry) => (entry.environmentId === connection.environmentId ? connection : entry)), + ) + : pipe(current, Arr.append(connection)); + + await writeStorageItem(CONNECTIONS_KEY, JSON.stringify({ connections: next })); +} + +export async function clearSavedConnection(environmentId: EnvironmentId): Promise { + const current = await loadSavedConnections(); + const next = pipe( + current, + Arr.filter((entry) => entry.environmentId !== environmentId), + ); + await writeStorageItem(CONNECTIONS_KEY, JSON.stringify({ connections: next })); +} + +export async function loadPreferences(): Promise { + const parsed = await readJsonStorageItem(PREFERENCES_KEY); + if (!parsed || typeof parsed !== "object") { + return {}; + } + + if (typeof parsed.terminalFontSize === "number") { + return { terminalFontSize: parsed.terminalFontSize }; + } + + return {}; +} + +export async function savePreferencesPatch( + patch: Partial, +): Promise { + const current = await loadPreferences(); + const next: MobilePreferences = { + ...current, + ...patch, + }; + await writeStorageItem(PREFERENCES_KEY, JSON.stringify(next)); + return next; +} diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts new file mode 100644 index 00000000000..70f6736e85c --- /dev/null +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, it } from "vitest"; + +import { + EventId, + ProjectId, + ProviderInstanceId, + ThreadId, + TurnId, + type OrchestrationThread, + type OrchestrationThreadActivity, +} from "@t3tools/contracts"; + +import { buildThreadFeed } from "./threadActivity"; + +function makeActivity( + input: Partial & + Pick, +): OrchestrationThreadActivity { + return { + tone: "info", + payload: {}, + turnId: null, + ...input, + }; +} + +function makeThread( + input: Partial & Pick, +): OrchestrationThread { + return { + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: "2026-04-01T00:00:00.000Z", + updatedAt: "2026-04-01T00:00:00.000Z", + archivedAt: null, + deletedAt: null, + messages: [], + proposedPlans: [], + activities: [], + checkpoints: [], + session: null, + ...input, + }; +} + +describe("buildThreadFeed", () => { + it("includes runtime warnings from the latest turn", () => { + const thread = makeThread({ + id: ThreadId.make("thread-1"), + projectId: ProjectId.make("project-1"), + title: "Runtime warning thread", + latestTurn: { + turnId: TurnId.make("turn-latest"), + state: "running", + requestedAt: "2026-04-01T00:00:00.000Z", + startedAt: "2026-04-01T00:00:01.000Z", + completedAt: null, + assistantMessageId: null, + }, + activities: [ + makeActivity({ + id: EventId.make("activity-old"), + kind: "runtime.warning", + summary: "Runtime warning", + createdAt: "2026-04-01T00:00:02.000Z", + turnId: TurnId.make("turn-old"), + payload: { + message: "Old warning", + }, + }), + makeActivity({ + id: EventId.make("activity-latest"), + kind: "runtime.warning", + summary: "Runtime warning", + createdAt: "2026-04-01T00:00:03.000Z", + turnId: TurnId.make("turn-latest"), + payload: { + message: "Latest warning", + }, + }), + ], + }); + + const feed = buildThreadFeed(thread, [], null); + const group = feed[0]; + + expect(group).toMatchObject({ + type: "activity-group", + }); + if (!group || group.type !== "activity-group") { + return; + } + + expect(group.activities).toEqual([ + { + id: "activity-latest", + createdAt: "2026-04-01T00:00:03.000Z", + summary: "Runtime warning", + detail: null, + status: null, + }, + ]); + }); + + it("collapses matching tool lifecycle rows like desktop", () => { + const thread = makeThread({ + id: ThreadId.make("thread-2"), + projectId: ProjectId.make("project-1"), + title: "Collapsed tools", + latestTurn: { + turnId: TurnId.make("turn-1"), + state: "completed", + requestedAt: "2026-04-01T00:00:00.000Z", + startedAt: "2026-04-01T00:00:01.000Z", + completedAt: "2026-04-01T00:00:03.000Z", + assistantMessageId: null, + }, + activities: [ + makeActivity({ + id: EventId.make("tool-updated"), + kind: "tool.updated", + tone: "tool", + summary: "Run tests", + createdAt: "2026-04-01T00:00:01.000Z", + turnId: TurnId.make("turn-1"), + payload: { + title: "Run tests", + itemType: "command_execution", + detail: "/bin/zsh -lc 'bun run test'", + }, + }), + makeActivity({ + id: EventId.make("tool-completed"), + kind: "tool.completed", + tone: "tool", + summary: "Run tests completed", + createdAt: "2026-04-01T00:00:02.000Z", + turnId: TurnId.make("turn-1"), + payload: { + title: "Run tests", + itemType: "command_execution", + detail: "/bin/zsh -lc 'bun run test'", + }, + }), + ], + }); + + const feed = buildThreadFeed(thread, [], null); + const group = feed[0]; + + expect(group).toMatchObject({ + type: "activity-group", + }); + if (!group || group.type !== "activity-group") { + return; + } + + expect(group.activities).toEqual([ + { + id: "tool-completed", + createdAt: "2026-04-01T00:00:02.000Z", + summary: "Run tests", + detail: "bun run test", + status: null, + }, + ]); + }); +}); diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts new file mode 100644 index 00000000000..96a115aa127 --- /dev/null +++ b/apps/mobile/src/lib/threadActivity.ts @@ -0,0 +1,936 @@ +import { ApprovalRequestId, isToolLifecycleItemType } from "@t3tools/contracts"; +import type { + CommandId, + EnvironmentId, + MessageId, + OrchestrationThread, + OrchestrationThreadActivity, + TurnId, + ToolLifecycleItemType, + ThreadId, + UserInputQuestion, +} from "@t3tools/contracts"; + +import type { DraftComposerImageAttachment } from "./composerImages"; +import * as Arr from "effect/Array"; +import * as Order from "effect/Order"; + +export interface PendingApproval { + readonly requestId: ApprovalRequestId; + readonly requestKind: "command" | "file-read" | "file-change"; + readonly createdAt: string; + readonly detail?: string; +} + +export interface PendingUserInput { + readonly requestId: ApprovalRequestId; + readonly createdAt: string; + readonly questions: ReadonlyArray; +} + +export interface PendingUserInputDraftAnswer { + readonly selectedOptionLabel?: string; + readonly customAnswer?: string; +} + +export interface QueuedThreadMessage { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; + readonly messageId: MessageId; + readonly commandId: CommandId; + readonly text: string; + readonly attachments: ReadonlyArray; + readonly createdAt: string; +} + +export interface ThreadFeedActivity { + readonly id: string; + readonly createdAt: string; + readonly summary: string; + readonly detail: string | null; + readonly status: string | null; +} + +interface WorkLogEntry { + id: string; + createdAt: string; + label: string; + detail?: string; + command?: string; + rawCommand?: string; + changedFiles?: ReadonlyArray; + tone: "thinking" | "tool" | "info" | "error"; + toolTitle?: string; + itemType?: ToolLifecycleItemType; + requestKind?: PendingApproval["requestKind"]; +} + +interface DerivedWorkLogEntry extends WorkLogEntry { + activityKind: OrchestrationThreadActivity["kind"]; + collapseKey?: string; +} + +type RawThreadFeedEntry = + | { + readonly type: "message"; + readonly id: string; + readonly createdAt: string; + readonly message: OrchestrationThread["messages"][number]; + } + | { + readonly type: "queued-message"; + readonly id: string; + readonly createdAt: string; + readonly queuedMessage: QueuedThreadMessage; + readonly sending: boolean; + } + | { + readonly type: "activity"; + readonly id: string; + readonly createdAt: string; + readonly activity: ThreadFeedActivity; + }; + +export type ThreadFeedEntry = + | Extract + | { + readonly type: "activity-group"; + readonly id: string; + readonly createdAt: string; + readonly activities: ReadonlyArray; + }; + +function requestKindFromRequestType(requestType: unknown): PendingApproval["requestKind"] | null { + switch (requestType) { + case "command_execution_approval": + case "exec_command_approval": + return "command"; + case "file_read_approval": + return "file-read"; + case "file_change_approval": + case "apply_patch_approval": + return "file-change"; + default: + return null; + } +} + +function isStalePendingRequestFailureDetail(detail: string | undefined): boolean { + const normalized = detail?.toLowerCase(); + if (!normalized) { + return false; + } + return ( + normalized.includes("stale pending approval request") || + normalized.includes("stale pending user-input request") || + normalized.includes("unknown pending approval request") || + normalized.includes("unknown pending permission request") || + normalized.includes("unknown pending user-input request") + ); +} + +function parseApprovalRequestId(value: unknown): ApprovalRequestId | null { + return typeof value === "string" && value.length > 0 ? ApprovalRequestId.make(value) : null; +} + +function parseUserInputQuestions( + payload: Record | null, +): ReadonlyArray | null { + const questions = payload?.questions; + if (!Array.isArray(questions)) { + return null; + } + + const parsed = questions + .map((entry) => { + if (!entry || typeof entry !== "object") return null; + const question = entry as Record; + if ( + typeof question.id !== "string" || + typeof question.header !== "string" || + typeof question.question !== "string" || + !Array.isArray(question.options) + ) { + return null; + } + const options = question.options + .map((option) => { + if (!option || typeof option !== "object") return null; + const record = option as Record; + if (typeof record.label !== "string" || typeof record.description !== "string") { + return null; + } + return { + label: record.label, + description: record.description, + }; + }) + .filter((option): option is UserInputQuestion["options"][number] => option !== null); + if (options.length === 0) { + return null; + } + return { + id: question.id, + header: question.header, + question: question.question, + options, + multiSelect: question.multiSelect === true, + }; + }) + .filter((question): question is UserInputQuestion => question !== null); + + return parsed.length > 0 ? parsed : null; +} + +function normalizeDraftAnswer(value: string | undefined): string | null { + if (typeof value !== "string") { + return null; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function resolvePendingUserInputAnswer( + draft: PendingUserInputDraftAnswer | undefined, +): string | null { + const customAnswer = normalizeDraftAnswer(draft?.customAnswer); + if (customAnswer) { + return customAnswer; + } + return normalizeDraftAnswer(draft?.selectedOptionLabel); +} + +function deriveWorkLogEntries( + activities: ReadonlyArray, + latestTurnId: TurnId | undefined, +): WorkLogEntry[] { + const ordered = Arr.sort(activities, activityOrder); + const entries = ordered + .filter((activity) => (latestTurnId ? activity.turnId === latestTurnId : true)) + .filter((activity) => activity.kind !== "tool.started") + .filter((activity) => activity.kind !== "task.started" && activity.kind !== "task.completed") + .filter((activity) => activity.kind !== "context-window.updated") + .filter((activity) => activity.summary !== "Checkpoint captured") + .filter((activity) => !isPlanBoundaryToolActivity(activity)) + .map(toDerivedWorkLogEntry); + return collapseDerivedWorkLogEntries(entries).map( + ({ activityKind: _activityKind, collapseKey: _collapseKey, ...entry }) => entry, + ); +} + +function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): boolean { + if (activity.kind !== "tool.updated" && activity.kind !== "tool.completed") { + return false; + } + + const payload = + activity.payload && typeof activity.payload === "object" + ? (activity.payload as Record) + : null; + return typeof payload?.detail === "string" && payload.detail.startsWith("ExitPlanMode:"); +} + +function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWorkLogEntry { + const payload = + activity.payload && typeof activity.payload === "object" + ? (activity.payload as Record) + : null; + const commandPreview = extractToolCommand(payload); + const changedFiles = extractChangedFiles(payload); + const title = extractToolTitle(payload); + const entry: DerivedWorkLogEntry = { + id: activity.id, + createdAt: activity.createdAt, + label: activity.summary, + tone: activity.tone === "approval" ? "info" : activity.tone, + activityKind: activity.kind, + }; + const itemType = extractWorkLogItemType(payload); + const requestKind = extractWorkLogRequestKind(payload); + if (payload && typeof payload.detail === "string" && payload.detail.length > 0) { + const detail = stripTrailingExitCode(payload.detail).output; + if (detail) { + entry.detail = detail; + } + } + if (commandPreview.command) { + entry.command = commandPreview.command; + } + if (commandPreview.rawCommand) { + entry.rawCommand = commandPreview.rawCommand; + } + if (changedFiles.length > 0) { + entry.changedFiles = changedFiles; + } + if (title) { + entry.toolTitle = title; + } + if (itemType) { + entry.itemType = itemType; + } + if (requestKind) { + entry.requestKind = requestKind; + } + const collapseKey = deriveToolLifecycleCollapseKey(entry); + if (collapseKey) { + entry.collapseKey = collapseKey; + } + return entry; +} + +function collapseDerivedWorkLogEntries( + entries: ReadonlyArray, +): DerivedWorkLogEntry[] { + const collapsed: DerivedWorkLogEntry[] = []; + for (const entry of entries) { + const previous = collapsed.at(-1); + if (previous && shouldCollapseToolLifecycleEntries(previous, entry)) { + collapsed[collapsed.length - 1] = mergeDerivedWorkLogEntries(previous, entry); + continue; + } + collapsed.push(entry); + } + return collapsed; +} + +function shouldCollapseToolLifecycleEntries( + previous: DerivedWorkLogEntry, + next: DerivedWorkLogEntry, +): boolean { + if (previous.activityKind !== "tool.updated" && previous.activityKind !== "tool.completed") { + return false; + } + if (next.activityKind !== "tool.updated" && next.activityKind !== "tool.completed") { + return false; + } + if (previous.activityKind === "tool.completed") { + return false; + } + return previous.collapseKey !== undefined && previous.collapseKey === next.collapseKey; +} + +function mergeDerivedWorkLogEntries( + previous: DerivedWorkLogEntry, + next: DerivedWorkLogEntry, +): DerivedWorkLogEntry { + const changedFiles = mergeChangedFiles(previous.changedFiles, next.changedFiles); + const detail = next.detail ?? previous.detail; + const command = next.command ?? previous.command; + const rawCommand = next.rawCommand ?? previous.rawCommand; + const toolTitle = next.toolTitle ?? previous.toolTitle; + const itemType = next.itemType ?? previous.itemType; + const requestKind = next.requestKind ?? previous.requestKind; + const collapseKey = next.collapseKey ?? previous.collapseKey; + return { + ...previous, + ...next, + ...(detail ? { detail } : {}), + ...(command ? { command } : {}), + ...(rawCommand ? { rawCommand } : {}), + ...(changedFiles.length > 0 ? { changedFiles } : {}), + ...(toolTitle ? { toolTitle } : {}), + ...(itemType ? { itemType } : {}), + ...(requestKind ? { requestKind } : {}), + ...(collapseKey ? { collapseKey } : {}), + }; +} + +function mergeChangedFiles( + previous: ReadonlyArray | undefined, + next: ReadonlyArray | undefined, +): string[] { + const merged = [...(previous ?? []), ...(next ?? [])]; + if (merged.length === 0) { + return []; + } + return [...new Set(merged)]; +} + +function deriveToolLifecycleCollapseKey(entry: DerivedWorkLogEntry): string | undefined { + if (entry.activityKind !== "tool.updated" && entry.activityKind !== "tool.completed") { + return undefined; + } + const normalizedLabel = normalizeCompactToolLabel(entry.toolTitle ?? entry.label); + const detail = entry.detail?.trim() ?? ""; + const itemType = entry.itemType ?? ""; + if (normalizedLabel.length === 0 && detail.length === 0 && itemType.length === 0) { + return undefined; + } + return [itemType, normalizedLabel, detail].join("\u001f"); +} + +function normalizeCompactToolLabel(value: string): string { + return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim(); +} + +function workEntryPreview( + workEntry: Pick, +): string | null { + if (workEntry.command) return workEntry.command; + if (workEntry.detail) return workEntry.detail; + if ((workEntry.changedFiles?.length ?? 0) === 0) return null; + const [firstPath] = workEntry.changedFiles ?? []; + if (!firstPath) return null; + return workEntry.changedFiles!.length === 1 + ? firstPath + : `${firstPath} +${workEntry.changedFiles!.length - 1} more`; +} + +function capitalizePhrase(value: string): string { + const trimmed = value.trim(); + if (trimmed.length === 0) { + return value; + } + return `${trimmed.charAt(0).toUpperCase()}${trimmed.slice(1)}`; +} + +function workEntryHeading(workEntry: WorkLogEntry): string { + if (!workEntry.toolTitle) { + return capitalizePhrase(normalizeCompactToolLabel(workEntry.label)); + } + return capitalizePhrase(normalizeCompactToolLabel(workEntry.toolTitle)); +} + +function asRecord(value: unknown): Record | null { + return value && typeof value === "object" ? (value as Record) : null; +} + +function asTrimmedString(value: unknown): string | null { + if (typeof value !== "string") { + return null; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function trimMatchingOuterQuotes(value: string): string { + const trimmed = value.trim(); + if ( + (trimmed.startsWith("'") && trimmed.endsWith("'")) || + (trimmed.startsWith('"') && trimmed.endsWith('"')) + ) { + const unquoted = trimmed.slice(1, -1).trim(); + return unquoted.length > 0 ? unquoted : trimmed; + } + return trimmed; +} + +function executableBasename(value: string): string | null { + const trimmed = trimMatchingOuterQuotes(value); + if (trimmed.length === 0) { + return null; + } + const normalized = trimmed.replace(/\\/g, "/"); + const segments = normalized.split("/"); + const last = segments.at(-1)?.trim() ?? ""; + return last.length > 0 ? last.toLowerCase() : null; +} + +function splitExecutableAndRest(value: string): { executable: string; rest: string } | null { + const trimmed = value.trim(); + if (trimmed.length === 0) { + return null; + } + + if (trimmed.startsWith('"') || trimmed.startsWith("'")) { + const quote = trimmed.charAt(0); + const closeIndex = trimmed.indexOf(quote, 1); + if (closeIndex <= 0) { + return null; + } + return { + executable: trimmed.slice(0, closeIndex + 1), + rest: trimmed.slice(closeIndex + 1).trim(), + }; + } + + const firstWhitespace = trimmed.search(/\s/); + if (firstWhitespace < 0) { + return { + executable: trimmed, + rest: "", + }; + } + + return { + executable: trimmed.slice(0, firstWhitespace), + rest: trimmed.slice(firstWhitespace).trim(), + }; +} + +const SHELL_WRAPPER_SPECS = [ + { + executables: ["pwsh", "pwsh.exe", "powershell", "powershell.exe"], + wrapperFlagPattern: /(?:^|\s)-command\s+/i, + }, + { + executables: ["cmd", "cmd.exe"], + wrapperFlagPattern: /(?:^|\s)\/c\s+/i, + }, + { + executables: ["bash", "sh", "zsh"], + wrapperFlagPattern: /(?:^|\s)-(?:l)?c\s+/i, + }, +] as const; + +function findShellWrapperSpec(shell: string) { + return SHELL_WRAPPER_SPECS.find((spec) => + (spec.executables as ReadonlyArray).includes(shell), + ); +} + +function unwrapCommandRemainder(value: string, wrapperFlagPattern: RegExp): string | null { + const match = wrapperFlagPattern.exec(value); + if (!match) { + return null; + } + + const command = value.slice(match.index + match[0].length).trim(); + if (command.length === 0) { + return null; + } + + const unwrapped = trimMatchingOuterQuotes(command); + return unwrapped.length > 0 ? unwrapped : null; +} + +function unwrapKnownShellCommandWrapper(value: string): string { + const split = splitExecutableAndRest(value); + if (!split || split.rest.length === 0) { + return value; + } + + const shell = executableBasename(split.executable); + if (!shell) { + return value; + } + + const spec = findShellWrapperSpec(shell); + if (!spec) { + return value; + } + + return unwrapCommandRemainder(split.rest, spec.wrapperFlagPattern) ?? value; +} + +function formatCommandArrayPart(value: string): string { + return /[\s"'`]/.test(value) ? `"${value.replace(/"/g, '\\"')}"` : value; +} + +function formatCommandValue(value: unknown): string | null { + const direct = asTrimmedString(value); + if (direct) { + return direct; + } + if (!Array.isArray(value)) { + return null; + } + const parts = value + .map((entry) => asTrimmedString(entry)) + .filter((entry): entry is string => entry !== null); + if (parts.length === 0) { + return null; + } + return parts.map((part) => formatCommandArrayPart(part)).join(" "); +} + +function normalizeCommandValue(value: unknown): string | null { + const formatted = formatCommandValue(value); + return formatted ? unwrapKnownShellCommandWrapper(formatted) : null; +} + +function toRawToolCommand(value: unknown, normalizedCommand: string | null): string | null { + const formatted = formatCommandValue(value); + if (!formatted || normalizedCommand === null) { + return null; + } + return formatted === normalizedCommand ? null : formatted; +} + +function extractToolCommand(payload: Record | null): { + command: string | null; + rawCommand: string | null; +} { + const data = asRecord(payload?.data); + const item = asRecord(data?.item); + const itemResult = asRecord(item?.result); + const itemInput = asRecord(item?.input); + const itemType = asTrimmedString(payload?.itemType); + const detail = asTrimmedString(payload?.detail); + const candidates: unknown[] = [ + item?.command, + itemInput?.command, + itemResult?.command, + data?.command, + itemType === "command_execution" && detail ? stripTrailingExitCode(detail).output : null, + ]; + + for (const candidate of candidates) { + const command = normalizeCommandValue(candidate); + if (!command) { + continue; + } + return { + command, + rawCommand: toRawToolCommand(candidate, command), + }; + } + + return { + command: null, + rawCommand: null, + }; +} + +function extractToolTitle(payload: Record | null): string | null { + return asTrimmedString(payload?.title); +} + +function stripTrailingExitCode(value: string): { + output: string | null; + exitCode?: number | undefined; +} { + const trimmed = value.trim(); + const match = /^(?[\s\S]*?)(?:\s*\d+)>)\s*$/i.exec( + trimmed, + ); + if (!match?.groups) { + return { + output: trimmed.length > 0 ? trimmed : null, + }; + } + const exitCode = Number.parseInt(match.groups.code ?? "", 10); + const normalizedOutput = match.groups.output?.trim() ?? ""; + return { + output: normalizedOutput.length > 0 ? normalizedOutput : null, + ...(Number.isInteger(exitCode) ? { exitCode } : {}), + }; +} + +function extractWorkLogItemType( + payload: Record | null, +): WorkLogEntry["itemType"] | undefined { + if (typeof payload?.itemType === "string" && isToolLifecycleItemType(payload.itemType)) { + return payload.itemType; + } + return undefined; +} + +function extractWorkLogRequestKind( + payload: Record | null, +): WorkLogEntry["requestKind"] | undefined { + if ( + payload?.requestKind === "command" || + payload?.requestKind === "file-read" || + payload?.requestKind === "file-change" + ) { + return payload.requestKind; + } + return requestKindFromRequestType(payload?.requestType) ?? undefined; +} + +function pushChangedFile(target: string[], seen: Set, value: unknown) { + const normalized = asTrimmedString(value); + if (!normalized || seen.has(normalized)) { + return; + } + seen.add(normalized); + target.push(normalized); +} + +function collectChangedFiles(value: unknown, target: string[], seen: Set, depth: number) { + if (depth > 4 || target.length >= 12) { + return; + } + if (Array.isArray(value)) { + for (const entry of value) { + collectChangedFiles(entry, target, seen, depth + 1); + if (target.length >= 12) { + return; + } + } + return; + } + + const record = asRecord(value); + if (!record) { + return; + } + + pushChangedFile(target, seen, record.path); + pushChangedFile(target, seen, record.filePath); + pushChangedFile(target, seen, record.relativePath); + pushChangedFile(target, seen, record.filename); + pushChangedFile(target, seen, record.newPath); + pushChangedFile(target, seen, record.oldPath); + + for (const nestedKey of [ + "item", + "result", + "input", + "data", + "changes", + "files", + "edits", + "patch", + "patches", + "operations", + ]) { + if (!(nestedKey in record)) { + continue; + } + collectChangedFiles(record[nestedKey], target, seen, depth + 1); + if (target.length >= 12) { + return; + } + } +} + +function extractChangedFiles(payload: Record | null): string[] { + const changedFiles: string[] = []; + const seen = new Set(); + collectChangedFiles(asRecord(payload?.data), changedFiles, seen, 0); + return changedFiles; +} + +function compareActivityLifecycleRank(kind: string): number { + if (kind.endsWith(".started") || kind === "tool.started") { + return 0; + } + if (kind.endsWith(".progress") || kind.endsWith(".updated")) { + return 1; + } + if (kind.endsWith(".completed") || kind.endsWith(".resolved")) { + return 2; + } + return 1; +} + +const activityOrder = Order.combineAll([ + Order.mapInput(Order.Number, (activity) => activity.sequence ?? Number.MAX_SAFE_INTEGER), + Order.mapInput(Order.String, (activity) => activity.createdAt), + Order.mapInput(Order.Number, (activity) => compareActivityLifecycleRank(activity.kind)), + Order.mapInput(Order.String, (activity) => activity.id), +]); + +function isEmptyMessage(entry: RawThreadFeedEntry): boolean { + if (entry.type !== "message") { + return false; + } + const hasText = entry.message.text.trim().length > 0; + const hasAttachments = (entry.message.attachments ?? []).length > 0; + return !hasText && !hasAttachments; +} + +function groupAdjacentActivities(entries: ReadonlyArray): ThreadFeedEntry[] { + const grouped: ThreadFeedEntry[] = []; + + for (const entry of entries) { + // Skip empty messages so they don't break activity grouping. + if (isEmptyMessage(entry)) { + continue; + } + + if (entry.type !== "activity") { + grouped.push(entry); + continue; + } + + const previous = grouped.at(-1); + if (previous?.type === "activity-group") { + grouped[grouped.length - 1] = { + ...previous, + activities: [...previous.activities, entry.activity], + }; + continue; + } + + grouped.push({ + type: "activity-group", + id: entry.id, + createdAt: entry.createdAt, + activities: [entry.activity], + }); + } + + return grouped; +} + +export function derivePendingApprovals( + activities: ReadonlyArray, +): PendingApproval[] { + const openByRequestId = new Map(); + const ordered = Arr.sort(activities, activityOrder); + + for (const activity of ordered) { + const payload = + activity.payload && typeof activity.payload === "object" + ? (activity.payload as Record) + : null; + const requestId = parseApprovalRequestId(payload?.requestId); + const requestKind = + payload?.requestKind === "command" || + payload?.requestKind === "file-read" || + payload?.requestKind === "file-change" + ? payload.requestKind + : requestKindFromRequestType(payload?.requestType); + const detail = typeof payload?.detail === "string" ? payload.detail : undefined; + + if (activity.kind === "approval.requested" && requestId && requestKind) { + openByRequestId.set(requestId, { + requestId, + requestKind, + createdAt: activity.createdAt, + ...(detail ? { detail } : {}), + }); + continue; + } + + if (activity.kind === "approval.resolved" && requestId) { + openByRequestId.delete(requestId); + continue; + } + + if ( + activity.kind === "provider.approval.respond.failed" && + requestId && + isStalePendingRequestFailureDetail(detail) + ) { + openByRequestId.delete(requestId); + } + } + + return Arr.sortWith([...openByRequestId.values()], (s) => new Date(s.createdAt), Order.Date); +} + +export function derivePendingUserInputs( + activities: ReadonlyArray, +): PendingUserInput[] { + const openByRequestId = new Map(); + const ordered = Arr.sort(activities, activityOrder); + + for (const activity of ordered) { + const payload = + activity.payload && typeof activity.payload === "object" + ? (activity.payload as Record) + : null; + const requestId = parseApprovalRequestId(payload?.requestId); + const detail = typeof payload?.detail === "string" ? payload.detail : undefined; + + if (activity.kind === "user-input.requested" && requestId) { + const questions = parseUserInputQuestions(payload); + if (!questions) { + continue; + } + openByRequestId.set(requestId, { + requestId, + createdAt: activity.createdAt, + questions, + }); + continue; + } + + if (activity.kind === "user-input.resolved" && requestId) { + openByRequestId.delete(requestId); + continue; + } + + if ( + activity.kind === "provider.user-input.respond.failed" && + requestId && + isStalePendingRequestFailureDetail(detail) + ) { + openByRequestId.delete(requestId); + } + } + + return Arr.sortWith(openByRequestId.values(), (s) => new Date(s.createdAt), Order.Date); +} + +export function setPendingUserInputCustomAnswer( + draft: PendingUserInputDraftAnswer | undefined, + customAnswer: string, +): PendingUserInputDraftAnswer { + const selectedOptionLabel = + customAnswer.trim().length > 0 ? undefined : draft?.selectedOptionLabel; + return { + customAnswer, + ...(selectedOptionLabel ? { selectedOptionLabel } : {}), + }; +} + +export function buildPendingUserInputAnswers( + questions: ReadonlyArray, + draftAnswers: Record, +): Record | null { + const answers: Record = {}; + + for (const question of questions) { + const answer = resolvePendingUserInputAnswer(draftAnswers[question.id]); + if (!answer) { + return null; + } + answers[question.id] = answer; + } + + return answers; +} + +export function buildThreadFeed( + thread: OrchestrationThread, + queuedMessages: ReadonlyArray, + dispatchingQueuedMessageId: MessageId | null, + options?: { + readonly loadedMessages?: ReadonlyArray; + }, +): ThreadFeedEntry[] { + const loadedMessages = options?.loadedMessages ?? thread.messages; + const oldestLoadedMessageCreatedAt = + options?.loadedMessages !== undefined ? (loadedMessages[0]?.createdAt ?? null) : null; + const workLogEntries = deriveWorkLogEntries( + thread.activities, + thread.latestTurn?.turnId ?? undefined, + ); + const entries = Arr.sortWith( + [ + ...loadedMessages.map((message) => ({ + type: "message", + id: message.id, + createdAt: message.createdAt, + message, + })), + ...queuedMessages.map((queuedMessage) => ({ + type: "queued-message", + id: queuedMessage.messageId, + createdAt: queuedMessage.createdAt, + queuedMessage, + sending: queuedMessage.messageId === dispatchingQueuedMessageId, + })), + ...workLogEntries + .filter((entry) => { + if (options?.loadedMessages === undefined) { + return true; + } + return ( + oldestLoadedMessageCreatedAt === null || entry.createdAt >= oldestLoadedMessageCreatedAt + ); + }) + .map((entry) => ({ + type: "activity", + id: entry.id, + createdAt: entry.createdAt, + activity: { + id: entry.id, + createdAt: entry.createdAt, + summary: workEntryHeading(entry), + detail: workEntryPreview(entry), + status: null, + }, + })), + ], + (s) => new Date(s.createdAt), + Order.Date, + ); + + return groupAdjacentActivities(entries); +} diff --git a/apps/mobile/src/lib/time.ts b/apps/mobile/src/lib/time.ts new file mode 100644 index 00000000000..9cbdada68fb --- /dev/null +++ b/apps/mobile/src/lib/time.ts @@ -0,0 +1,19 @@ +export function relativeTime(input: string): string { + const timestamp = Date.parse(input); + if (Number.isNaN(timestamp)) { + return "now"; + } + + const deltaSeconds = Math.max(0, Math.floor((Date.now() - timestamp) / 1000)); + if (deltaSeconds < 10) return "now"; + if (deltaSeconds < 60) return `${deltaSeconds}s`; + + const deltaMinutes = Math.floor(deltaSeconds / 60); + if (deltaMinutes < 60) return `${deltaMinutes}m`; + + const deltaHours = Math.floor(deltaMinutes / 60); + if (deltaHours < 24) return `${deltaHours}h`; + + const deltaDays = Math.floor(deltaHours / 24); + return `${deltaDays}d`; +} diff --git a/apps/mobile/src/lib/useNativePaste.ts b/apps/mobile/src/lib/useNativePaste.ts new file mode 100644 index 00000000000..c97eb5a35f9 --- /dev/null +++ b/apps/mobile/src/lib/useNativePaste.ts @@ -0,0 +1,21 @@ +import type { PasteEventPayload } from "expo-paste-input"; +import { useCallback } from "react"; + +/** + * Returns a stable `onPaste` handler for `TextInputWrapper` from + * `expo-paste-input`. When the user pastes images via the OS paste gesture, + * `onImages` is called with the pasted URIs. Text pastes are left to the + * native TextInput — no extra handling is needed. + * + * Used by both the thread composer and the new-task draft screen. + */ +export function useNativePaste(onImages: (uris: ReadonlyArray) => void) { + return useCallback( + (payload: PasteEventPayload) => { + if (payload.type === "images" && payload.uris && payload.uris.length > 0) { + onImages(payload.uris); + } + }, + [onImages], + ); +} diff --git a/apps/mobile/src/lib/useThemeColor.ts b/apps/mobile/src/lib/useThemeColor.ts new file mode 100644 index 00000000000..38dbf6c9b08 --- /dev/null +++ b/apps/mobile/src/lib/useThemeColor.ts @@ -0,0 +1,12 @@ +import type { ColorValue } from "react-native"; +import { useCSSVariable } from "uniwind"; + +/** + * Typed wrapper around `useCSSVariable` that returns a `ColorValue` for use + * in React Native style props (backgroundColor, tintColor, etc.). + * + * Usage: `const color = useThemeColor("--color-icon");` + */ +export function useThemeColor(variable: `--color-${string}`): ColorValue { + return useCSSVariable(variable) as string as ColorValue; +} diff --git a/apps/mobile/src/lib/uuid.ts b/apps/mobile/src/lib/uuid.ts new file mode 100644 index 00000000000..95af0040f1c --- /dev/null +++ b/apps/mobile/src/lib/uuid.ts @@ -0,0 +1,4 @@ +import * as Effect from "effect/Effect"; +import * as Random from "effect/Random"; + +export const uuidv4 = () => Effect.runSync(Random.nextUUIDv4); diff --git a/apps/mobile/src/state/atom-registry.ts b/apps/mobile/src/state/atom-registry.ts new file mode 100644 index 00000000000..b30e7c3729a --- /dev/null +++ b/apps/mobile/src/state/atom-registry.ts @@ -0,0 +1,3 @@ +import { AtomRegistry } from "effect/unstable/reactivity"; + +export const appAtomRegistry = AtomRegistry.make(); diff --git a/apps/mobile/src/state/environment-session-registry.ts b/apps/mobile/src/state/environment-session-registry.ts new file mode 100644 index 00000000000..3eb94b32c06 --- /dev/null +++ b/apps/mobile/src/state/environment-session-registry.ts @@ -0,0 +1,48 @@ +import type { EnvironmentId } from "@t3tools/contracts"; + +import type { EnvironmentSession } from "./remote-runtime-types"; + +const environmentSessions = new Map(); +const environmentConnectionListeners = new Set<() => void>(); + +export function getEnvironmentSession(environmentId: EnvironmentId): EnvironmentSession | null { + return environmentSessions.get(environmentId) ?? null; +} + +export function getEnvironmentClient(environmentId: EnvironmentId) { + return getEnvironmentSession(environmentId)?.client ?? null; +} + +export function setEnvironmentSession( + environmentId: EnvironmentId, + session: EnvironmentSession, +): void { + environmentSessions.set(environmentId, session); +} + +export function removeEnvironmentSession(environmentId: EnvironmentId): EnvironmentSession | null { + const session = getEnvironmentSession(environmentId); + environmentSessions.delete(environmentId); + return session; +} + +export function drainEnvironmentSessions(): ReadonlyArray { + const sessions = [...environmentSessions.values()]; + environmentSessions.clear(); + return sessions; +} + +export function notifyEnvironmentConnectionListeners() { + for (const listener of environmentConnectionListeners) listener(); +} + +/** + * Subscribe to environment-connection changes (connect / disconnect / reconnect). + * Returns an unsubscribe function. + */ +export function subscribeEnvironmentConnections(listener: () => void): () => void { + environmentConnectionListeners.add(listener); + return () => { + environmentConnectionListeners.delete(listener); + }; +} diff --git a/apps/mobile/src/state/remote-runtime-types.ts b/apps/mobile/src/state/remote-runtime-types.ts new file mode 100644 index 00000000000..74d2cc8d995 --- /dev/null +++ b/apps/mobile/src/state/remote-runtime-types.ts @@ -0,0 +1,26 @@ +import type { + EnvironmentConnection, + EnvironmentConnectionState, + WsRpcClient, +} from "@t3tools/client-runtime"; +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; + +export type { EnvironmentRuntimeState } from "@t3tools/client-runtime"; + +export interface ConnectedEnvironmentSummary { + readonly environmentId: EnvironmentId; + readonly environmentLabel: string; + readonly displayUrl: string; + readonly connectionState: EnvironmentConnectionState; + readonly connectionError: string | null; +} + +export interface SelectedThreadRef { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; +} + +export interface EnvironmentSession { + readonly client: WsRpcClient; + readonly connection: EnvironmentConnection; +} diff --git a/apps/mobile/src/state/use-checkpoint-diff.ts b/apps/mobile/src/state/use-checkpoint-diff.ts new file mode 100644 index 00000000000..3111008f00a --- /dev/null +++ b/apps/mobile/src/state/use-checkpoint-diff.ts @@ -0,0 +1,16 @@ +import { createCheckpointDiffManager, type CheckpointDiffTarget } from "@t3tools/client-runtime"; + +import { appAtomRegistry } from "./atom-registry"; +import { getEnvironmentClient } from "./environment-session-registry"; + +export const checkpointDiffManager = createCheckpointDiffManager({ + getRegistry: () => appAtomRegistry, + getClient: (environmentId) => getEnvironmentClient(environmentId)?.orchestration ?? null, +}); + +export function loadCheckpointDiff( + target: CheckpointDiffTarget, + options?: { readonly force?: boolean }, +) { + return checkpointDiffManager.load(target, undefined, options); +} diff --git a/apps/mobile/src/state/use-composer-path-search.ts b/apps/mobile/src/state/use-composer-path-search.ts new file mode 100644 index 00000000000..a42143a427b --- /dev/null +++ b/apps/mobile/src/state/use-composer-path-search.ts @@ -0,0 +1,46 @@ +import { useAtomValue } from "@effect/atom-react"; +import { + type ComposerPathSearchState, + type ComposerPathSearchTarget, + EMPTY_COMPOSER_PATH_SEARCH_ATOM, + EMPTY_COMPOSER_PATH_SEARCH_STATE, + composerPathSearchStateAtom, + createComposerPathSearchManager, + getComposerPathSearchTargetKey, + normalizeComposerPathSearchQuery, +} from "@t3tools/client-runtime"; +import { useEffect, useMemo } from "react"; + +import { appAtomRegistry } from "./atom-registry"; +import { + getEnvironmentClient, + subscribeEnvironmentConnections, +} from "./environment-session-registry"; + +const COMPOSER_PATH_SEARCH_STALE_TIME_MS = 15_000; + +const composerPathSearchManager = createComposerPathSearchManager({ + getRegistry: () => appAtomRegistry, + getClient: (environmentId) => getEnvironmentClient(environmentId)?.projects ?? null, + subscribeClientChanges: subscribeEnvironmentConnections, + staleTimeMs: COMPOSER_PATH_SEARCH_STALE_TIME_MS, +}); + +export function useComposerPathSearch(target: ComposerPathSearchTarget): ComposerPathSearchState { + const stableTarget = useMemo( + () => ({ + environmentId: target.environmentId, + cwd: target.cwd, + query: normalizeComposerPathSearchQuery(target.query), + }), + [target.cwd, target.environmentId, target.query], + ); + const targetKey = getComposerPathSearchTargetKey(stableTarget); + + useEffect(() => composerPathSearchManager.watch(stableTarget), [stableTarget]); + + const state = useAtomValue( + targetKey !== null ? composerPathSearchStateAtom(targetKey) : EMPTY_COMPOSER_PATH_SEARCH_ATOM, + ); + return targetKey === null ? EMPTY_COMPOSER_PATH_SEARCH_STATE : state; +} diff --git a/apps/mobile/src/state/use-environment-runtime.ts b/apps/mobile/src/state/use-environment-runtime.ts new file mode 100644 index 00000000000..f4a65a0d283 --- /dev/null +++ b/apps/mobile/src/state/use-environment-runtime.ts @@ -0,0 +1,76 @@ +import { useAtomValue } from "@effect/atom-react"; +import { + EMPTY_ENVIRONMENT_RUNTIME_ATOM, + EMPTY_ENVIRONMENT_RUNTIME_STATE, + createEnvironmentRuntimeManager, + environmentRuntimeStateAtom, + getEnvironmentRuntimeTargetKey, + type EnvironmentRuntimeState, +} from "@t3tools/client-runtime"; +import type { EnvironmentId } from "@t3tools/contracts"; +import { useCallback, useMemo, useRef, useSyncExternalStore } from "react"; + +import { appAtomRegistry } from "./atom-registry"; +import * as Arr from "effect/Array"; +import * as Order from "effect/Order"; + +export const environmentRuntimeManager = createEnvironmentRuntimeManager({ + getRegistry: () => appAtomRegistry, +}); + +export function useEnvironmentRuntime( + environmentId: EnvironmentId | null, +): EnvironmentRuntimeState { + const targetKey = getEnvironmentRuntimeTargetKey({ environmentId }); + const state = useAtomValue( + targetKey !== null ? environmentRuntimeStateAtom(targetKey) : EMPTY_ENVIRONMENT_RUNTIME_ATOM, + ); + return targetKey === null ? EMPTY_ENVIRONMENT_RUNTIME_STATE : state; +} + +export function useEnvironmentRuntimeStates( + environmentIds: ReadonlyArray, +): Readonly> { + const stableEnvironmentIds = useMemo( + () => Arr.sort(new Set(environmentIds), Order.String), + [environmentIds], + ); + const snapshotCacheRef = useRef>>({}); + + const subscribe = useCallback( + (onStoreChange: () => void) => { + const unsubs = stableEnvironmentIds.map((environmentId) => + appAtomRegistry.subscribe(environmentRuntimeStateAtom(environmentId), onStoreChange), + ); + return () => { + for (const unsub of unsubs) { + unsub(); + } + }; + }, + [stableEnvironmentIds], + ); + + const getSnapshot = useCallback(() => { + const previous = snapshotCacheRef.current; + let hasChanged = Object.keys(previous).length !== stableEnvironmentIds.length; + const next: Record = {}; + + for (const environmentId of stableEnvironmentIds) { + const snapshot = environmentRuntimeManager.getSnapshot({ environmentId }); + next[environmentId] = snapshot; + if (!hasChanged && previous[environmentId] !== snapshot) { + hasChanged = true; + } + } + + if (!hasChanged) { + return previous; + } + + snapshotCacheRef.current = next; + return next; + }, [stableEnvironmentIds]); + + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); +} diff --git a/apps/mobile/src/state/use-remote-catalog.ts b/apps/mobile/src/state/use-remote-catalog.ts new file mode 100644 index 00000000000..5a504b1c8b7 --- /dev/null +++ b/apps/mobile/src/state/use-remote-catalog.ts @@ -0,0 +1,115 @@ +import { useMemo } from "react"; +import * as Order from "effect/Order"; +import * as Arr from "effect/Array"; + +import { + EnvironmentConnectionState, + EnvironmentScopedProjectShell, + EnvironmentScopedThreadShell, + scopeProjectShell, + scopeThreadShell, +} from "@t3tools/client-runtime"; + +import { ConnectedEnvironmentSummary } from "./remote-runtime-types"; +import { useShellSnapshotStates } from "./use-shell-snapshot"; +import { + useRemoteConnectionStatus, + useRemoteEnvironmentState, +} from "./use-remote-environment-registry"; + +const projectsSortOrder = Order.make( + (left, right) => + (left.title.localeCompare(right.title) as -1 | 0 | 1) || + (left.environmentId.localeCompare(right.environmentId) as -1 | 0 | 1), +); + +const threadsSortOrder = Order.make( + (left, right) => + ((new Date(right.updatedAt ?? right.createdAt).getTime() - + new Date(left.updatedAt ?? left.createdAt).getTime()) as -1 | 0 | 1) || + (left.environmentId.localeCompare(right.environmentId) as -1 | 0 | 1), +); + +function deriveOverallConnectionState( + environments: ReadonlyArray, +): EnvironmentConnectionState { + if (environments.length === 0) { + return "idle"; + } + if (environments.some((environment) => environment.connectionState === "ready")) { + return "ready"; + } + if (environments.some((environment) => environment.connectionState === "reconnecting")) { + return "reconnecting"; + } + if (environments.some((environment) => environment.connectionState === "connecting")) { + return "connecting"; + } + return "disconnected"; +} + +export function useRemoteCatalog() { + const { connectedEnvironments, connectionState } = useRemoteConnectionStatus(); + const { environmentStateById, savedConnectionsById } = useRemoteEnvironmentState(); + const shellSnapshotStates = useShellSnapshotStates( + Object.values(savedConnectionsById).map((connection) => connection.environmentId), + ); + + const projects = useMemo( + () => + Arr.sort( + Object.values(savedConnectionsById).flatMap((connection) => + (shellSnapshotStates[connection.environmentId]?.data?.projects ?? []).map((project) => + scopeProjectShell(connection.environmentId, project), + ), + ), + projectsSortOrder, + ), + [savedConnectionsById, shellSnapshotStates], + ); + + const threads = useMemo( + () => + Arr.sort( + Object.values(savedConnectionsById).flatMap((connection) => + (shellSnapshotStates[connection.environmentId]?.data?.threads ?? []).map((thread) => + scopeThreadShell(connection.environmentId, thread), + ), + ), + threadsSortOrder, + ), + [savedConnectionsById, shellSnapshotStates], + ); + + const serverConfigByEnvironmentId = useMemo( + () => + Object.fromEntries( + Object.entries(environmentStateById).map(([environmentId, runtime]) => [ + environmentId, + runtime.serverConfig ?? null, + ]), + ), + [environmentStateById], + ); + + const overallConnectionState = useMemo( + () => deriveOverallConnectionState(connectedEnvironments), + [connectedEnvironments], + ); + + const hasRemoteActivity = useMemo( + () => + threads.some( + (thread) => thread.session?.status === "running" || thread.session?.status === "starting", + ), + [threads], + ); + + return { + projects, + threads, + serverConfigByEnvironmentId, + connectionState: connectionState ?? overallConnectionState, + hasRemoteActivity, + }; +} diff --git a/apps/mobile/src/state/use-remote-environment-registry.ts b/apps/mobile/src/state/use-remote-environment-registry.ts new file mode 100644 index 00000000000..0d68352c6f7 --- /dev/null +++ b/apps/mobile/src/state/use-remote-environment-registry.ts @@ -0,0 +1,498 @@ +import { useAtomValue } from "@effect/atom-react"; +import { useCallback, useEffect, useMemo } from "react"; +import { Alert } from "react-native"; + +import { + type EnvironmentRuntimeState, + createEnvironmentConnection, + createKnownEnvironment, + createWsRpcClient, + EnvironmentConnectionState, + WsTransport, +} from "@t3tools/client-runtime"; +import type { EnvironmentId } from "@t3tools/contracts"; +import { resolveRemoteWebSocketConnectionUrl } from "@t3tools/shared/remote"; +import * as Arr from "effect/Array"; +import * as Order from "effect/Order"; +import * as Option from "effect/Option"; +import { pipe } from "effect/Function"; +import { Atom } from "effect/unstable/reactivity"; +import { type SavedRemoteConnection, bootstrapRemoteConnection } from "../lib/connection"; +import { terminalDebugLog } from "../features/terminal/terminalDebugLog"; +import { clearSavedConnection, loadSavedConnections, saveConnection } from "../lib/storage"; +import { appAtomRegistry } from "./atom-registry"; +import { + drainEnvironmentSessions, + notifyEnvironmentConnectionListeners, + removeEnvironmentSession, + setEnvironmentSession, +} from "./environment-session-registry"; +import { type ConnectedEnvironmentSummary } from "./remote-runtime-types"; +import { + invalidateSourceControlDiscoveryForEnvironment, + resetSourceControlDiscoveryState, +} from "./use-source-control-discovery"; +import { environmentRuntimeManager, useEnvironmentRuntimeStates } from "./use-environment-runtime"; +import { shellSnapshotManager } from "./use-shell-snapshot"; +import { subscribeTerminalMetadata, terminalSessionManager } from "./use-terminal-session"; + +const terminalMetadataUnsubscribers = new Map void>(); + +interface RemoteEnvironmentLocalState { + readonly isLoadingSavedConnection: boolean; + readonly connectionPairingUrl: string; + readonly pendingConnectionError: string | null; + readonly savedConnectionsById: Record; +} + +const isLoadingSavedConnectionAtom = Atom.make(true).pipe( + Atom.keepAlive, + Atom.withLabel("mobile:is-loading-saved-connection"), +); + +const connectionPairingUrlAtom = Atom.make("").pipe( + Atom.keepAlive, + Atom.withLabel("mobile:connection-pairing-url"), +); + +const pendingConnectionErrorAtom = Atom.make(null).pipe( + Atom.keepAlive, + Atom.withLabel("mobile:pending-connection-error"), +); + +const savedConnectionsByIdAtom = Atom.make>({}).pipe( + Atom.keepAlive, + Atom.withLabel("mobile:saved-connections"), +); + +function getSavedConnectionsById(): Record { + return appAtomRegistry.get(savedConnectionsByIdAtom); +} + +function setIsLoadingSavedConnection(value: boolean): void { + appAtomRegistry.set(isLoadingSavedConnectionAtom, value); +} + +function setConnectionPairingUrl(pairingUrl: string): void { + appAtomRegistry.set(connectionPairingUrlAtom, pairingUrl); +} + +function clearConnectionPairingUrl(): void { + appAtomRegistry.set(connectionPairingUrlAtom, ""); +} + +export function setPendingConnectionError(message: string | null): void { + appAtomRegistry.set(pendingConnectionErrorAtom, message); +} + +function clearPendingConnectionError(): void { + appAtomRegistry.set(pendingConnectionErrorAtom, null); +} + +function replaceSavedConnections(connections: Record): void { + appAtomRegistry.set(savedConnectionsByIdAtom, connections); +} + +function upsertSavedConnection(connection: SavedRemoteConnection): void { + const current = appAtomRegistry.get(savedConnectionsByIdAtom); + appAtomRegistry.set(savedConnectionsByIdAtom, { + ...current, + [connection.environmentId]: connection, + }); +} + +function removeSavedConnection(environmentId: EnvironmentId): void { + const current = appAtomRegistry.get(savedConnectionsByIdAtom); + const next = { ...current }; + delete next[environmentId]; + appAtomRegistry.set(savedConnectionsByIdAtom, next); +} + +function useRemoteEnvironmentLocalState(): RemoteEnvironmentLocalState { + const isLoadingSavedConnection = useAtomValue(isLoadingSavedConnectionAtom); + const connectionPairingUrl = useAtomValue(connectionPairingUrlAtom); + const pendingConnectionError = useAtomValue(pendingConnectionErrorAtom); + const savedConnectionsById = useAtomValue(savedConnectionsByIdAtom); + + return useMemo( + () => ({ + isLoadingSavedConnection, + connectionPairingUrl, + pendingConnectionError, + savedConnectionsById, + }), + [connectionPairingUrl, isLoadingSavedConnection, pendingConnectionError, savedConnectionsById], + ); +} + +function setEnvironmentConnectionStatus( + environmentId: EnvironmentId, + state: ConnectedEnvironmentSummary["connectionState"], + error?: string | null, +) { + environmentRuntimeManager.patch({ environmentId }, (current) => ({ + ...current, + connectionState: state, + connectionError: error === undefined ? current.connectionError : error, + })); +} + +export async function disconnectEnvironment( + environmentId: EnvironmentId, + options?: { readonly removeSaved?: boolean }, +) { + const session = removeEnvironmentSession(environmentId); + notifyEnvironmentConnectionListeners(); + await session?.connection.dispose(); + terminalMetadataUnsubscribers.get(environmentId)?.(); + terminalMetadataUnsubscribers.delete(environmentId); + shellSnapshotManager.invalidate({ environmentId }); + invalidateSourceControlDiscoveryForEnvironment(environmentId); + terminalSessionManager.invalidateEnvironment(environmentId); + environmentRuntimeManager.invalidate({ environmentId }); + + if (options?.removeSaved) { + await clearSavedConnection(environmentId); + removeSavedConnection(environmentId); + } +} + +export async function connectSavedEnvironment( + connection: SavedRemoteConnection, + options?: { readonly persist?: boolean }, +) { + await disconnectEnvironment(connection.environmentId); + + if (options?.persist !== false) { + await saveConnection(connection); + } + + upsertSavedConnection(connection); + setEnvironmentConnectionStatus(connection.environmentId, "connecting", null); + shellSnapshotManager.markPending({ environmentId: connection.environmentId }); + + const transport = new WsTransport( + () => + resolveRemoteWebSocketConnectionUrl({ + wsBaseUrl: connection.wsBaseUrl, + httpBaseUrl: connection.httpBaseUrl, + bearerToken: connection.bearerToken, + }), + { + onAttempt: () => { + environmentRuntimeManager.patch({ environmentId: connection.environmentId }, (previous) => { + const nextState = + previous.connectionState === "ready" || + previous.connectionState === "reconnecting" || + previous.connectionState === "disconnected" + ? "reconnecting" + : "connecting"; + return { + ...previous, + connectionState: nextState, + connectionError: null, + }; + }); + }, + onError: (message) => { + setEnvironmentConnectionStatus(connection.environmentId, "disconnected", message); + }, + onClose: (details) => { + const reason = + details.reason.trim().length > 0 + ? details.reason + : details.code === 1000 + ? null + : `Remote connection closed (${details.code}).`; + setEnvironmentConnectionStatus(connection.environmentId, "disconnected", reason); + }, + }, + ); + + const client = createWsRpcClient(transport); + const environmentConnection = createEnvironmentConnection({ + kind: "saved", + knownEnvironment: { + ...createKnownEnvironment({ + id: connection.environmentId, + label: connection.environmentLabel, + source: "manual", + target: { + httpBaseUrl: connection.httpBaseUrl, + wsBaseUrl: connection.wsBaseUrl, + }, + }), + environmentId: connection.environmentId, + }, + client, + applyShellEvent: (event, environmentId) => { + shellSnapshotManager.applyEvent({ environmentId }, event); + }, + syncShellSnapshot: (snapshot, environmentId) => { + shellSnapshotManager.syncSnapshot({ environmentId }, snapshot); + environmentRuntimeManager.patch({ environmentId }, (runtime) => ({ + ...runtime, + connectionState: "ready", + connectionError: null, + })); + }, + onShellResubscribe: (environmentId) => { + shellSnapshotManager.markPending({ environmentId }); + }, + onConfigSnapshot: (serverConfig) => { + environmentRuntimeManager.patch({ environmentId: connection.environmentId }, (runtime) => ({ + ...runtime, + serverConfig, + })); + }, + }); + + setEnvironmentSession(connection.environmentId, { + client, + connection: environmentConnection, + }); + terminalMetadataUnsubscribers.set( + connection.environmentId, + subscribeTerminalMetadata({ + environmentId: connection.environmentId, + client, + }), + ); + terminalDebugLog("registry:terminal-metadata-subscribed", { + environmentId: connection.environmentId, + }); + notifyEnvironmentConnectionListeners(); + + try { + await environmentConnection.ensureBootstrapped(); + } catch (error) { + setEnvironmentConnectionStatus( + connection.environmentId, + "disconnected", + error instanceof Error ? error.message : "Failed to bootstrap remote connection.", + ); + } +} + +const environmentsSortOrder = Order.make( + (left, right) => left.environmentLabel.localeCompare(right.environmentLabel) as -1 | 0 | 1, +); + +function deriveConnectedEnvironments( + savedConnectionsById: Record, + environmentStateById: Record, +): ReadonlyArray { + return Arr.sort( + Object.values(savedConnectionsById).map((connection) => { + const runtime = environmentStateById[connection.environmentId]; + return { + environmentId: connection.environmentId, + environmentLabel: connection.environmentLabel, + displayUrl: connection.displayUrl, + connectionState: runtime?.connectionState ?? "idle", + connectionError: runtime?.connectionError ?? null, + }; + }), + environmentsSortOrder, + ); +} + +export function useRemoteEnvironmentBootstrap() { + useEffect(() => { + let cancelled = false; + + void loadSavedConnections() + .then((connections) => { + if (cancelled) { + return; + } + + replaceSavedConnections( + Object.fromEntries( + connections.map((connection) => [connection.environmentId, connection]), + ), + ); + + setIsLoadingSavedConnection(false); + + void Promise.all( + connections.map((connection) => + connectSavedEnvironment(connection, { + persist: false, + }), + ), + ); + }) + .catch(() => { + if (!cancelled) { + setIsLoadingSavedConnection(false); + } + }); + + return () => { + cancelled = true; + for (const session of drainEnvironmentSessions()) { + void session.connection.dispose(); + } + for (const unsubscribe of terminalMetadataUnsubscribers.values()) { + unsubscribe(); + } + terminalMetadataUnsubscribers.clear(); + environmentRuntimeManager.invalidate(); + shellSnapshotManager.invalidate(); + resetSourceControlDiscoveryState(); + terminalSessionManager.invalidate(); + notifyEnvironmentConnectionListeners(); + }; + }, []); +} + +export function useRemoteEnvironmentState() { + const state = useRemoteEnvironmentLocalState(); + const environmentStateById = useEnvironmentRuntimeStates( + Object.values(state.savedConnectionsById).map((connection) => connection.environmentId), + ); + + return useMemo( + () => ({ + ...state, + environmentStateById, + }), + [environmentStateById, state], + ); +} + +export function useRemoteConnectionStatus() { + const { environmentStateById, pendingConnectionError, savedConnectionsById } = + useRemoteEnvironmentState(); + + const connectedEnvironments = useMemo( + () => deriveConnectedEnvironments(savedConnectionsById, environmentStateById), + [environmentStateById, savedConnectionsById], + ); + + const connectionState = useMemo(() => { + if (connectedEnvironments.length === 0) { + return "idle"; + } + if (connectedEnvironments.some((environment) => environment.connectionState === "ready")) { + return "ready"; + } + if ( + connectedEnvironments.some((environment) => environment.connectionState === "reconnecting") + ) { + return "reconnecting"; + } + if (connectedEnvironments.some((environment) => environment.connectionState === "connecting")) { + return "connecting"; + } + return "disconnected"; + }, [connectedEnvironments]); + + const connectionError = useMemo( + () => + pipe( + Arr.appendAll( + [pendingConnectionError], + Arr.map(connectedEnvironments, (environment) => environment.connectionError), + ), + Arr.findFirst((value) => value !== null), + Option.getOrNull, + ), + [connectedEnvironments, pendingConnectionError], + ); + + return { + connectedEnvironments, + connectionState, + connectionError, + }; +} + +export function useRemoteConnections() { + const { connectionPairingUrl } = useRemoteEnvironmentState(); + const { connectedEnvironments, connectionError, connectionState } = useRemoteConnectionStatus(); + + const onConnectPress = useCallback( + async (pairingUrl?: string) => { + try { + const nextPairingUrl = pairingUrl ?? connectionPairingUrl; + const connection = await bootstrapRemoteConnection({ pairingUrl: nextPairingUrl }); + clearPendingConnectionError(); + await connectSavedEnvironment(connection); + clearConnectionPairingUrl(); + } catch (error) { + setPendingConnectionError( + error instanceof Error ? error.message : "Failed to pair with the environment.", + ); + throw error; + } + }, + [connectionPairingUrl], + ); + + const onUpdateEnvironment = useCallback( + async ( + environmentId: EnvironmentId, + updates: { readonly label: string; readonly displayUrl: string }, + ) => { + const connection = getSavedConnectionsById()[environmentId]; + if (!connection) { + return; + } + + const updated: SavedRemoteConnection = { + ...connection, + environmentLabel: updates.label.trim() || connection.environmentLabel, + displayUrl: updates.displayUrl.trim() || connection.displayUrl, + }; + + await saveConnection(updated); + upsertSavedConnection(updated); + }, + [], + ); + + const onReconnectEnvironment = useCallback((environmentId: EnvironmentId) => { + const connection = getSavedConnectionsById()[environmentId]; + if (!connection) { + return; + } + void connectSavedEnvironment(connection, { persist: false }); + }, []); + + const onRemoveEnvironmentPress = useCallback((environmentId: EnvironmentId) => { + const connection = getSavedConnectionsById()[environmentId]; + if (!connection) { + return; + } + + Alert.alert( + "Remove environment?", + `Disconnect and forget ${connection.environmentLabel} on this device.`, + [ + { text: "Cancel", style: "cancel" }, + { + text: "Remove", + style: "destructive", + onPress: () => { + void disconnectEnvironment(environmentId, { removeSaved: true }); + }, + }, + ], + ); + }, []); + + return { + connectionPairingUrl, + connectionState, + connectionError, + connectedEnvironments, + connectedEnvironmentCount: connectedEnvironments.length, + onChangeConnectionPairingUrl: setConnectionPairingUrl, + onConnectPress, + onReconnectEnvironment, + onUpdateEnvironment, + onRemoveEnvironmentPress, + }; +} diff --git a/apps/mobile/src/state/use-selected-thread-commands.ts b/apps/mobile/src/state/use-selected-thread-commands.ts new file mode 100644 index 00000000000..a28d33c65d1 --- /dev/null +++ b/apps/mobile/src/state/use-selected-thread-commands.ts @@ -0,0 +1,187 @@ +import { useCallback } from "react"; + +import { + CommandId, + type ModelSelection, + type ProviderInteractionMode, + type RuntimeMode, +} from "@t3tools/contracts"; + +import { uuidv4 } from "../lib/uuid"; +import { environmentRuntimeManager } from "./use-environment-runtime"; +import { getEnvironmentClient } from "./environment-session-registry"; +import { useRemoteEnvironmentState } from "./use-remote-environment-registry"; +import { useThreadSelection } from "./use-thread-selection"; + +export function useSelectedThreadCommands(input: { + readonly refreshSelectedThreadGitStatus: (options?: { + readonly quiet?: boolean; + readonly cwd?: string | null; + }) => Promise; +}) { + const { refreshSelectedThreadGitStatus } = input; + const { selectedThread } = useThreadSelection(); + const { savedConnectionsById } = useRemoteEnvironmentState(); + + const onRefresh = useCallback(async () => { + const targets = selectedThread + ? [selectedThread.environmentId] + : Object.values(savedConnectionsById).map((connection) => connection.environmentId); + + await Promise.all( + targets.map(async (environmentId) => { + const client = getEnvironmentClient(environmentId); + if (!client) { + return; + } + + try { + const serverConfig = await client.server.getConfig(); + environmentRuntimeManager.patch({ environmentId }, (current) => ({ + ...current, + serverConfig, + connectionError: null, + })); + } catch (error) { + environmentRuntimeManager.patch({ environmentId }, (current) => ({ + ...current, + connectionError: + error instanceof Error ? error.message : "Failed to refresh remote data.", + })); + } + }), + ); + + if (selectedThread) { + await refreshSelectedThreadGitStatus({ quiet: true }); + } + }, [refreshSelectedThreadGitStatus, savedConnectionsById, selectedThread]); + + const onUpdateThreadModelSelection = useCallback( + async (modelSelection: ModelSelection) => { + if (!selectedThread) { + return; + } + + const client = getEnvironmentClient(selectedThread.environmentId); + if (!client) { + return; + } + + await client.orchestration.dispatchCommand({ + type: "thread.meta.update", + commandId: CommandId.make(uuidv4()), + threadId: selectedThread.id, + modelSelection, + }); + }, + [selectedThread], + ); + + const onUpdateThreadRuntimeMode = useCallback( + async (runtimeMode: RuntimeMode) => { + if (!selectedThread) { + return; + } + + const client = getEnvironmentClient(selectedThread.environmentId); + if (!client) { + return; + } + + await client.orchestration.dispatchCommand({ + type: "thread.runtime-mode.set", + commandId: CommandId.make(uuidv4()), + threadId: selectedThread.id, + runtimeMode, + createdAt: new Date().toISOString(), + }); + }, + [selectedThread], + ); + + const onUpdateThreadInteractionMode = useCallback( + async (interactionMode: ProviderInteractionMode) => { + if (!selectedThread) { + return; + } + + const client = getEnvironmentClient(selectedThread.environmentId); + if (!client) { + return; + } + + await client.orchestration.dispatchCommand({ + type: "thread.interaction-mode.set", + commandId: CommandId.make(uuidv4()), + threadId: selectedThread.id, + interactionMode, + createdAt: new Date().toISOString(), + }); + }, + [selectedThread], + ); + + const onStopThread = useCallback(async () => { + if (!selectedThread) { + return; + } + + const client = getEnvironmentClient(selectedThread.environmentId); + if (!client) { + return; + } + + if ( + selectedThread.session?.status !== "running" && + selectedThread.session?.status !== "starting" + ) { + return; + } + + await client.orchestration.dispatchCommand({ + type: "thread.turn.interrupt", + commandId: CommandId.make(uuidv4()), + threadId: selectedThread.id, + ...(selectedThread.session?.activeTurnId + ? { turnId: selectedThread.session.activeTurnId } + : {}), + createdAt: new Date().toISOString(), + }); + }, [selectedThread]); + + const onRenameThread = useCallback( + async (title: string) => { + if (!selectedThread) { + return; + } + + const client = getEnvironmentClient(selectedThread.environmentId); + if (!client) { + return; + } + + const trimmed = title.trim(); + if (!trimmed || trimmed === selectedThread.title) { + return; + } + + await client.orchestration.dispatchCommand({ + type: "thread.meta.update", + commandId: CommandId.make(uuidv4()), + threadId: selectedThread.id, + title: trimmed, + }); + }, + [selectedThread], + ); + + return { + onRefresh, + onUpdateThreadModelSelection, + onUpdateThreadRuntimeMode, + onUpdateThreadInteractionMode, + onRenameThread, + onStopThread, + }; +} diff --git a/apps/mobile/src/state/use-selected-thread-git-actions.ts b/apps/mobile/src/state/use-selected-thread-git-actions.ts new file mode 100644 index 00000000000..18860935f36 --- /dev/null +++ b/apps/mobile/src/state/use-selected-thread-git-actions.ts @@ -0,0 +1,341 @@ +import { useCallback, useEffect } from "react"; + +import { + EnvironmentScopedProjectShell, + EnvironmentScopedThreadShell, + type VcsRef, + type GitActionRequestInput, +} from "@t3tools/client-runtime"; +import { CommandId, type GitRunStackedActionResult } from "@t3tools/contracts"; +import { + dedupeRemoteBranchesWithLocalMatches, + sanitizeFeatureBranchName, +} from "@t3tools/shared/git"; + +import { uuidv4 } from "../lib/uuid"; +import { getEnvironmentClient } from "./environment-session-registry"; +import { setPendingConnectionError } from "./use-remote-environment-registry"; +import { vcsActionManager, showGitActionResult } from "./use-vcs-action-state"; +import { vcsRefManager } from "./use-vcs-refs"; +import { vcsStatusManager } from "./use-vcs-status"; +import { useThreadSelection } from "./use-thread-selection"; +import { useSelectedThreadWorktree } from "./use-selected-thread-worktree"; + +export function useSelectedThreadGitActions() { + const { selectedThread, selectedThreadProject } = useThreadSelection(); + const { selectedThreadCwd, selectedThreadWorktreePath } = useSelectedThreadWorktree(); + + const selectedThreadGitRootCwd = selectedThreadProject?.workspaceRoot ?? null; + + const updateThreadGitContext = useCallback( + async ( + thread: NonNullable, + nextState: { + readonly branch?: string | null; + readonly worktreePath?: string | null; + }, + ) => { + const client = getEnvironmentClient(thread.environmentId); + if (!client) { + return; + } + + await client.orchestration.dispatchCommand({ + type: "thread.meta.update", + commandId: CommandId.make(uuidv4()), + threadId: thread.id, + ...(nextState.branch !== undefined ? { branch: nextState.branch } : {}), + ...(nextState.worktreePath !== undefined ? { worktreePath: nextState.worktreePath } : {}), + }); + }, + [], + ); + + const refreshSelectedThreadGitStatus = useCallback( + async (options?: { readonly quiet?: boolean; readonly cwd?: string | null }) => { + if (!selectedThread || !selectedThreadProject) { + return null; + } + + const cwd = options?.cwd ?? selectedThreadCwd; + if (!cwd) { + return null; + } + + try { + const client = getEnvironmentClient(selectedThread.environmentId); + if (!client) { + return null; + } + + const status = await vcsActionManager.refreshStatus( + { environmentId: selectedThread.environmentId, cwd }, + { ...client.vcs, runChangeRequest: client.git.runStackedAction }, + options, + ); + setPendingConnectionError(null); + return status; + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to refresh git status."; + setPendingConnectionError(message); + return null; + } + }, + [selectedThread, selectedThreadCwd, selectedThreadProject], + ); + + useEffect(() => { + if (!selectedThread || !selectedThreadProject) { + return; + } + + void refreshSelectedThreadGitStatus({ quiet: true }); + }, [refreshSelectedThreadGitStatus, selectedThread, selectedThreadProject]); + + const runSelectedThreadGitMutation = useCallback( + async ( + operation: (input: { + readonly thread: EnvironmentScopedThreadShell; + readonly project: EnvironmentScopedProjectShell; + readonly cwd: string; + }) => Promise, + ): Promise => { + if (!selectedThread || !selectedThreadProject) { + return null; + } + + const cwd = selectedThreadCwd; + if (!cwd) { + return null; + } + + try { + setPendingConnectionError(null); + return await operation({ + thread: selectedThread, + project: selectedThreadProject, + cwd, + }); + } catch (error) { + const message = error instanceof Error ? error.message : "Git action failed."; + setPendingConnectionError(message); + showGitActionResult({ type: "error", title: "Git action failed", description: message }); + return null; + } + }, + [selectedThread, selectedThreadCwd, selectedThreadProject], + ); + + const refreshSelectedThreadBranches = useCallback(async (): Promise> => { + if (!selectedThread || !selectedThreadProject || !selectedThreadGitRootCwd) { + return []; + } + + const client = getEnvironmentClient(selectedThread.environmentId); + if (!client) { + return []; + } + + try { + const result = await vcsRefManager.load( + { environmentId: selectedThread.environmentId, cwd: selectedThreadGitRootCwd, query: null }, + client.vcs, + { limit: 100 }, + ); + return dedupeRemoteBranchesWithLocalMatches(result?.refs ?? []).filter( + (branch) => !branch.isRemote, + ); + } catch (error) { + setPendingConnectionError( + error instanceof Error ? error.message : "Failed to load branches.", + ); + return []; + } + }, [selectedThread, selectedThreadGitRootCwd, selectedThreadProject]); + + const syncSelectedThreadBranchState = useCallback( + async (input: { + readonly thread: EnvironmentScopedThreadShell; + readonly cwd: string; + readonly branchRootCwd?: string | null; + readonly nextThreadState?: { + readonly branch?: string | null; + readonly worktreePath?: string | null; + }; + }) => { + if (input.nextThreadState) { + await updateThreadGitContext(input.thread, input.nextThreadState); + } + + const branchRootCwd = input.branchRootCwd ?? selectedThreadProject?.workspaceRoot ?? null; + if (branchRootCwd) { + vcsRefManager.invalidate({ + environmentId: input.thread.environmentId, + cwd: branchRootCwd, + query: null, + }); + await refreshSelectedThreadBranches(); + } + + await refreshSelectedThreadGitStatus({ quiet: true, cwd: input.cwd }); + }, + [ + refreshSelectedThreadBranches, + refreshSelectedThreadGitStatus, + selectedThreadProject?.workspaceRoot, + updateThreadGitContext, + ], + ); + + const onCheckoutSelectedThreadBranch = useCallback( + async (branch: string) => { + await runSelectedThreadGitMutation(async ({ thread, cwd }) => { + const result = await vcsActionManager.switchRef( + { environmentId: thread.environmentId, cwd }, + { refName: branch }, + ); + await syncSelectedThreadBranchState({ + thread, + cwd, + nextThreadState: { + branch: result?.refName ?? thread.branch, + worktreePath: selectedThreadWorktreePath, + }, + }); + }); + }, + [runSelectedThreadGitMutation, selectedThreadWorktreePath, syncSelectedThreadBranchState], + ); + + const onCreateSelectedThreadBranch = useCallback( + async (branch: string) => { + await runSelectedThreadGitMutation(async ({ thread, cwd }) => { + const result = await vcsActionManager.createRef( + { environmentId: thread.environmentId, cwd }, + { + refName: branch, + switchRef: true, + }, + ); + await syncSelectedThreadBranchState({ + thread, + cwd, + nextThreadState: { + branch: result?.refName ?? thread.branch, + worktreePath: selectedThreadWorktreePath, + }, + }); + }); + }, + [runSelectedThreadGitMutation, selectedThreadWorktreePath, syncSelectedThreadBranchState], + ); + + const onCreateSelectedThreadWorktree = useCallback( + async (nextWorktree: { readonly baseBranch: string; readonly newBranch: string }) => { + await runSelectedThreadGitMutation(async ({ thread, project }) => { + const result = await vcsActionManager.createWorktree( + { environmentId: thread.environmentId, cwd: project.workspaceRoot }, + { + refName: nextWorktree.baseBranch, + newRefName: sanitizeFeatureBranchName(nextWorktree.newBranch), + path: null, + }, + ); + if (!result) { + return; + } + + await syncSelectedThreadBranchState({ + thread, + cwd: result.worktree.path, + branchRootCwd: project.workspaceRoot, + nextThreadState: { + branch: result.worktree.refName, + worktreePath: result.worktree.path, + }, + }); + }); + }, + [runSelectedThreadGitMutation, syncSelectedThreadBranchState], + ); + + const onPullSelectedThreadBranch = useCallback(async () => { + await runSelectedThreadGitMutation(async ({ thread, cwd }) => { + const result = await vcsActionManager.pull({ environmentId: thread.environmentId, cwd }); + await refreshSelectedThreadGitStatus({ quiet: true, cwd }); + if (result) { + showGitActionResult({ + type: "success", + title: + result.status === "skipped_up_to_date" + ? "Already up to date" + : `Pulled latest on ${result.refName}`, + }); + } + }); + }, [refreshSelectedThreadGitStatus, runSelectedThreadGitMutation]); + + const onRunSelectedThreadGitAction = useCallback( + async (input: GitActionRequestInput): Promise => { + return await runSelectedThreadGitMutation(async ({ thread, cwd }) => { + const result = await vcsActionManager.runChangeRequest( + { environmentId: thread.environmentId, cwd }, + { + actionId: uuidv4(), + action: input.action, + ...(input.commitMessage ? { commitMessage: input.commitMessage } : {}), + ...(input.featureBranch ? { featureBranch: input.featureBranch } : {}), + ...(input.filePaths?.length ? { filePaths: [...input.filePaths] } : {}), + }, + { + gitStatus: vcsStatusManager.getSnapshot({ + environmentId: thread.environmentId, + cwd, + }).data, + }, + ); + if (!result) { + return null; + } + + showGitActionResult({ + type: "success", + title: result.toast.title, + description: result.toast.description, + prUrl: result.toast.cta.kind === "open_pr" ? result.toast.cta.url : undefined, + }); + + if (result.branch.status === "created" && result.branch.name) { + await syncSelectedThreadBranchState({ + thread, + cwd, + nextThreadState: { + branch: result.branch.name, + worktreePath: selectedThreadWorktreePath, + }, + }); + return result; + } + + await refreshSelectedThreadGitStatus({ quiet: true, cwd }); + return result; + }); + }, + [ + refreshSelectedThreadGitStatus, + runSelectedThreadGitMutation, + selectedThreadWorktreePath, + syncSelectedThreadBranchState, + ], + ); + + return { + refreshSelectedThreadGitStatus, + refreshSelectedThreadBranches, + onCheckoutSelectedThreadBranch, + onCreateSelectedThreadBranch, + onCreateSelectedThreadWorktree, + onPullSelectedThreadBranch, + onRunSelectedThreadGitAction, + }; +} diff --git a/apps/mobile/src/state/use-selected-thread-git-state.ts b/apps/mobile/src/state/use-selected-thread-git-state.ts new file mode 100644 index 00000000000..6c855a3ebf7 --- /dev/null +++ b/apps/mobile/src/state/use-selected-thread-git-state.ts @@ -0,0 +1,48 @@ +import { useMemo } from "react"; + +import { dedupeRemoteBranchesWithLocalMatches } from "@t3tools/shared/git"; + +import { useVcsActionState } from "./use-vcs-action-state"; +import { useVcsRefs } from "./use-vcs-refs"; +import { useSourceControlDiscovery } from "./use-source-control-discovery"; +import { useThreadSelection } from "./use-thread-selection"; +import { useSelectedThreadWorktree } from "./use-selected-thread-worktree"; + +export function useSelectedThreadGitState() { + const { selectedThread, selectedThreadProject } = useThreadSelection(); + const { selectedThreadCwd } = useSelectedThreadWorktree(); + + const selectedThreadGitTarget = useMemo( + () => ({ + environmentId: selectedThread?.environmentId ?? null, + cwd: selectedThreadCwd, + }), + [selectedThread?.environmentId, selectedThreadCwd], + ); + const gitActionState = useVcsActionState(selectedThreadGitTarget); + const sourceControlDiscovery = useSourceControlDiscovery(selectedThread?.environmentId ?? null); + + const selectedThreadBranchTarget = useMemo( + () => ({ + environmentId: selectedThread?.environmentId ?? null, + cwd: selectedThreadProject?.workspaceRoot ?? null, + query: null, + }), + [selectedThread?.environmentId, selectedThreadProject?.workspaceRoot], + ); + const selectedThreadBranchState = useVcsRefs(selectedThreadBranchTarget); + const selectedThreadBranches = useMemo( + () => + dedupeRemoteBranchesWithLocalMatches(selectedThreadBranchState.data?.refs ?? []).filter( + (branch) => !branch.isRemote, + ), + [selectedThreadBranchState.data?.refs], + ); + + return { + gitOperationLabel: gitActionState.currentLabel, + sourceControlDiscovery, + selectedThreadBranches, + selectedThreadBranchesLoading: selectedThreadBranchState.isPending, + }; +} diff --git a/apps/mobile/src/state/use-selected-thread-requests.ts b/apps/mobile/src/state/use-selected-thread-requests.ts new file mode 100644 index 00000000000..232135b6a7e --- /dev/null +++ b/apps/mobile/src/state/use-selected-thread-requests.ts @@ -0,0 +1,176 @@ +import { useAtomValue } from "@effect/atom-react"; +import { useCallback, useMemo, useState } from "react"; + +import { ApprovalRequestId, CommandId, type ProviderApprovalDecision } from "@t3tools/contracts"; +import { Atom } from "effect/unstable/reactivity"; + +import { scopedRequestKey } from "../lib/scopedEntities"; +import { + buildPendingUserInputAnswers, + derivePendingApprovals, + derivePendingUserInputs, + setPendingUserInputCustomAnswer, + type PendingUserInputDraftAnswer, +} from "../lib/threadActivity"; +import { uuidv4 } from "../lib/uuid"; +import { appAtomRegistry } from "./atom-registry"; +import { getEnvironmentClient } from "./environment-session-registry"; +import { useSelectedThreadDetail } from "./use-thread-detail"; +import { useThreadSelection } from "./use-thread-selection"; + +const userInputDraftsByRequestKeyAtom = Atom.make< + Record> +>({}).pipe(Atom.keepAlive, Atom.withLabel("mobile:user-input-drafts")); + +function setUserInputDraftOption(requestKey: string, questionId: string, label: string): void { + const current = appAtomRegistry.get(userInputDraftsByRequestKeyAtom); + appAtomRegistry.set(userInputDraftsByRequestKeyAtom, { + ...current, + [requestKey]: { + ...current[requestKey], + [questionId]: { + selectedOptionLabel: label, + }, + }, + }); +} + +function setUserInputDraftCustomAnswer( + requestKey: string, + questionId: string, + customAnswer: string, +): void { + const current = appAtomRegistry.get(userInputDraftsByRequestKeyAtom); + appAtomRegistry.set(userInputDraftsByRequestKeyAtom, { + ...current, + [requestKey]: { + ...current[requestKey], + [questionId]: setPendingUserInputCustomAnswer( + current[requestKey]?.[questionId], + customAnswer, + ), + }, + }); +} + +export function useSelectedThreadRequests() { + const { selectedThread: selectedThreadShell } = useThreadSelection(); + const selectedThread = useSelectedThreadDetail(); + const userInputDraftsByRequestKey = useAtomValue(userInputDraftsByRequestKeyAtom); + const [respondingApprovalId, setRespondingApprovalId] = useState(null); + const [respondingUserInputId, setRespondingUserInputId] = useState( + null, + ); + + const activePendingApprovals = useMemo( + () => (selectedThread ? derivePendingApprovals(selectedThread.activities) : []), + [selectedThread], + ); + const activePendingApproval = activePendingApprovals[0] ?? null; + const activePendingUserInputs = useMemo( + () => (selectedThread ? derivePendingUserInputs(selectedThread.activities) : []), + [selectedThread], + ); + const activePendingUserInput = activePendingUserInputs[0] ?? null; + const activePendingUserInputDrafts = + activePendingUserInput && selectedThreadShell + ? (userInputDraftsByRequestKey[ + scopedRequestKey(selectedThreadShell.environmentId, activePendingUserInput.requestId) + ] ?? {}) + : {}; + const activePendingUserInputAnswers = activePendingUserInput + ? buildPendingUserInputAnswers(activePendingUserInput.questions, activePendingUserInputDrafts) + : null; + + const onSelectUserInputOption = useCallback( + (requestId: ApprovalRequestId, questionId: string, label: string) => { + if (!selectedThreadShell) { + return; + } + + const requestKey = scopedRequestKey(selectedThreadShell.environmentId, requestId); + setUserInputDraftOption(requestKey, questionId, label); + }, + [selectedThreadShell], + ); + + const onChangeUserInputCustomAnswer = useCallback( + (requestId: ApprovalRequestId, questionId: string, customAnswer: string) => { + if (!selectedThreadShell) { + return; + } + + const requestKey = scopedRequestKey(selectedThreadShell.environmentId, requestId); + setUserInputDraftCustomAnswer(requestKey, questionId, customAnswer); + }, + [selectedThreadShell], + ); + + const onRespondToApproval = useCallback( + async (requestId: ApprovalRequestId, decision: ProviderApprovalDecision) => { + if (!selectedThreadShell) { + return; + } + + const client = getEnvironmentClient(selectedThreadShell.environmentId); + if (!client) { + return; + } + + setRespondingApprovalId(requestId); + try { + await client.orchestration.dispatchCommand({ + type: "thread.approval.respond", + commandId: CommandId.make(uuidv4()), + threadId: selectedThreadShell.id, + requestId, + decision, + createdAt: new Date().toISOString(), + }); + } finally { + setRespondingApprovalId((current) => (current === requestId ? null : current)); + } + }, + [selectedThreadShell], + ); + + const onSubmitUserInput = useCallback(async () => { + if (!selectedThreadShell || !activePendingUserInput || !activePendingUserInputAnswers) { + return; + } + + const client = getEnvironmentClient(selectedThreadShell.environmentId); + if (!client) { + return; + } + + setRespondingUserInputId(activePendingUserInput.requestId); + try { + await client.orchestration.dispatchCommand({ + type: "thread.user-input.respond", + commandId: CommandId.make(uuidv4()), + threadId: selectedThreadShell.id, + requestId: activePendingUserInput.requestId, + answers: activePendingUserInputAnswers, + createdAt: new Date().toISOString(), + }); + } finally { + setRespondingUserInputId((current) => + current === activePendingUserInput.requestId ? null : current, + ); + } + }, [activePendingUserInput, activePendingUserInputAnswers, selectedThreadShell]); + + return { + activePendingApproval, + activePendingUserInput, + activePendingUserInputDrafts, + activePendingUserInputAnswers, + respondingApprovalId, + respondingUserInputId, + onRespondToApproval, + onSelectUserInputOption, + onChangeUserInputCustomAnswer, + onSubmitUserInput, + }; +} diff --git a/apps/mobile/src/state/use-selected-thread-worktree.ts b/apps/mobile/src/state/use-selected-thread-worktree.ts new file mode 100644 index 00000000000..c5046c5b135 --- /dev/null +++ b/apps/mobile/src/state/use-selected-thread-worktree.ts @@ -0,0 +1,24 @@ +import { useMemo } from "react"; + +import { useSelectedThreadDetail } from "./use-thread-detail"; +import { useThreadSelection } from "./use-thread-selection"; +import { resolvePreferredThreadWorktreePath } from "../features/terminal/terminalLaunchContext"; + +export function useSelectedThreadWorktree() { + const { selectedThread, selectedThreadProject } = useThreadSelection(); + const selectedThreadDetail = useSelectedThreadDetail(); + + const selectedThreadWorktreePath = useMemo( + () => + resolvePreferredThreadWorktreePath({ + threadShellWorktreePath: selectedThread?.worktreePath ?? null, + threadDetailWorktreePath: selectedThreadDetail?.worktreePath ?? null, + }), + [selectedThread?.worktreePath, selectedThreadDetail?.worktreePath], + ); + + return { + selectedThreadWorktreePath, + selectedThreadCwd: selectedThreadWorktreePath ?? selectedThreadProject?.workspaceRoot ?? null, + }; +} diff --git a/apps/mobile/src/state/use-shell-snapshot.ts b/apps/mobile/src/state/use-shell-snapshot.ts new file mode 100644 index 00000000000..ed7c75eeee4 --- /dev/null +++ b/apps/mobile/src/state/use-shell-snapshot.ts @@ -0,0 +1,74 @@ +import * as Arr from "effect/Array"; +import * as Order from "effect/Order"; +import { useAtomValue } from "@effect/atom-react"; +import { + EMPTY_SHELL_SNAPSHOT_ATOM, + EMPTY_SHELL_SNAPSHOT_STATE, + createShellSnapshotManager, + getShellSnapshotTargetKey, + shellSnapshotStateAtom, + type ShellSnapshotState, +} from "@t3tools/client-runtime"; +import type { EnvironmentId } from "@t3tools/contracts"; +import { useCallback, useMemo, useRef, useSyncExternalStore } from "react"; + +import { appAtomRegistry } from "./atom-registry"; + +export const shellSnapshotManager = createShellSnapshotManager({ + getRegistry: () => appAtomRegistry, +}); + +export function useShellSnapshot(environmentId: EnvironmentId | null): ShellSnapshotState { + const targetKey = getShellSnapshotTargetKey({ environmentId }); + const state = useAtomValue( + targetKey !== null ? shellSnapshotStateAtom(targetKey) : EMPTY_SHELL_SNAPSHOT_ATOM, + ); + return targetKey === null ? EMPTY_SHELL_SNAPSHOT_STATE : state; +} + +export function useShellSnapshotStates( + environmentIds: ReadonlyArray, +): Readonly> { + const stableEnvironmentIds = useMemo( + () => Arr.sort(new Set(environmentIds), Order.String), + [environmentIds], + ); + const snapshotCacheRef = useRef>>({}); + + const subscribe = useCallback( + (onStoreChange: () => void) => { + const unsubs = stableEnvironmentIds.map((environmentId) => + appAtomRegistry.subscribe(shellSnapshotStateAtom(environmentId), onStoreChange), + ); + return () => { + for (const unsub of unsubs) { + unsub(); + } + }; + }, + [stableEnvironmentIds], + ); + + const getSnapshot = useCallback(() => { + const previous = snapshotCacheRef.current; + let hasChanged = Object.keys(previous).length !== stableEnvironmentIds.length; + const next: Record = {}; + + for (const environmentId of stableEnvironmentIds) { + const snapshot = shellSnapshotManager.getSnapshot({ environmentId }); + next[environmentId] = snapshot; + if (!hasChanged && previous[environmentId] !== snapshot) { + hasChanged = true; + } + } + + if (!hasChanged) { + return previous; + } + + snapshotCacheRef.current = next; + return next; + }, [stableEnvironmentIds]); + + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); +} diff --git a/apps/mobile/src/state/use-source-control-discovery.ts b/apps/mobile/src/state/use-source-control-discovery.ts new file mode 100644 index 00000000000..8f206be2cee --- /dev/null +++ b/apps/mobile/src/state/use-source-control-discovery.ts @@ -0,0 +1,78 @@ +import { useAtomValue } from "@effect/atom-react"; +import { + EMPTY_SOURCE_CONTROL_DISCOVERY_ATOM, + EMPTY_SOURCE_CONTROL_DISCOVERY_STATE, + type SourceControlDiscoveryClient, + type SourceControlDiscoveryState, + type SourceControlDiscoveryTarget, + createSourceControlDiscoveryManager, + getSourceControlDiscoveryTargetKey, + sourceControlDiscoveryStateAtom, +} from "@t3tools/client-runtime"; +import type { EnvironmentId, SourceControlDiscoveryResult } from "@t3tools/contracts"; +import { useEffect, useMemo } from "react"; + +import { appAtomRegistry } from "./atom-registry"; +import { + getEnvironmentClient, + subscribeEnvironmentConnections, +} from "./environment-session-registry"; + +const sourceControlDiscoveryManager = createSourceControlDiscoveryManager({ + getRegistry: () => appAtomRegistry, + getClient: (environmentId) => getEnvironmentClient(environmentId)?.server ?? null, + subscribeClientChanges: subscribeEnvironmentConnections, +}); + +function sourceControlDiscoveryTargetForEnvironment( + environmentId: EnvironmentId | null, +): SourceControlDiscoveryTarget { + return { key: environmentId ?? null }; +} + +export function refreshSourceControlDiscoveryForEnvironment( + environmentId: EnvironmentId | null, + client?: SourceControlDiscoveryClient | null, +): Promise { + return sourceControlDiscoveryManager.refresh( + sourceControlDiscoveryTargetForEnvironment(environmentId), + client ?? undefined, + ); +} + +export function invalidateSourceControlDiscoveryForEnvironment( + environmentId: EnvironmentId | null, +): void { + sourceControlDiscoveryManager.invalidate( + sourceControlDiscoveryTargetForEnvironment(environmentId), + ); +} + +export function resetSourceControlDiscoveryState(): void { + sourceControlDiscoveryManager.reset(); +} + +export function resetSourceControlDiscoveryStateForTests(): void { + resetSourceControlDiscoveryState(); +} + +export function useSourceControlDiscovery( + environmentId: EnvironmentId | null, +): SourceControlDiscoveryState { + const target = useMemo( + () => sourceControlDiscoveryTargetForEnvironment(environmentId), + [environmentId], + ); + + useEffect(() => { + return sourceControlDiscoveryManager.watch(target); + }, [target]); + + const targetKey = getSourceControlDiscoveryTargetKey(target); + const state = useAtomValue( + targetKey !== null + ? sourceControlDiscoveryStateAtom(targetKey) + : EMPTY_SOURCE_CONTROL_DISCOVERY_ATOM, + ); + return targetKey === null ? EMPTY_SOURCE_CONTROL_DISCOVERY_STATE : state; +} diff --git a/apps/mobile/src/state/use-terminal-session.ts b/apps/mobile/src/state/use-terminal-session.ts new file mode 100644 index 00000000000..9ea13eef3e3 --- /dev/null +++ b/apps/mobile/src/state/use-terminal-session.ts @@ -0,0 +1,84 @@ +import { useAtomValue } from "@effect/atom-react"; +import { + createTerminalSessionManager, + EMPTY_KNOWN_TERMINAL_SESSIONS_ATOM, + EMPTY_TERMINAL_SESSION_ATOM, + getKnownTerminalSessionTarget, + getKnownTerminalSessionListFilter, + knownTerminalSessionsAtom, + terminalSessionStateAtom, + type TerminalSessionTarget, + type TerminalSessionState, +} from "@t3tools/client-runtime"; +import type { + EnvironmentId, + TerminalAttachInput, + TerminalAttachStreamEvent, + TerminalMetadataStreamEvent, + TerminalSessionSnapshot, +} from "@t3tools/contracts"; +import { useMemo } from "react"; + +import { appAtomRegistry } from "./atom-registry"; + +export const terminalSessionManager = createTerminalSessionManager({ + getRegistry: () => appAtomRegistry, +}); + +export function subscribeTerminalMetadata(input: { + readonly environmentId: EnvironmentId; + readonly client: { + readonly terminal: { + readonly onMetadata: ( + listener: (event: TerminalMetadataStreamEvent) => void, + options?: { readonly onResubscribe?: () => void }, + ) => () => void; + }; + }; +}) { + return terminalSessionManager.subscribeMetadata(input); +} + +export function attachTerminalSession(input: { + readonly environmentId: EnvironmentId; + readonly client: Parameters[0]["client"]; + readonly terminal: TerminalAttachInput; + readonly onSnapshot?: (snapshot: TerminalSessionSnapshot) => void; + readonly onEvent?: (event: TerminalAttachStreamEvent) => void; +}) { + return terminalSessionManager.attach({ + environmentId: input.environmentId, + client: input.client, + terminal: input.terminal, + ...(input.onSnapshot ? { onSnapshot: input.onSnapshot } : {}), + ...(input.onEvent ? { onEvent: input.onEvent } : {}), + }); +} + +export function useTerminalSession(input: TerminalSessionTarget): TerminalSessionState { + const target = getKnownTerminalSessionTarget(input); + return useAtomValue( + target !== null ? terminalSessionStateAtom(target) : EMPTY_TERMINAL_SESSION_ATOM, + ); +} + +export function useTerminalSessionTarget(input: TerminalSessionTarget) { + return useMemo( + () => ({ + environmentId: input.environmentId, + threadId: input.threadId, + terminalId: input.terminalId, + }), + [input.environmentId, input.threadId, input.terminalId], + ); +} + +export function useKnownTerminalSessions(input: { + readonly environmentId: TerminalSessionTarget["environmentId"]; + readonly threadId: TerminalSessionTarget["threadId"]; +}) { + const filter = getKnownTerminalSessionListFilter(input); + return useAtomValue( + filter !== null ? knownTerminalSessionsAtom(filter) : EMPTY_KNOWN_TERMINAL_SESSIONS_ATOM, + ); +} diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts new file mode 100644 index 00000000000..4f1d4ce9655 --- /dev/null +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -0,0 +1,469 @@ +import { useAtomValue } from "@effect/atom-react"; +import { useCallback, useEffect, useMemo } from "react"; + +import { EnvironmentScopedThreadShell } from "@t3tools/client-runtime"; +import { CommandId, MessageId, type EnvironmentId, type ThreadId } from "@t3tools/contracts"; +import { deriveActiveWorkStartedAt } from "@t3tools/shared/orchestrationTiming"; +import { Atom } from "effect/unstable/reactivity"; + +import { + convertPastedImagesToAttachments, + pasteComposerClipboard, + pickComposerImages, +} from "../lib/composerImages"; +import type { DraftComposerImageAttachment } from "../lib/composerImages"; +import { scopedThreadKey } from "../lib/scopedEntities"; +import { buildThreadFeed, type QueuedThreadMessage } from "../lib/threadActivity"; +import { uuidv4 } from "../lib/uuid"; +import { appAtomRegistry } from "../state/atom-registry"; +import { getEnvironmentClient } from "./environment-session-registry"; +import type { ConnectedEnvironmentSummary } from "../state/remote-runtime-types"; +import { + setPendingConnectionError, + useRemoteConnectionStatus, +} from "../state/use-remote-environment-registry"; +import { useRemoteCatalog } from "../state/use-remote-catalog"; +import { useSelectedThreadDetail } from "../state/use-thread-detail"; +import { useThreadSelection } from "../state/use-thread-selection"; + +const draftMessageByThreadKeyAtom = Atom.make>({}).pipe( + Atom.keepAlive, + Atom.withLabel("mobile:thread-composer:draft-message"), +); + +const draftAttachmentsByThreadKeyAtom = Atom.make< + Record> +>({}).pipe(Atom.keepAlive, Atom.withLabel("mobile:thread-composer:draft-attachments")); + +const dispatchingQueuedMessageIdAtom = Atom.make(null).pipe( + Atom.keepAlive, + Atom.withLabel("mobile:thread-composer:dispatching-message-id"), +); + +const queuedMessagesByThreadKeyAtom = Atom.make>>( + {}, +).pipe(Atom.keepAlive, Atom.withLabel("mobile:thread-composer:queued-messages")); + +function setDraftMessage(threadKey: string, value: string): void { + const current = appAtomRegistry.get(draftMessageByThreadKeyAtom); + appAtomRegistry.set(draftMessageByThreadKeyAtom, { + ...current, + [threadKey]: value, + }); +} + +function appendDraftAttachments( + threadKey: string, + attachments: ReadonlyArray, +): void { + const current = appAtomRegistry.get(draftAttachmentsByThreadKeyAtom); + appAtomRegistry.set(draftAttachmentsByThreadKeyAtom, { + ...current, + [threadKey]: [...(current[threadKey] ?? []), ...attachments], + }); +} + +function appendDraftMessage(threadKey: string, value: string): void { + const current = appAtomRegistry.get(draftMessageByThreadKeyAtom); + appAtomRegistry.set(draftMessageByThreadKeyAtom, { + ...current, + [threadKey]: `${current[threadKey] ?? ""}${value}`, + }); +} + +export function appendReviewCommentToDraft(input: { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; + readonly text: string; + readonly attachments?: ReadonlyArray; +}): void { + const threadKey = scopedThreadKey(input.environmentId, input.threadId); + const current = appAtomRegistry.get(draftMessageByThreadKeyAtom); + const existing = current[threadKey] ?? ""; + const separator = existing.trim().length > 0 && !existing.endsWith("\n") ? "\n\n" : ""; + appAtomRegistry.set(draftMessageByThreadKeyAtom, { + ...current, + [threadKey]: `${existing}${separator}${input.text}`, + }); + if (input.attachments && input.attachments.length > 0) { + appendDraftAttachments(threadKey, input.attachments); + } +} + +export function useThreadDraftForThread(input: { + readonly environmentId?: EnvironmentId; + readonly threadId?: ThreadId; +}) { + const draftMessageByThreadKey = useAtomValue(draftMessageByThreadKeyAtom); + const draftAttachmentsByThreadKey = useAtomValue(draftAttachmentsByThreadKeyAtom); + const threadKey = + input.environmentId && input.threadId + ? scopedThreadKey(input.environmentId, input.threadId) + : null; + + return { + draftMessage: threadKey ? (draftMessageByThreadKey[threadKey] ?? "") : "", + draftAttachments: threadKey ? (draftAttachmentsByThreadKey[threadKey] ?? []) : [], + }; +} + +function clearDraft(threadKey: string): void { + const draftMessages = appAtomRegistry.get(draftMessageByThreadKeyAtom); + const draftAttachments = appAtomRegistry.get(draftAttachmentsByThreadKeyAtom); + appAtomRegistry.set(draftMessageByThreadKeyAtom, { + ...draftMessages, + [threadKey]: "", + }); + appAtomRegistry.set(draftAttachmentsByThreadKeyAtom, { + ...draftAttachments, + [threadKey]: [], + }); +} + +function removeDraftImage(threadKey: string, imageId: string): void { + const current = appAtomRegistry.get(draftAttachmentsByThreadKeyAtom); + appAtomRegistry.set(draftAttachmentsByThreadKeyAtom, { + ...current, + [threadKey]: (current[threadKey] ?? []).filter((image) => image.id !== imageId), + }); +} + +function beginDispatchingQueuedMessage(queuedMessageId: MessageId): void { + appAtomRegistry.set(dispatchingQueuedMessageIdAtom, queuedMessageId); +} + +function finishDispatchingQueuedMessage(queuedMessageId: MessageId): void { + const current = appAtomRegistry.get(dispatchingQueuedMessageIdAtom); + appAtomRegistry.set(dispatchingQueuedMessageIdAtom, current === queuedMessageId ? null : current); +} + +function enqueueQueuedMessage(message: QueuedThreadMessage): void { + const current = appAtomRegistry.get(queuedMessagesByThreadKeyAtom); + const threadKey = scopedThreadKey(message.environmentId, message.threadId); + appAtomRegistry.set(queuedMessagesByThreadKeyAtom, { + ...current, + [threadKey]: [...(current[threadKey] ?? []), message], + }); +} + +function removeQueuedMessage( + environmentId: EnvironmentId, + threadId: ThreadId, + queuedMessageId: MessageId, +): void { + const current = appAtomRegistry.get(queuedMessagesByThreadKeyAtom); + const threadKey = scopedThreadKey(environmentId, threadId); + const existing = current[threadKey]; + if (!existing) { + return; + } + + const nextQueue = existing.filter((entry) => entry.messageId !== queuedMessageId); + const next = { ...current }; + if (nextQueue.length === 0) { + delete next[threadKey]; + } else { + next[threadKey] = nextQueue; + } + + appAtomRegistry.set(queuedMessagesByThreadKeyAtom, next); +} + +function useQueueDrain(input: { + readonly dispatchingQueuedMessageId: MessageId | null; + readonly queuedMessagesByThreadKey: Record>; + readonly threads: ReadonlyArray; + readonly environments: ReadonlyArray; + readonly sendQueuedMessage: (message: QueuedThreadMessage) => Promise; +}) { + const { + dispatchingQueuedMessageId, + environments, + queuedMessagesByThreadKey, + sendQueuedMessage, + threads, + } = input; + + useEffect(() => { + if (dispatchingQueuedMessageId !== null) { + return; + } + + for (const [threadKey, queuedMessages] of Object.entries(queuedMessagesByThreadKey)) { + const nextQueuedMessage = queuedMessages[0]; + if (!nextQueuedMessage) { + continue; + } + + const thread = threads.find( + (candidate) => scopedThreadKey(candidate.environmentId, candidate.id) === threadKey, + ); + if (!thread) { + continue; + } + + const environment = environments.find( + (candidate) => candidate.environmentId === nextQueuedMessage.environmentId, + ); + if (!environment || environment.connectionState !== "ready") { + continue; + } + + const threadStatus = thread.session?.status; + if (threadStatus === "running" || threadStatus === "starting") { + continue; + } + + void sendQueuedMessage(nextQueuedMessage); + return; + } + }, [ + dispatchingQueuedMessageId, + environments, + queuedMessagesByThreadKey, + sendQueuedMessage, + threads, + ]); +} + +export function useThreadComposerState() { + const { connectedEnvironments } = useRemoteConnectionStatus(); + const { threads } = useRemoteCatalog(); + const { selectedThread: selectedThreadShell } = useThreadSelection(); + const selectedThread = useSelectedThreadDetail(); + const draftMessageByThreadKey = useAtomValue(draftMessageByThreadKeyAtom); + const draftAttachmentsByThreadKey = useAtomValue(draftAttachmentsByThreadKeyAtom); + const dispatchingQueuedMessageId = useAtomValue(dispatchingQueuedMessageIdAtom); + const queuedMessagesByThreadKey = useAtomValue(queuedMessagesByThreadKeyAtom); + + const selectedThreadKey = selectedThreadShell + ? scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id) + : null; + const selectedThreadQueuedMessages = useMemo( + () => (selectedThreadKey ? (queuedMessagesByThreadKey[selectedThreadKey] ?? []) : []), + [queuedMessagesByThreadKey, selectedThreadKey], + ); + + const selectedThreadFeed = useMemo( + () => + selectedThread + ? buildThreadFeed(selectedThread, selectedThreadQueuedMessages, dispatchingQueuedMessageId) + : [], + [dispatchingQueuedMessageId, selectedThread, selectedThreadQueuedMessages], + ); + + const draftMessage = selectedThreadKey ? (draftMessageByThreadKey[selectedThreadKey] ?? "") : ""; + const draftAttachments = selectedThreadKey + ? (draftAttachmentsByThreadKey[selectedThreadKey] ?? []) + : []; + const selectedThreadQueueCount = selectedThreadQueuedMessages.length; + + const selectedThreadSessionActivity = useMemo(() => { + if (!selectedThread?.session) { + return null; + } + + return { + orchestrationStatus: selectedThread.session.status, + activeTurnId: selectedThread.session.activeTurnId ?? undefined, + }; + }, [selectedThread]); + + const queuedSendStartedAt = selectedThreadQueuedMessages[0]?.createdAt ?? null; + const activeWorkStartedAt = useMemo(() => { + if (!selectedThread) { + return null; + } + + return deriveActiveWorkStartedAt( + selectedThread.latestTurn, + selectedThreadSessionActivity, + queuedSendStartedAt, + ); + }, [queuedSendStartedAt, selectedThread, selectedThreadSessionActivity]); + + const activeThreadBusy = + !!selectedThread && + (selectedThread.session?.status === "running" || selectedThread.session?.status === "starting"); + + const sendQueuedMessage = useCallback( + async (queuedMessage: QueuedThreadMessage) => { + const client = getEnvironmentClient(queuedMessage.environmentId); + const thread = threads.find( + (candidate) => + candidate.environmentId === queuedMessage.environmentId && + candidate.id === queuedMessage.threadId, + ); + if (!client || !thread) { + return; + } + + beginDispatchingQueuedMessage(queuedMessage.messageId); + try { + await client.orchestration.dispatchCommand({ + type: "thread.turn.start", + commandId: queuedMessage.commandId, + threadId: queuedMessage.threadId, + message: { + messageId: queuedMessage.messageId, + role: "user", + text: queuedMessage.text, + attachments: queuedMessage.attachments, + }, + runtimeMode: thread.runtimeMode, + interactionMode: thread.interactionMode, + createdAt: queuedMessage.createdAt, + }); + + removeQueuedMessage( + queuedMessage.environmentId, + queuedMessage.threadId, + queuedMessage.messageId, + ); + } catch (error) { + removeQueuedMessage( + queuedMessage.environmentId, + queuedMessage.threadId, + queuedMessage.messageId, + ); + setPendingConnectionError( + error instanceof Error ? error.message : "Failed to send message.", + ); + } finally { + finishDispatchingQueuedMessage(queuedMessage.messageId); + } + }, + [threads], + ); + + useQueueDrain({ + dispatchingQueuedMessageId, + queuedMessagesByThreadKey, + threads, + environments: connectedEnvironments, + sendQueuedMessage, + }); + + const onSendMessage = useCallback(() => { + if (!selectedThreadShell) { + return; + } + + const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); + const text = (draftMessageByThreadKey[threadKey] ?? "").trim(); + const attachments = draftAttachmentsByThreadKey[threadKey] ?? []; + if (text.length === 0 && attachments.length === 0) { + return; + } + + const createdAt = new Date().toISOString(); + enqueueQueuedMessage({ + environmentId: selectedThreadShell.environmentId, + threadId: selectedThreadShell.id, + messageId: MessageId.make(uuidv4()), + commandId: CommandId.make(uuidv4()), + text, + attachments, + createdAt, + }); + clearDraft(threadKey); + }, [draftAttachmentsByThreadKey, draftMessageByThreadKey, selectedThreadShell]); + + const onChangeDraftMessage = useCallback( + (value: string) => { + if (!selectedThreadShell) { + return; + } + + const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); + setDraftMessage(threadKey, value); + }, + [selectedThreadShell], + ); + + const onPickDraftImages = useCallback(async () => { + if (!selectedThreadShell) { + return; + } + + const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); + const result = await pickComposerImages({ + existingCount: draftAttachmentsByThreadKey[threadKey]?.length ?? 0, + }); + if (result.images.length > 0) { + appendDraftAttachments(threadKey, result.images); + } + if (result.error) { + setPendingConnectionError(result.error); + } + }, [draftAttachmentsByThreadKey, selectedThreadShell]); + + const onPasteIntoDraft = useCallback(async () => { + if (!selectedThreadShell) { + return; + } + + const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); + const result = await pasteComposerClipboard({ + existingCount: draftAttachmentsByThreadKey[threadKey]?.length ?? 0, + }); + if (result.images.length > 0) { + appendDraftAttachments(threadKey, result.images); + } + if (result.text) { + appendDraftMessage(threadKey, result.text); + } + if (result.error) { + setPendingConnectionError(result.error); + } + }, [draftAttachmentsByThreadKey, selectedThreadShell]); + + const onNativePasteImages = useCallback( + async (uris: ReadonlyArray) => { + if (!selectedThreadShell || uris.length === 0) { + return; + } + + const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); + try { + const images = await convertPastedImagesToAttachments({ + uris, + existingCount: draftAttachmentsByThreadKey[threadKey]?.length ?? 0, + }); + if (images.length > 0) { + appendDraftAttachments(threadKey, images); + } + } catch (error) { + console.error("[native paste] error converting images", error); + } + }, + [draftAttachmentsByThreadKey, selectedThreadShell], + ); + + const onRemoveDraftImage = useCallback( + (imageId: string) => { + if (!selectedThreadShell) { + return; + } + + const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); + removeDraftImage(threadKey, imageId); + }, + [selectedThreadShell], + ); + + return { + selectedThreadFeed, + selectedThreadQueueCount, + activeWorkStartedAt, + draftMessage, + draftAttachments, + activeThreadBusy, + onChangeDraftMessage, + onPickDraftImages, + onPasteIntoDraft, + onNativePasteImages, + onRemoveDraftImage, + onSendMessage, + }; +} diff --git a/apps/mobile/src/state/use-thread-detail.ts b/apps/mobile/src/state/use-thread-detail.ts new file mode 100644 index 00000000000..900dbd648b5 --- /dev/null +++ b/apps/mobile/src/state/use-thread-detail.ts @@ -0,0 +1,82 @@ +import { useAtomValue } from "@effect/atom-react"; +import { + EMPTY_THREAD_DETAIL_ATOM, + EMPTY_THREAD_DETAIL_STATE, + createThreadDetailManager, + getThreadDetailTargetKey, + threadDetailStateAtom, + type ThreadDetailState, + type ThreadDetailTarget, +} from "@t3tools/client-runtime"; +import { useEffect, useMemo } from "react"; + +import { derivePendingApprovals, derivePendingUserInputs } from "../lib/threadActivity"; +import { appAtomRegistry } from "./atom-registry"; +import { + getEnvironmentClient, + subscribeEnvironmentConnections, +} from "./environment-session-registry"; +import { useThreadSelection } from "./use-thread-selection"; + +function shouldKeepThreadDetailWarm(state: ThreadDetailState): boolean { + const thread = state.data; + if (!thread || state.isDeleted) { + return false; + } + + if (thread.latestTurn?.sourceProposedPlan) { + return true; + } + + const sessionStatus = thread.session?.status; + if (sessionStatus && sessionStatus !== "idle" && sessionStatus !== "stopped") { + return true; + } + + return ( + derivePendingApprovals(thread.activities).length > 0 || + derivePendingUserInputs(thread.activities).length > 0 + ); +} + +const threadDetailManager = createThreadDetailManager({ + getRegistry: () => appAtomRegistry, + getClient: (environmentId) => { + const client = getEnvironmentClient(environmentId); + return client ? client.orchestration : null; + }, + getClientIdentity: (environmentId) => { + return getEnvironmentClient(environmentId) ? environmentId : null; + }, + subscribeClientChanges: subscribeEnvironmentConnections, + retention: { + idleTtlMs: 5 * 60 * 1_000, + maxRetainedEntries: 24, + shouldKeepWarm: (_target, state) => shouldKeepThreadDetailWarm(state), + }, +}); + +export function useThreadDetail(target: ThreadDetailTarget): ThreadDetailState { + const { environmentId, threadId } = target; + const targetKey = getThreadDetailTargetKey(target); + + useEffect( + () => threadDetailManager.watch({ environmentId, threadId }), + [environmentId, threadId], + ); + + const state = useAtomValue( + targetKey !== null ? threadDetailStateAtom(targetKey) : EMPTY_THREAD_DETAIL_ATOM, + ); + return targetKey === null ? EMPTY_THREAD_DETAIL_STATE : state; +} + +export function useSelectedThreadDetail() { + const { selectedThread } = useThreadSelection(); + const state = useThreadDetail({ + environmentId: selectedThread?.environmentId ?? null, + threadId: selectedThread?.id ?? null, + }); + + return useMemo(() => state.data, [state.data]); +} diff --git a/apps/mobile/src/state/use-thread-selection.ts b/apps/mobile/src/state/use-thread-selection.ts new file mode 100644 index 00000000000..c303faed617 --- /dev/null +++ b/apps/mobile/src/state/use-thread-selection.ts @@ -0,0 +1,95 @@ +import { useLocalSearchParams } from "expo-router"; +import { useMemo } from "react"; +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; + +import { EnvironmentScopedThreadShell } from "@t3tools/client-runtime"; +import { EnvironmentScopedProjectShell } from "@t3tools/client-runtime"; +import { useRemoteCatalog } from "./use-remote-catalog"; +import { useRemoteEnvironmentState } from "./use-remote-environment-registry"; + +function firstRouteParam(value: string | string[] | undefined): string | null { + if (Array.isArray(value)) { + return value[0] ?? null; + } + + return value ?? null; +} + +function deriveSelectedThread( + selectedThreadRef: { readonly environmentId: EnvironmentId; readonly threadId: ThreadId } | null, + threads: ReadonlyArray, +): EnvironmentScopedThreadShell | null { + if (!selectedThreadRef) { + return null; + } + + return ( + threads.find( + (thread) => + thread.environmentId === selectedThreadRef.environmentId && + thread.id === selectedThreadRef.threadId, + ) ?? null + ); +} + +function deriveSelectedThreadProject( + selectedThread: EnvironmentScopedThreadShell | null, + projects: ReadonlyArray, +): EnvironmentScopedProjectShell | null { + if (!selectedThread) { + return null; + } + + return ( + projects.find( + (project) => + project.environmentId === selectedThread.environmentId && + project.id === selectedThread.projectId, + ) ?? null + ); +} + +export function useThreadSelection() { + const { projects, threads } = useRemoteCatalog(); + const { environmentStateById, savedConnectionsById } = useRemoteEnvironmentState(); + const params = useLocalSearchParams<{ + environmentId?: string | string[]; + threadId?: string | string[]; + }>(); + const selectedThreadRef = useMemo(() => { + const environmentId = firstRouteParam(params.environmentId); + const threadId = firstRouteParam(params.threadId); + if (!environmentId || !threadId) { + return null; + } + + return { + environmentId: EnvironmentId.make(environmentId), + threadId: ThreadId.make(threadId), + }; + }, [params.environmentId, params.threadId]); + const selectedThread = useMemo( + () => deriveSelectedThread(selectedThreadRef, threads), + [selectedThreadRef, threads], + ); + + const selectedThreadProject = useMemo( + () => deriveSelectedThreadProject(selectedThread, projects), + [projects, selectedThread], + ); + + const selectedEnvironmentConnection = selectedThread + ? (savedConnectionsById[selectedThread.environmentId] ?? null) + : null; + const selectedEnvironmentRuntime = selectedThread + ? (environmentStateById[selectedThread.environmentId] ?? null) + : null; + + return { + selectedThreadRef, + selectedThread, + selectedThreadProject, + selectedEnvironmentConnection, + selectedEnvironmentRuntime, + }; +} diff --git a/apps/mobile/src/state/use-vcs-action-state.ts b/apps/mobile/src/state/use-vcs-action-state.ts new file mode 100644 index 00000000000..64e4da958ef --- /dev/null +++ b/apps/mobile/src/state/use-vcs-action-state.ts @@ -0,0 +1,162 @@ +import { useAtomValue } from "@effect/atom-react"; +import { + type VcsActionState, + type VcsActionTarget, + EMPTY_VCS_ACTION_ATOM, + EMPTY_VCS_ACTION_STATE, + createVcsActionManager, + getVcsActionTargetKey, + vcsActionStateAtom, +} from "@t3tools/client-runtime"; +import { useCallback, useEffect, useRef, useState } from "react"; + +import { uuidv4 } from "../lib/uuid"; +import { appAtomRegistry } from "./atom-registry"; +import { getEnvironmentClient } from "./environment-session-registry"; + +export const vcsActionManager = createVcsActionManager({ + getRegistry: () => appAtomRegistry, + getClient: (environmentId) => { + const client = getEnvironmentClient(environmentId); + return client ? { ...client.vcs, runChangeRequest: client.git.runStackedAction } : null; + }, + getActionId: uuidv4, +}); + +export function useVcsActionState(target: VcsActionTarget): VcsActionState { + const targetKey = getVcsActionTargetKey(target); + const state = useAtomValue( + targetKey !== null ? vcsActionStateAtom(targetKey) : EMPTY_VCS_ACTION_ATOM, + ); + return targetKey === null ? EMPTY_VCS_ACTION_STATE : state; +} + +// --------------------------------------------------------------------------- +// Git action result notification +// --------------------------------------------------------------------------- + +export interface GitActionResultNotification { + readonly type: "success" | "error"; + readonly title: string; + readonly description?: string; + readonly prUrl?: string; +} + +const RESULT_DISMISS_MS = 5_000; + +type ResultListener = (result: GitActionResultNotification | null) => void; +const resultListeners = new Set(); +let currentResult: GitActionResultNotification | null = null; +let dismissTimer: ReturnType | null = null; + +function broadcast(result: GitActionResultNotification | null): void { + currentResult = result; + for (const listener of resultListeners) { + listener(result); + } +} + +export function showGitActionResult(result: GitActionResultNotification): void { + if (dismissTimer) clearTimeout(dismissTimer); + broadcast(result); + dismissTimer = setTimeout(() => broadcast(null), RESULT_DISMISS_MS); +} + +export function dismissGitActionResult(): void { + if (dismissTimer) clearTimeout(dismissTimer); + broadcast(null); +} + +export function useGitActionResultNotification(): { + readonly result: GitActionResultNotification | null; + readonly dismiss: () => void; +} { + const [result, setResult] = useState(currentResult); + + useEffect(() => { + resultListeners.add(setResult); + setResult(currentResult); + return () => { + resultListeners.delete(setResult); + }; + }, []); + + return { result, dismiss: dismissGitActionResult }; +} + +// --------------------------------------------------------------------------- +// Unified git action progress (combines running state + result notification) +// --------------------------------------------------------------------------- + +export type GitActionProgressPhase = "idle" | "running" | "success" | "error"; + +export interface GitActionProgress { + readonly phase: GitActionProgressPhase; + readonly label: string | null; + readonly description: string | null; + readonly prUrl?: string; +} + +const EMPTY_PROGRESS: GitActionProgress = { + phase: "idle", + label: null, + description: null, +}; + +function formatElapsedSeconds(ms: number | null): string | null { + if (ms === null) return null; + const elapsed = Math.max(0, Math.floor((Date.now() - ms) / 1000)); + if (elapsed < 2) return null; + return `Running for ${elapsed}s`; +} + +export function useGitActionProgress(target: VcsActionTarget): GitActionProgress { + const actionState = useVcsActionState(target); + const { result } = useGitActionResultNotification(); + + const [, forceUpdate] = useState(0); + const intervalRef = useRef | null>(null); + + const startElapsedTimer = useCallback(() => { + if (intervalRef.current) return; + intervalRef.current = setInterval(() => forceUpdate((n) => n + 1), 1000); + }, []); + + const stopElapsedTimer = useCallback(() => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + }, []); + + useEffect(() => { + if (actionState.isRunning) { + startElapsedTimer(); + } else { + stopElapsedTimer(); + } + return stopElapsedTimer; + }, [actionState.isRunning, startElapsedTimer, stopElapsedTimer]); + + if (actionState.isRunning) { + const description = + actionState.lastOutputLine ?? + formatElapsedSeconds(actionState.hookStartedAtMs ?? actionState.phaseStartedAtMs); + return { + phase: "running", + label: actionState.currentLabel, + description, + }; + } + + if (result) { + return { + phase: result.type, + label: result.title, + description: result.description ?? null, + prUrl: result.prUrl, + }; + } + + return EMPTY_PROGRESS; +} diff --git a/apps/mobile/src/state/use-vcs-refs.ts b/apps/mobile/src/state/use-vcs-refs.ts new file mode 100644 index 00000000000..3af3a6e945e --- /dev/null +++ b/apps/mobile/src/state/use-vcs-refs.ts @@ -0,0 +1,51 @@ +import { useAtomValue } from "@effect/atom-react"; +import { useEffect, useMemo } from "react"; +import { + type VcsRefState, + type VcsRefTarget, + EMPTY_VCS_REF_ATOM, + EMPTY_VCS_REF_STATE, + createVcsRefManager, + getVcsRefTargetKey, + vcsRefStateAtom, +} from "@t3tools/client-runtime"; + +import { appAtomRegistry } from "./atom-registry"; +import { + getEnvironmentClient, + subscribeEnvironmentConnections, +} from "./environment-session-registry"; + +const VCS_REF_LIST_LIMIT = 100; +const VCS_REF_STALE_TIME_MS = 5_000; + +export const vcsRefManager = createVcsRefManager({ + getRegistry: () => appAtomRegistry, + getClient: (environmentId) => { + const client = getEnvironmentClient(environmentId); + return client ? client.vcs : null; + }, + subscribeClientChanges: subscribeEnvironmentConnections, + watchLimit: VCS_REF_LIST_LIMIT, + staleTimeMs: VCS_REF_STALE_TIME_MS, + onBackgroundError: (error) => { + console.warn("[vcs-refs] background refresh failed", error); + }, +}); + +export function useVcsRefs(target: VcsRefTarget): VcsRefState { + const stableTarget = useMemo( + () => ({ + environmentId: target.environmentId, + cwd: target.cwd, + query: target.query ?? null, + }), + [target.cwd, target.environmentId, target.query], + ); + const targetKey = getVcsRefTargetKey(stableTarget); + + useEffect(() => vcsRefManager.watch(stableTarget), [stableTarget]); + + const state = useAtomValue(targetKey !== null ? vcsRefStateAtom(targetKey) : EMPTY_VCS_REF_ATOM); + return targetKey === null ? EMPTY_VCS_REF_STATE : state; +} diff --git a/apps/mobile/src/state/use-vcs-status.ts b/apps/mobile/src/state/use-vcs-status.ts new file mode 100644 index 00000000000..e7d7049d332 --- /dev/null +++ b/apps/mobile/src/state/use-vcs-status.ts @@ -0,0 +1,62 @@ +import { useAtomValue } from "@effect/atom-react"; +import { + type VcsStatusState, + type VcsStatusTarget, + EMPTY_VCS_STATUS_ATOM, + EMPTY_VCS_STATUS_STATE, + createVcsStatusManager, + getVcsStatusTargetKey, + vcsStatusStateAtom, +} from "@t3tools/client-runtime"; +import { useEffect } from "react"; + +import { appAtomRegistry } from "./atom-registry"; +import { + getEnvironmentClient, + subscribeEnvironmentConnections, +} from "./environment-session-registry"; + +/** + * Singleton VCS status manager for the mobile app. + * + * Uses ref-counted `onStatus` subscriptions (one per unique cwd) + * rather than one-shot `refreshStatus` RPCs. Multiple threads + * sharing the same cwd (i.e. same project, no worktree) share + * a single WS subscription. + * + * `subscribeClientChanges` ensures subscriptions are established + * even when the WS connection isn't ready at mount time, and + * re-established on reconnection. + */ +export const vcsStatusManager = createVcsStatusManager({ + getRegistry: () => appAtomRegistry, + getClient: (environmentId) => { + const client = getEnvironmentClient(environmentId); + return client ? client.vcs : null; + }, + getClientIdentity: (environmentId) => { + return getEnvironmentClient(environmentId) ? environmentId : null; + }, + subscribeClientChanges: subscribeEnvironmentConnections, +}); + +/** + * Subscribe to live VCS status for a target (environmentId + cwd). + * + * Mirrors the web's `useVcsStatus` hook. Automatically subscribes + * on mount, ref-counts shared cwds, and unsubscribes on unmount. + * Returns reactive `VcsStatusState` via Effect atoms. + */ +export function useVcsStatus(target: VcsStatusTarget): VcsStatusState { + const targetKey = getVcsStatusTargetKey(target); + + useEffect( + () => vcsStatusManager.watch({ environmentId: target.environmentId, cwd: target.cwd }), + [target.environmentId, target.cwd], + ); + + const state = useAtomValue( + targetKey !== null ? vcsStatusStateAtom(targetKey) : EMPTY_VCS_STATUS_ATOM, + ); + return targetKey === null ? EMPTY_VCS_STATUS_STATE : state; +} diff --git a/apps/mobile/tsconfig.json b/apps/mobile/tsconfig.json new file mode 100644 index 00000000000..f3763c56543 --- /dev/null +++ b/apps/mobile/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "expo/tsconfig.base", + "compilerOptions": { + "allowImportingTsExtensions": true, + "strict": true + } +} diff --git a/apps/mobile/uniwind-types.d.ts b/apps/mobile/uniwind-types.d.ts new file mode 100644 index 00000000000..cc099419a9b --- /dev/null +++ b/apps/mobile/uniwind-types.d.ts @@ -0,0 +1,10 @@ +// NOTE: This file is generated by uniwind and it should not be edited manually. +/// + +declare module 'uniwind' { + export interface UniwindConfig { + themes: readonly ['light', 'dark'] + } +} + +export {} diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 1161ff6a7d7..18d93af250e 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -105,7 +105,6 @@ function isStalePendingApprovalFailureDetail(detail: string | null): boolean { detail.includes("unknown pending permission request") ); } - function derivePendingUserInputCountFromActivities( activities: ReadonlyArray, ): number { diff --git a/apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts index 1a13229fd8e..66a12e9e33b 100644 --- a/apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts @@ -53,12 +53,14 @@ describe("ProjectSetupScriptRunner", () => { Layer.provideMerge( Layer.succeed(TerminalManager, { open, + attachStream: () => Effect.die(new Error("unused")), write, resize: () => Effect.void, clear: () => Effect.void, restart: () => Effect.die(new Error("unused")), close: () => Effect.void, subscribe: () => Effect.succeed(() => undefined), + subscribeMetadata: () => Effect.succeed(() => undefined), }), ), ), @@ -91,6 +93,7 @@ describe("ProjectSetupScriptRunner", () => { history: "", exitCode: null, exitSignal: null, + label: "setup-setup", updatedAt: "2026-01-01T00:00:00.000Z", }), ); @@ -112,12 +115,14 @@ describe("ProjectSetupScriptRunner", () => { Layer.provideMerge( Layer.succeed(TerminalManager, { open, + attachStream: () => Effect.die(new Error("unused")), write, resize: () => Effect.void, clear: () => Effect.void, restart: () => Effect.die(new Error("unused")), close: () => Effect.void, subscribe: () => Effect.succeed(() => undefined), + subscribeMetadata: () => Effect.succeed(() => undefined), }), ), ), diff --git a/apps/server/src/review/ReviewService.test.ts b/apps/server/src/review/ReviewService.test.ts new file mode 100644 index 00000000000..eb8758b1282 --- /dev/null +++ b/apps/server/src/review/ReviewService.test.ts @@ -0,0 +1,76 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; + +import { ServerConfig } from "../config.ts"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; +import * as ReviewService from "./ReviewService.ts"; + +function makeLayer(input: { + readonly workspaceRoot: string; + readonly baseDir: string; + readonly detectCalls?: Array<{ readonly cwd: string }>; +}) { + return ReviewService.layer.pipe( + Layer.provide( + Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({ + get: () => Effect.die("unexpected VCS registry get"), + resolve: () => Effect.die("unexpected VCS registry resolve"), + detect: (request) => + Effect.sync(() => { + input.detectCalls?.push({ cwd: request.cwd }); + return null; + }), + }), + ), + Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})), + Layer.provide(ServerConfig.layerTest(input.workspaceRoot, input.baseDir)), + Layer.provideMerge(NodeServices.layer), + ); +} + +describe("ReviewService", () => { + it.effect("rejects diff preview cwd outside the configured workspace roots", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const workspaceRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-workspace-" }); + const outsideRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-outside-" }); + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-base-" }); + const detectCalls: Array<{ readonly cwd: string }> = []; + + const error = yield* Effect.gen(function* () { + const review = yield* ReviewService.ReviewService; + return yield* review.getDiffPreview({ cwd: outsideRoot }).pipe(Effect.flip); + }).pipe(Effect.provide(makeLayer({ workspaceRoot, baseDir, detectCalls }))); + + assert.strictEqual(error._tag, "VcsRepositoryDetectionError"); + assert.strictEqual(error.operation, "ReviewService.getDiffPreview"); + assert.match( + "detail" in error ? error.detail : "", + /must stay within the configured workspace root/, + ); + assert.deepStrictEqual(detectCalls, []); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("allows diff preview cwd inside the configured workspace root", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const workspaceRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-workspace-" }); + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-base-" }); + const detectCalls: Array<{ readonly cwd: string }> = []; + + const result = yield* Effect.gen(function* () { + const review = yield* ReviewService.ReviewService; + return yield* review.getDiffPreview({ cwd: workspaceRoot }); + }).pipe(Effect.provide(makeLayer({ workspaceRoot, baseDir, detectCalls }))); + + assert.strictEqual(result.cwd, workspaceRoot); + assert.deepStrictEqual(result.sources, []); + assert.deepStrictEqual(detectCalls, [{ cwd: workspaceRoot }]); + }).pipe(Effect.provide(NodeServices.layer)), + ); +}); diff --git a/apps/server/src/review/ReviewService.ts b/apps/server/src/review/ReviewService.ts new file mode 100644 index 00000000000..ddbad606e92 --- /dev/null +++ b/apps/server/src/review/ReviewService.ts @@ -0,0 +1,101 @@ +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; + +import { + VcsRepositoryDetectionError, + VcsUnsupportedOperationError, + type ReviewDiffPreviewError, + type ReviewDiffPreviewInput, + type ReviewDiffPreviewResult, +} from "@t3tools/contracts"; + +import { ServerConfig } from "../config.ts"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; + +export interface ReviewServiceShape { + readonly getDiffPreview: ( + input: ReviewDiffPreviewInput, + ) => Effect.Effect; +} + +export class ReviewService extends Context.Service()( + "t3/review/ReviewService", +) {} + +export const make = Effect.fn("makeReviewService")(function* () { + const config = yield* ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const vcsRegistry = yield* VcsDriverRegistry.VcsDriverRegistry; + const git = yield* GitVcsDriver.GitVcsDriver; + + const canonicalizePath = (value: string) => + fileSystem + .realPath(path.resolve(value)) + .pipe(Effect.catch(() => Effect.succeed(path.resolve(value)))); + + const isWithinRoot = (candidate: string, root: string) => { + const relative = path.relative(root, candidate); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); + }; + + const assertWorkspaceBoundCwd = Effect.fn("ReviewService.assertWorkspaceBoundCwd")(function* ( + cwd: string, + ) { + const [candidate, workspaceRoot, worktreesRoot] = yield* Effect.all([ + canonicalizePath(cwd), + canonicalizePath(config.cwd), + canonicalizePath(config.worktreesDir), + ]); + + if (isWithinRoot(candidate, workspaceRoot) || isWithinRoot(candidate, worktreesRoot)) { + return; + } + + return yield* new VcsRepositoryDetectionError({ + operation: "ReviewService.getDiffPreview", + cwd, + detail: "Review diff preview cwd must stay within the configured workspace root.", + }); + }); + + const getDiffPreview: ReviewServiceShape["getDiffPreview"] = Effect.fn( + "ReviewService.getDiffPreview", + )(function* (input) { + yield* assertWorkspaceBoundCwd(input.cwd); + + const handle = yield* vcsRegistry.detect({ cwd: input.cwd, requestedKind: "auto" }); + if (!handle) { + return { + cwd: input.cwd, + generatedAt: yield* DateTime.now, + sources: [], + }; + } + + const getDriverDiffPreview = handle.driver.getDiffPreview; + if (!getDriverDiffPreview) { + if (handle.kind === "git") { + return yield* git.getReviewDiffPreview(input); + } + return yield* new VcsUnsupportedOperationError({ + operation: "ReviewService.getDiffPreview", + kind: handle.kind, + detail: `The ${handle.kind} VCS driver does not support review diff previews.`, + }); + } + + return yield* getDriverDiffPreview(input); + }); + + return ReviewService.of({ + getDiffPreview, + }); +}); + +export const layer = Layer.effect(ReviewService, make()); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 8394897c48c..c8d7bc57a96 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -111,6 +111,7 @@ import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts"; import * as VcsDriverRegistry from "./vcs/VcsDriverRegistry.ts"; import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts"; import * as GitWorkflowService from "./git/GitWorkflowService.ts"; +import * as ReviewService from "./review/ReviewService.ts"; import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore.ts"; import { ServerAuthLive } from "./auth/Layers/ServerAuth.ts"; @@ -325,6 +326,7 @@ const buildAppUnderTest = (options?: { gitVcsDriver?: Partial; gitManager?: Partial; sourceControlRepositoryService?: Partial; + reviewService?: Partial; vcsStatusBroadcaster?: Partial; projectSetupScriptRunner?: Partial; terminalManager?: Partial; @@ -492,6 +494,14 @@ const buildAppUnderTest = (options?: { Layer.provideMerge(gitVcsDriverLayer), Layer.provideMerge(gitManagerLayer), ); + const reviewLayer = options?.layers?.reviewService + ? Layer.mock(ReviewService.ReviewService)({ + ...options.layers.reviewService, + }) + : ReviewService.layer.pipe( + Layer.provideMerge(gitVcsDriverLayer), + Layer.provide(vcsDriverRegistryLayer), + ); const vcsProvisioningLayer = VcsProvisioningService.layer.pipe( Layer.provide(vcsDriverRegistryLayer), ); @@ -593,6 +603,7 @@ const buildAppUnderTest = (options?: { Layer.provide(gitManagerLayer), Layer.provide(gitVcsDriverLayer), Layer.provide(gitWorkflowLayer), + Layer.provide(reviewLayer), Layer.provide(vcsProvisioningLayer), Layer.provide( Layer.mock(SourceControlRepositoryService.SourceControlRepositoryService)({ @@ -2568,6 +2579,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { it.effect("routes websocket rpc git methods", () => Effect.gen(function* () { yield* buildAppUnderTest({ + config: { + cwd: "/tmp/repo", + }, layers: { gitManager: { invalidateLocalStatus: () => Effect.void, @@ -2707,6 +2721,33 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }, vcsDriver: { isInsideWorkTree: () => Effect.succeed(true), + getDiffPreview: (input) => + Effect.succeed({ + cwd: input.cwd, + generatedAt: DateTime.nowUnsafe(), + sources: [ + { + id: "working-tree", + kind: "working-tree", + title: "Dirty worktree", + baseRef: "HEAD", + headRef: null, + diff: "dirty-diff", + diffHash: "hash-dirty", + truncated: false, + }, + { + id: "branch-range", + kind: "branch-range", + title: "Against main", + baseRef: "main", + headRef: "feature/demo", + diff: "base-diff", + diffHash: "hash-base", + truncated: false, + }, + ], + }), }, }, }); @@ -2814,6 +2855,13 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }), ), ); + + const diffPreview = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.reviewGetDiffPreview]({ cwd: "/tmp/repo" }), + ), + ); + assert.equal(diffPreview.sources[0]?.diff, "dirty-diff"); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); @@ -4253,6 +4301,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { history: "", exitCode: null, exitSignal: null, + label: "Primary", updatedAt: "2026-01-01T00:00:00.000Z", }; diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 813cdae7317..7f7321a4d1d 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -56,6 +56,7 @@ import * as VcsProcess from "./vcs/VcsProcess.ts"; import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts"; import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts"; import * as GitWorkflowService from "./git/GitWorkflowService.ts"; +import * as ReviewService from "./review/ReviewService.ts"; import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts"; import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; import { ProjectSetupScriptRunnerLive } from "./project/Layers/ProjectSetupScriptRunner.ts"; @@ -198,11 +199,17 @@ const SourceControlRepositoryServiceLayerLive = SourceControlRepositoryService.l Layer.provideMerge(SourceControlProviderRegistryLayerLive), ); +const ReviewLayerLive = ReviewService.layer.pipe( + Layer.provideMerge(GitVcsDriver.layer), + Layer.provideMerge(VcsDriverRegistryLayerLive), +); + const VcsLayerLive = Layer.empty.pipe( Layer.provideMerge(VcsProjectConfig.layer), Layer.provideMerge(VcsDriverRegistryLayerLive), Layer.provideMerge(VcsProvisioningService.layer.pipe(Layer.provide(VcsDriverRegistryLayerLive))), Layer.provideMerge(GitWorkflowLayerLive), + Layer.provideMerge(ReviewLayerLive), Layer.provideMerge(SourceControlRepositoryServiceLayerLive), Layer.provideMerge(VcsStatusBroadcaster.layer.pipe(Layer.provide(GitWorkflowLayerLive))), ); diff --git a/apps/server/src/terminal/Layers/Manager.test.ts b/apps/server/src/terminal/Layers/Manager.test.ts index 0cca0f64e19..24cffcfd9ee 100644 --- a/apps/server/src/terminal/Layers/Manager.test.ts +++ b/apps/server/src/terminal/Layers/Manager.test.ts @@ -2,7 +2,9 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import { DEFAULT_TERMINAL_ID, + type TerminalAttachStreamEvent, type TerminalEvent, + type TerminalMetadataStreamEvent, type TerminalOpenInput, type TerminalRestartInput, } from "@t3tools/contracts"; @@ -188,7 +190,10 @@ interface CreateManagerOptions { shellResolver?: () => string; platform?: NodeJS.Platform; env?: NodeJS.ProcessEnv; - subprocessChecker?: (terminalPid: number) => Effect.Effect; + subprocessInspector?: (terminalPid: number) => Effect.Effect<{ + readonly hasRunningSubprocess: boolean; + readonly childCommand: string | null; + }>; subprocessPollIntervalMs?: number; processKillGraceMs?: number; maxRetainedInactiveSessions?: number; @@ -225,8 +230,8 @@ const createManager = ( ...(options.shellResolver !== undefined ? { shellResolver: options.shellResolver } : {}), ...(options.platform !== undefined ? { platform: options.platform } : {}), ...(options.env !== undefined ? { env: options.env } : {}), - ...(options.subprocessChecker !== undefined - ? { subprocessChecker: options.subprocessChecker } + ...(options.subprocessInspector !== undefined + ? { subprocessInspector: options.subprocessInspector } : {}), ...(options.subprocessPollIntervalMs !== undefined ? { subprocessPollIntervalMs: options.subprocessPollIntervalMs } @@ -264,13 +269,119 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", ( const third = yield* manager.open(openInput()); assert.equal(first.threadId, "thread-1"); - assert.equal(first.terminalId, "default"); + assert.equal(first.terminalId, DEFAULT_TERMINAL_ID); assert.equal(second.threadId, "thread-1"); assert.equal(third.threadId, "thread-1"); expect(ptyAdapter.spawnInputs).toHaveLength(1); }), ); + it.effect("attaches to running sessions without restarting them", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(); + + yield* manager.open(openInput()); + const scope = yield* Effect.scope; + const attachEvents = yield* Ref.make>([]); + const unsubscribe = yield* manager.attachStream( + { + threadId: "thread-1", + terminalId: DEFAULT_TERMINAL_ID, + cols: 100, + rows: 40, + }, + (event) => Ref.update(attachEvents, (events) => [...events, event]), + ); + yield* Scope.addFinalizer(scope, Effect.sync(unsubscribe)); + + const snapshot = (yield* Ref.get(attachEvents)).find((event) => event.type === "snapshot"); + expect(snapshot).toBeDefined(); + if (!snapshot || snapshot.type !== "snapshot") return; + assert.equal(snapshot.snapshot.threadId, "thread-1"); + assert.equal(snapshot.snapshot.terminalId, DEFAULT_TERMINAL_ID); + expect(ptyAdapter.spawnInputs).toHaveLength(1); + }), + ); + + it.effect("attaches to exited sessions without restarting them", () => + Effect.gen(function* () { + const { manager, ptyAdapter, getEvents } = yield* createManager(); + + yield* manager.open(openInput()); + const process = ptyAdapter.processes[0]; + expect(process).toBeDefined(); + if (!process) return; + + process.emitExit({ exitCode: 0, signal: 0 }); + + yield* waitFor( + Effect.map(getEvents, (events) => events.some((event) => event.type === "exited")), + "1200 millis", + ); + + const scope = yield* Effect.scope; + const attachEvents = yield* Ref.make>([]); + const unsubscribe = yield* manager.attachStream( + openInput({ + env: { + T3CODE_WORKTREE_PATH: "/tmp/should-not-restart", + }, + worktreePath: "/tmp/should-not-restart", + }), + (event) => Ref.update(attachEvents, (events) => [...events, event]), + ); + yield* Scope.addFinalizer(scope, Effect.sync(unsubscribe)); + + const snapshot = (yield* Ref.get(attachEvents)).find((event) => event.type === "snapshot"); + expect(snapshot).toBeDefined(); + if (!snapshot || snapshot.type !== "snapshot") return; + assert.equal(snapshot.snapshot.status, "exited"); + assert.equal(snapshot.snapshot.worktreePath, null); + expect(ptyAdapter.spawnInputs).toHaveLength(1); + }), + ); + + it.effect("restarts inactive sessions from attach only when requested", () => + Effect.gen(function* () { + const { manager, ptyAdapter, getEvents } = yield* createManager(); + + yield* manager.open(openInput()); + const process = ptyAdapter.processes[0]; + expect(process).toBeDefined(); + if (!process) return; + + process.emitExit({ exitCode: 0, signal: 0 }); + + yield* waitFor( + Effect.map(getEvents, (events) => events.some((event) => event.type === "exited")), + "1200 millis", + ); + + const scope = yield* Effect.scope; + const attachEvents = yield* Ref.make>([]); + const unsubscribe = yield* manager.attachStream( + { + ...openInput({ + env: { + T3CODE_WORKTREE_PATH: "/tmp/restart-requested", + }, + worktreePath: "/tmp/restart-requested", + }), + restartIfNotRunning: true, + }, + (event) => Ref.update(attachEvents, (events) => [...events, event]), + ); + yield* Scope.addFinalizer(scope, Effect.sync(unsubscribe)); + + const snapshot = (yield* Ref.get(attachEvents)).find((event) => event.type === "snapshot"); + expect(snapshot).toBeDefined(); + if (!snapshot || snapshot.type !== "snapshot") return; + assert.equal(snapshot.snapshot.status, "running"); + assert.equal(snapshot.snapshot.worktreePath, "/tmp/restart-requested"); + expect(ptyAdapter.spawnInputs).toHaveLength(2); + }), + ); + const makeDirectory = (filePath: string) => Effect.flatMap(Effect.service(FileSystem.FileSystem), (fs) => fs.makeDirectory(filePath, { recursive: true }), @@ -407,7 +518,7 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", ( (event) => event.type === "cleared" && event.threadId === "thread-1" && - event.terminalId === "default", + event.terminalId === DEFAULT_TERMINAL_ID, ), ).toBe(true); }), @@ -431,6 +542,33 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", ( }), ); + it.effect("restarts a running session when open is called with a different cwd", () => + Effect.gen(function* () { + const { manager, ptyAdapter, logsDir, baseDir } = yield* createManager(); + const pathService = yield* Path.Path; + const originalCwd = pathService.join(baseDir, "original"); + const differentCwd = pathService.join(baseDir, "different"); + yield* makeDirectory(originalCwd); + yield* makeDirectory(differentCwd); + + yield* manager.open(openInput({ cwd: originalCwd })); + const firstProcess = ptyAdapter.processes[0]; + expect(firstProcess).toBeDefined(); + if (!firstProcess) return; + + firstProcess.emitData("before reopen\n"); + yield* waitFor(pathExists(historyLogPath(logsDir))); + + const reopened = yield* manager.open(openInput({ cwd: differentCwd })); + + expect(ptyAdapter.spawnInputs).toHaveLength(2); + assert.equal(firstProcess.killed, true); + assert.equal(reopened.cwd, differentCwd); + assert.equal(reopened.history, ""); + yield* waitFor(Effect.map(readFileString(historyLogPath(logsDir)), (text) => text === "")); + }), + ); + it.effect("propagates explicit worktree metadata through snapshots and lifecycle events", () => Effect.gen(function* () { const { manager, getEvents, baseDir } = yield* createManager(); @@ -553,27 +691,40 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", ( it.effect("emits subprocess activity events when child-process state changes", () => Effect.gen(function* () { - let hasRunningSubprocess = false; + let inspect: { + readonly hasRunningSubprocess: boolean; + readonly childCommand: string | null; + } = { hasRunningSubprocess: false, childCommand: null }; const { manager, getEvents } = yield* createManager(5, { - subprocessChecker: () => Effect.succeed(hasRunningSubprocess), + subprocessInspector: () => Effect.succeed(inspect), subprocessPollIntervalMs: 20, }); yield* manager.open(openInput()); expect((yield* getEvents).some((event) => event.type === "activity")).toBe(false); - hasRunningSubprocess = true; + inspect = { hasRunningSubprocess: true, childCommand: "vim" }; yield* waitFor( Effect.map(getEvents, (events) => - events.some((event) => event.type === "activity" && event.hasRunningSubprocess === true), + events.some( + (event) => + event.type === "activity" && + event.hasRunningSubprocess === true && + event.label === "vim", + ), ), "1200 millis", ); - hasRunningSubprocess = false; + inspect = { hasRunningSubprocess: false, childCommand: null }; yield* waitFor( Effect.map(getEvents, (events) => - events.some((event) => event.type === "activity" && event.hasRunningSubprocess === false), + events.some( + (event) => + event.type === "activity" && + event.hasRunningSubprocess === false && + event.label === "Terminal 1", + ), ), "1200 millis", ); @@ -584,9 +735,9 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", ( Effect.gen(function* () { let checks = 0; const { manager } = yield* createManager(5, { - subprocessChecker: () => { + subprocessInspector: () => { checks += 1; - return Effect.succeed(false); + return Effect.succeed({ hasRunningSubprocess: false, childCommand: null }); }, subprocessPollIntervalMs: 20, }); @@ -769,6 +920,21 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", ( }).pipe(Effect.provide(TestClock.layer())), ); + it.effect("publishes closed events when terminals are explicitly closed", () => + Effect.gen(function* () { + const { manager, getEvents } = yield* createManager(); + yield* manager.open(openInput({ terminalId: "default" })); + yield* manager.open(openInput({ terminalId: "sidecar" })); + + yield* manager.close({ threadId: "thread-1" }); + + const closedEvents = (yield* getEvents).filter( + (event): event is Extract => event.type === "closed", + ); + expect(closedEvents.map((event) => event.terminalId).sort()).toEqual(["default", "sidecar"]); + }), + ); + it.effect("evicts oldest inactive terminal sessions when retention limit is exceeded", () => Effect.gen(function* () { const { manager, ptyAdapter, logsDir, getEvents } = yield* createManager(5, { @@ -1044,6 +1210,122 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", ( }), ); + it.effect("subscribes terminal metadata with an initial snapshot and live deltas", () => + Effect.gen(function* () { + const { manager } = yield* createManager(); + yield* manager.open(openInput({ threadId: "existing-thread" })); + + const scope = yield* Effect.scope; + const metadataEvents = yield* Ref.make>([]); + const unsubscribe = yield* manager.subscribeMetadata((event) => + Ref.update(metadataEvents, (events) => [...events, event]), + ); + yield* Scope.addFinalizer(scope, Effect.sync(unsubscribe)); + + const initialEvents = yield* Ref.get(metadataEvents); + expect(initialEvents[0]).toMatchObject({ + type: "snapshot", + terminals: [ + { + threadId: "existing-thread", + terminalId: DEFAULT_TERMINAL_ID, + }, + ], + }); + + yield* manager.open(openInput({ threadId: "new-thread" })); + + yield* waitFor( + Effect.map(Ref.get(metadataEvents), (events) => + events.some( + (event) => + event.type === "upsert" && + event.terminal.threadId === "new-thread" && + event.terminal.terminalId === DEFAULT_TERMINAL_ID, + ), + ), + "1200 millis", + ); + + yield* manager.close({ threadId: "new-thread", terminalId: DEFAULT_TERMINAL_ID }); + + yield* waitFor( + Effect.map(Ref.get(metadataEvents), (events) => + events.some( + (event) => + event.type === "remove" && + event.threadId === "new-thread" && + event.terminalId === DEFAULT_TERMINAL_ID, + ), + ), + "1200 millis", + ); + }), + ); + + it.effect("removes terminal metadata subscriptions when initial delivery fails", () => + Effect.gen(function* () { + const { manager } = yield* createManager(); + yield* manager.open(openInput({ threadId: "existing-thread" })); + + const leakedLiveEvents = yield* Ref.make(0); + const exit = yield* Effect.exit( + manager.subscribeMetadata((event) => + event.type === "snapshot" + ? Effect.die("snapshot listener failed") + : Ref.update(leakedLiveEvents, (count) => count + 1), + ), + ); + + expect(Exit.isFailure(exit)).toBe(true); + + yield* manager.open(openInput({ threadId: "new-thread" })); + expect(yield* Ref.get(leakedLiveEvents)).toBe(0); + }), + ); + + it.effect( + "streams attach snapshots followed by live events without duplicate start snapshots", + () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(5, { + ptyAdapter: new FakePtyAdapter("async"), + }); + const scope = yield* Effect.scope; + const attachEvents = yield* Ref.make>([]); + const unsubscribe = yield* manager.attachStream(openInput(), (event) => + Ref.update(attachEvents, (events) => [...events, event]), + ); + yield* Scope.addFinalizer(scope, Effect.sync(unsubscribe)); + + const process = ptyAdapter.processes[0]; + expect(process).toBeDefined(); + if (!process) return; + + expect(yield* Ref.get(attachEvents)).toMatchObject([ + { + type: "snapshot", + snapshot: { + threadId: "thread-1", + terminalId: DEFAULT_TERMINAL_ID, + }, + }, + ]); + + process.emitData("hello from attach\n"); + + yield* waitFor( + Effect.map(Ref.get(attachEvents), (events) => + events.some((event) => event.type === "output" && event.data === "hello from attach\n"), + ), + "1200 millis", + ); + + const events = yield* Ref.get(attachEvents); + expect(events.filter((event) => event.type === "snapshot")).toHaveLength(1); + }), + ); + it.effect("preserves queued PTY output ordering through exit callbacks", () => Effect.gen(function* () { const { manager, ptyAdapter, getEvents } = yield* createManager(5, { @@ -1073,10 +1355,26 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", ( (event) => event.type === "output" || event.type === "exited", ); expect(relevant).toEqual([ - expect.objectContaining({ type: "output", data: "first\n" }), - expect.objectContaining({ type: "output", data: "second\n" }), - expect.objectContaining({ type: "exited", exitCode: 0, exitSignal: 0 }), + expect.objectContaining({ type: "output", data: "first\n", sequence: 2 }), + expect.objectContaining({ type: "output", data: "second\n", sequence: 3 }), + expect.objectContaining({ type: "exited", exitCode: 0, exitSignal: 0, sequence: 4 }), ]); + + const scope = yield* Effect.scope; + const attachEvents = yield* Ref.make>([]); + const unsubscribe = yield* manager.attachStream( + { + threadId: "thread-1", + terminalId: DEFAULT_TERMINAL_ID, + }, + (event) => Ref.update(attachEvents, (events) => [...events, event]), + ); + yield* Scope.addFinalizer(scope, Effect.sync(unsubscribe)); + + const snapshot = (yield* Ref.get(attachEvents)).find((event) => event.type === "snapshot"); + expect(snapshot).toBeDefined(); + if (!snapshot || snapshot.type !== "snapshot") return; + expect(snapshot.snapshot.sequence).toBe(4); }), ); diff --git a/apps/server/src/terminal/Layers/Manager.ts b/apps/server/src/terminal/Layers/Manager.ts index 39875202dd8..0706fc2e511 100644 --- a/apps/server/src/terminal/Layers/Manager.ts +++ b/apps/server/src/terminal/Layers/Manager.ts @@ -1,20 +1,25 @@ import { DEFAULT_TERMINAL_ID, + type TerminalAttachInput, + type TerminalAttachStreamEvent, type TerminalEvent, + type TerminalMetadataStreamEvent, + type TerminalOpenInput, type TerminalSessionSnapshot, type TerminalSessionStatus, + type TerminalSummary, } from "@t3tools/contracts"; import { makeKeyedCoalescingWorker } from "@t3tools/shared/KeyedCoalescingWorker"; +import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; import * as Effect from "effect/Effect"; +import * as DateTime from "effect/DateTime"; import * as Encoding from "effect/Encoding"; import * as Equal from "effect/Equal"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; -import * as DateTime from "effect/DateTime"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; -import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Semaphore from "effect/Semaphore"; @@ -51,6 +56,27 @@ const DEFAULT_MAX_RETAINED_INACTIVE_SESSIONS = 128; const DEFAULT_OPEN_COLS = 120; const DEFAULT_OPEN_ROWS = 30; const TERMINAL_ENV_BLOCKLIST = new Set(["PORT", "ELECTRON_RENDERER_PORT", "ELECTRON_RUN_AS_NODE"]); +const MAX_TERMINAL_LABEL_LENGTH = 128; +const nowIsoUnsafe = () => DateTime.formatIso(DateTime.nowUnsafe()); + +function basename(value: string, platform: NodeJS.Platform = process.platform): string { + const separator = platform === "win32" ? /[\\/]+/ : /\/+/; + let result = value; + for (const part of value.split(separator)) { + if (part.length > 0) { + result = part; + } + } + return result; +} + +function joinPath(...parts: ReadonlyArray): string { + return parts.filter((part) => part.length > 0).join("/"); +} + +function joinWindowsPath(...parts: ReadonlyArray): string { + return parts.filter((part) => part.length > 0).join("\\"); +} class TerminalSubprocessCheckError extends Schema.TaggedErrorClass()( "TerminalSubprocessCheckError", @@ -71,8 +97,15 @@ class TerminalProcessSignalError extends Schema.TaggedErrorClass; +interface TerminalSubprocessInspectResult { + readonly hasRunningSubprocess: boolean; + readonly childCommand: string | null; +} + +interface TerminalSubprocessInspector { + ( + terminalPid: number, + ): Effect.Effect; } interface ShellCandidate { @@ -105,12 +138,15 @@ interface TerminalSessionState { exitCode: number | null; exitSignal: number | null; updatedAt: string; + eventSequence: number; cols: number; rows: number; process: PtyProcess | null; unsubscribeData: (() => void) | null; unsubscribeExit: (() => void) | null; hasRunningSubprocess: boolean; + /** Normalized child command name when `hasRunningSubprocess`; cleared when idle. */ + childCommandLabel: string | null; runtimeEnv: Record | null; } @@ -127,6 +163,7 @@ type DrainProcessEventAction = type: "output"; threadId: string; terminalId: string; + sequence: number; history: string | null; data: string; } @@ -135,6 +172,7 @@ type DrainProcessEventAction = process: PtyProcess | null; threadId: string; terminalId: string; + sequence: number; exitCode: number | null; exitSignal: number | null; }; @@ -144,6 +182,38 @@ interface TerminalManagerState { killFibers: Map>; } +function truncateTerminalWireLabel(value: string): string { + if (value.length <= MAX_TERMINAL_LABEL_LENGTH) return value; + return value.slice(0, MAX_TERMINAL_LABEL_LENGTH); +} + +function normalizeChildCommandName(raw: string, platform: NodeJS.Platform): string | null { + let trimmed = raw.trim(); + if (trimmed.length === 0) return null; + if ( + (trimmed.startsWith("[") && trimmed.endsWith("]")) || + (trimmed.startsWith("(") && trimmed.endsWith(")")) + ) { + trimmed = trimmed.slice(1, -1).trim(); + } + const firstToken = (trimmed.split(/\s+/)[0] ?? trimmed).trim(); + if (firstToken.length === 0) return null; + const base = basename(firstToken, platform); + const withoutExe = + platform === "win32" && base.toLowerCase().endsWith(".exe") ? base.slice(0, -4) : base; + return withoutExe.length > 0 ? withoutExe : null; +} + +function terminalWireLabel(session: TerminalSessionState): string { + if (session.hasRunningSubprocess && session.childCommandLabel) { + const trimmed = session.childCommandLabel.trim(); + if (trimmed.length > 0) { + return truncateTerminalWireLabel(trimmed); + } + } + return truncateTerminalWireLabel(getTerminalLabel(session.terminalId)); +} + function snapshot(session: TerminalSessionState): TerminalSessionSnapshot { return { threadId: session.threadId, @@ -155,10 +225,83 @@ function snapshot(session: TerminalSessionState): TerminalSessionSnapshot { history: session.history, exitCode: session.exitCode, exitSignal: session.exitSignal, + label: terminalWireLabel(session), updatedAt: session.updatedAt, + sequence: session.eventSequence, }; } +function summary(session: TerminalSessionState): TerminalSummary { + return { + threadId: session.threadId, + terminalId: session.terminalId, + cwd: session.cwd, + worktreePath: session.worktreePath, + status: session.status, + pid: session.pid, + exitCode: session.exitCode, + exitSignal: session.exitSignal, + hasRunningSubprocess: session.hasRunningSubprocess, + label: terminalWireLabel(session), + updatedAt: session.updatedAt, + }; +} + +function shouldPublishTerminalMetadataEvent(event: TerminalEvent): boolean { + switch (event.type) { + case "started": + case "restarted": + case "exited": + case "closed": + case "error": + case "activity": + return true; + case "output": + case "cleared": + return false; + } +} + +function terminalEventToAttachEvent(event: TerminalEvent): TerminalAttachStreamEvent | null { + switch (event.type) { + case "started": + return { + type: "snapshot", + snapshot: event.snapshot, + }; + case "output": + case "exited": + case "closed": + case "error": + case "cleared": + case "restarted": + case "activity": + return event; + } +} + +function isDuplicateAttachSnapshotEvent( + event: TerminalEvent, + initialSnapshot: TerminalSessionSnapshot, +) { + return typeof event.sequence === "number" && typeof initialSnapshot.sequence === "number" + ? event.sequence <= initialSnapshot.sequence + : event.type === "started" && + event.snapshot.threadId === initialSnapshot.threadId && + event.snapshot.terminalId === initialSnapshot.terminalId && + event.snapshot.updatedAt <= initialSnapshot.updatedAt; +} + +function advanceEventSequence(session: TerminalSessionState): { + readonly updatedAt: string; + readonly sequence: number; +} { + const updatedAt = nowIsoUnsafe(); + session.eventSequence += 1; + session.updatedAt = updatedAt; + return { updatedAt, sequence: session.eventSequence }; +} + function cleanupProcessHandles(session: TerminalSessionState): void { session.unsubscribeData?.(); session.unsubscribeData = null; @@ -211,25 +354,15 @@ function normalizeShellCommand( return firstToken.replace(/^['"]|['"]$/g, ""); } -function basename(command: string, platform: NodeJS.Platform): string { - const normalized = platform === "win32" ? command.replaceAll("/", "\\") : command; - const separator = platform === "win32" ? "\\" : "/"; - return normalized.slice(normalized.lastIndexOf(separator) + 1); -} - -function joinWindowsPath(...segments: ReadonlyArray): string { - return segments - .filter((segment) => segment.length > 0) - .join("\\") - .replace(/\\+/g, "\\"); -} - function shellCandidateFromCommand( command: string | null, platform: NodeJS.Platform = process.platform, ): ShellCandidate | null { if (!command || command.length === 0) return null; - const shellName = basename(command, platform).toLowerCase(); + const shellName = + platform === "win32" + ? basename(command, "win32").toLowerCase() + : basename(command).toLowerCase(); if (platform === "win32" && (shellName === "pwsh.exe" || shellName === "powershell.exe")) { return { shell: command, args: ["-NoLogo"] }; } @@ -355,13 +488,25 @@ function isRetryableShellSpawnError(error: PtySpawnError): boolean { ); } -function checkWindowsSubprocessActivity( +function parseFirstChildPidFromPgrep(stdout: string): number | null { + for (const line of stdout.split(/\r?\n/g)) { + const n = Number.parseInt(line.trim(), 10); + if (Number.isInteger(n) && n > 0) { + return n; + } + } + return null; +} + +function windowsInspectSubprocess( terminalPid: number, -): Effect.Effect { + platform: NodeJS.Platform, +): Effect.Effect { const command = [ - `$children = Get-CimInstance Win32_Process -Filter "ParentProcessId = ${terminalPid}" -ErrorAction SilentlyContinue`, - "if ($children) { exit 0 }", - "exit 1", + `$c = Get-CimInstance Win32_Process -Filter "ParentProcessId = ${terminalPid}" -ErrorAction SilentlyContinue | Select-Object -First 1`, + "if ($null -eq $c) { exit 1 }", + "Write-Output $c.Name", + "exit 0", ].join("; "); return Effect.tryPromise({ try: () => @@ -373,17 +518,33 @@ function checkWindowsSubprocessActivity( }), catch: (cause) => new TerminalSubprocessCheckError({ - message: "Failed to check Windows terminal subprocess activity.", + message: "Failed to inspect Windows terminal subprocesses.", cause, terminalPid, command: "powershell", }), - }).pipe(Effect.map((result) => result.code === 0)); + }).pipe( + Effect.map((result) => { + if (result.code !== 0) { + return { hasRunningSubprocess: false, childCommand: null } as const; + } + const name = result.stdout.trim().split(/\r?\n/)[0]?.trim() ?? ""; + if (name.length === 0) { + return { hasRunningSubprocess: true, childCommand: null } as const; + } + const normalized = normalizeChildCommandName(name, platform); + return { + hasRunningSubprocess: true, + childCommand: normalized ? truncateTerminalWireLabel(normalized) : null, + } as const; + }), + ); } -const checkPosixSubprocessActivity = Effect.fn("terminal.checkPosixSubprocessActivity")(function* ( +const posixInspectSubprocess = Effect.fn("terminal.posixInspectSubprocess")(function* ( terminalPid: number, -): Effect.fn.Return { + platform: NodeJS.Platform, +): Effect.fn.Return { const runPgrep = Effect.tryPromise({ try: () => runProcess("pgrep", ["-P", String(terminalPid)], { @@ -418,45 +579,94 @@ const checkPosixSubprocessActivity = Effect.fn("terminal.checkPosixSubprocessAct }), }); + let childPid: number | null = null; + const pgrepResult = yield* Effect.exit(runPgrep); if (pgrepResult._tag === "Success") { if (pgrepResult.value.code === 0) { - return pgrepResult.value.stdout.trim().length > 0; - } - if (pgrepResult.value.code === 1) { - return false; + childPid = parseFirstChildPidFromPgrep(pgrepResult.value.stdout); + } else if (pgrepResult.value.code === 1) { + return { hasRunningSubprocess: false, childCommand: null }; } } - const psResult = yield* Effect.exit(runPs); - if (psResult._tag === "Failure" || psResult.value.code !== 0) { - return false; + if (childPid === null) { + const psResult = yield* Effect.exit(runPs); + if (psResult._tag === "Failure" || psResult.value.code !== 0) { + return { hasRunningSubprocess: false, childCommand: null }; + } + for (const line of psResult.value.stdout.split(/\r?\n/g)) { + const [pidRaw, ppidRaw] = line.trim().split(/\s+/g); + const pid = Number(pidRaw); + const ppid = Number(ppidRaw); + if (!Number.isInteger(pid) || !Number.isInteger(ppid)) continue; + if (ppid === terminalPid) { + childPid = pid; + break; + } + } } - for (const line of psResult.value.stdout.split(/\r?\n/g)) { - const [pidRaw, ppidRaw] = line.trim().split(/\s+/g); - const pid = Number(pidRaw); - const ppid = Number(ppidRaw); - if (!Number.isInteger(pid) || !Number.isInteger(ppid)) continue; - if (ppid === terminalPid) { - return true; - } + if (childPid === null) { + return { hasRunningSubprocess: false, childCommand: null }; } - return false; -}); -const defaultSubprocessChecker = Effect.fn("terminal.defaultSubprocessChecker")(function* ( - terminalPid: number, -): Effect.fn.Return { - if (!Number.isInteger(terminalPid) || terminalPid <= 0) { - return false; + const runComm = Effect.tryPromise({ + try: () => + runProcess("ps", ["-p", String(childPid), "-o", "comm="], { + timeoutMs: 1_000, + allowNonZeroExit: true, + maxBufferBytes: 8_192, + outputMode: "truncate", + }), + catch: () => null, + }); + + const commResult = yield* Effect.exit(runComm); + let rawComm: string | null = null; + if (commResult._tag === "Success" && commResult.value && commResult.value.code === 0) { + rawComm = commResult.value.stdout.trim(); } - if (process.platform === "win32") { - return yield* checkWindowsSubprocessActivity(terminalPid); + + if (!rawComm || rawComm.length === 0) { + const runArgs = Effect.tryPromise({ + try: () => + runProcess("ps", ["-p", String(childPid), "-o", "args="], { + timeoutMs: 1_000, + allowNonZeroExit: true, + maxBufferBytes: 16_384, + outputMode: "truncate", + }), + catch: () => null, + }); + const argsResult = yield* Effect.exit(runArgs); + if (argsResult._tag === "Success" && argsResult.value && argsResult.value.code === 0) { + const first = argsResult.value.stdout.trim().split(/\s+/)[0] ?? ""; + rawComm = first.length > 0 ? first : null; + } } - return yield* checkPosixSubprocessActivity(terminalPid); + + const normalized = rawComm ? normalizeChildCommandName(rawComm, platform) : null; + return { + hasRunningSubprocess: true, + childCommand: normalized ? truncateTerminalWireLabel(normalized) : null, + }; }); +function defaultSubprocessInspectorForPlatform( + platform: NodeJS.Platform, +): TerminalSubprocessInspector { + return Effect.fn("terminal.defaultSubprocessInspector")(function* (terminalPid: number) { + if (!Number.isInteger(terminalPid) || terminalPid <= 0) { + return { hasRunningSubprocess: false, childCommand: null }; + } + if (platform === "win32") { + return yield* windowsInspectSubprocess(terminalPid, platform); + } + return yield* posixInspectSubprocess(terminalPid, platform); + }); +} + function capHistory(history: string, maxLines: number): string { if (history.length === 0) return history; const hasTrailingNewline = history.endsWith("\n"); @@ -704,7 +914,7 @@ interface TerminalManagerOptions { shellResolver?: () => string; platform?: NodeJS.Platform; env?: NodeJS.ProcessEnv; - subprocessChecker?: TerminalSubprocessChecker; + subprocessInspector?: TerminalSubprocessInspector; subprocessPollIntervalMs?: number; processKillGraceMs?: number; maxRetainedInactiveSessions?: number; @@ -722,17 +932,16 @@ const makeTerminalManager = Effect.fn("makeTerminalManager")(function* () { export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWithOptions")( function* (options: TerminalManagerOptions) { const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; const context = yield* Effect.context(); const runFork = Effect.runForkWith(context); - const nowIso = Effect.map(DateTime.now, DateTime.formatIso); const logsDir = options.logsDir; const historyLineLimit = options.historyLineLimit ?? DEFAULT_HISTORY_LINE_LIMIT; const platform = options.platform ?? process.platform; const baseEnv = options.env ?? process.env; const shellResolver = options.shellResolver ?? (() => defaultShellResolver(platform, baseEnv)); - const subprocessChecker = options.subprocessChecker ?? defaultSubprocessChecker; + const subprocessInspector = + options.subprocessInspector ?? defaultSubprocessInspectorForPlatform(platform); const subprocessPollIntervalMs = options.subprocessPollIntervalMs ?? DEFAULT_SUBPROCESS_POLL_INTERVAL_MS; const processKillGraceMs = options.processKillGraceMs ?? DEFAULT_PROCESS_KILL_GRACE_MS; @@ -760,13 +969,13 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith const historyPath = (threadId: string, terminalId: string) => { const threadPart = toSafeThreadId(threadId); if (terminalId === DEFAULT_TERMINAL_ID) { - return path.join(logsDir, `${threadPart}.log`); + return joinPath(logsDir, `${threadPart}.log`); } - return path.join(logsDir, `${threadPart}_${toSafeTerminalId(terminalId)}.log`); + return joinPath(logsDir, `${threadPart}_${toSafeTerminalId(terminalId)}.log`); }; const legacyHistoryPath = (threadId: string) => - path.join(logsDir, `${legacySafeThreadId(threadId)}.log`); + joinPath(logsDir, `${legacySafeThreadId(threadId)}.log`); const toTerminalHistoryError = (operation: "read" | "truncate" | "migrate", threadId: string, terminalId: string) => @@ -1069,7 +1278,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith name.startsWith(threadPrefix), ), (name) => - fileSystem.remove(path.join(logsDir, name), { force: true }).pipe( + fileSystem.remove(joinPath(logsDir, name), { force: true }).pipe( Effect.catch((error) => Effect.logWarning("failed to delete terminal histories for thread", { threadId, @@ -1170,7 +1379,6 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith expectedPid: number, ) { while (true) { - const updatedAt = yield* nowIso; const action: DrainProcessEventAction = yield* Effect.sync(() => { if (session.pid !== expectedPid || !session.process || session.status !== "running") { session.pendingProcessEvents = []; @@ -1205,12 +1413,13 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith historyLineLimit, ); } - session.updatedAt = updatedAt; + const eventStamp = advanceEventSequence(session); return { type: "output", threadId: session.threadId, terminalId: session.terminalId, + sequence: eventStamp.sequence, history: sanitized.visibleText.length > 0 ? session.history : null, data: nextEvent.data, } as const; @@ -1221,6 +1430,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith session.process = null; session.pid = null; session.hasRunningSubprocess = false; + session.childCommandLabel = null; session.status = "exited"; session.pendingHistoryControlSequence = ""; session.pendingProcessEvents = []; @@ -1232,13 +1442,14 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith session.exitSignal = Number.isInteger(nextEvent.event.signal) ? nextEvent.event.signal : null; - session.updatedAt = updatedAt; + const eventStamp = advanceEventSequence(session); return { type: "exit", process, threadId: session.threadId, terminalId: session.terminalId, + sequence: eventStamp.sequence, exitCode: session.exitCode, exitSignal: session.exitSignal, } as const; @@ -1253,24 +1464,22 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith yield* queuePersist(action.threadId, action.terminalId, action.history); } - const createdAt = yield* nowIso; yield* publishEvent({ type: "output", threadId: action.threadId, terminalId: action.terminalId, - createdAt, + sequence: action.sequence, data: action.data, }); continue; } yield* clearKillFiber(action.process); - const createdAt = yield* nowIso; yield* publishEvent({ type: "exited", threadId: action.threadId, terminalId: action.terminalId, - createdAt, + sequence: action.sequence, exitCode: action.exitCode, exitSignal: action.exitSignal, }); @@ -1285,18 +1494,18 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith const process = session.process; if (!process) return; - const updatedAt = yield* nowIso; yield* modifyManagerState((state) => { cleanupProcessHandles(session); session.process = null; session.pid = null; session.hasRunningSubprocess = false; + session.childCommandLabel = null; session.status = "exited"; session.pendingHistoryControlSequence = ""; session.pendingProcessEvents = []; session.pendingProcessEventIndex = 0; session.processEventDrainRunning = false; - session.updatedAt = updatedAt; + session.updatedAt = nowIsoUnsafe(); return [undefined, state] as const; }); @@ -1375,7 +1584,6 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith "terminal.cwd": input.cwd, }); - const startingAt = yield* nowIso; yield* modifyManagerState((state) => { session.status = "starting"; session.cwd = input.cwd; @@ -1385,10 +1593,11 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith session.exitCode = null; session.exitSignal = null; session.hasRunningSubprocess = false; + session.childCommandLabel = null; session.pendingProcessEvents = []; session.pendingProcessEventIndex = 0; session.processEventDrainRunning = false; - session.updatedAt = startingAt; + session.updatedAt = nowIsoUnsafe(); return [undefined, state] as const; }); @@ -1419,14 +1628,17 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith runFork(drainProcessEvents(session, processPid)); }); - const startedAt = yield* nowIso; + let eventStamp: ReturnType = { + updatedAt: session.updatedAt, + sequence: session.eventSequence, + }; yield* modifyManagerState((state) => { session.process = ptyProcess; session.pid = processPid; session.status = "running"; - session.updatedAt = startedAt; session.unsubscribeData = unsubscribeData; session.unsubscribeExit = unsubscribeExit; + eventStamp = advanceEventSequence(session); return [undefined, state] as const; }); @@ -1434,7 +1646,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith type: eventType, threadId: session.threadId, terminalId: session.terminalId, - createdAt: startedAt, + sequence: eventStamp.sequence, snapshot: snapshot(session), }); }), @@ -1452,7 +1664,6 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith yield* startKillEscalation(ptyProcess, session.threadId, session.terminalId); } - const failedAt = yield* nowIso; yield* modifyManagerState((state) => { session.status = "error"; session.pid = null; @@ -1460,10 +1671,11 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith session.unsubscribeData = null; session.unsubscribeExit = null; session.hasRunningSubprocess = false; + session.childCommandLabel = null; session.pendingProcessEvents = []; session.pendingProcessEventIndex = 0; session.processEventDrainRunning = false; - session.updatedAt = failedAt; + advanceEventSequence(session); return [undefined, state] as const; }); @@ -1474,7 +1686,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith type: "error", threadId: session.threadId, terminalId: session.terminalId, - createdAt: failedAt, + sequence: session.eventSequence, message, }); yield* Effect.logError("failed to start terminal", { @@ -1493,6 +1705,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith ) { const key = toSessionKey(threadId, terminalId); const session = yield* getSession(threadId, terminalId); + const closedEventSequence = Option.isSome(session) ? session.value.eventSequence + 1 : 0; if (Option.isSome(session)) { yield* stopProcess(session.value); @@ -1501,15 +1714,24 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith yield* flushPersist(threadId, terminalId); - yield* modifyManagerState((state) => { + const removed = yield* modifyManagerState((state) => { if (!state.sessions.has(key)) { - return [undefined, state] as const; + return [false, state] as const; } const sessions = new Map(state.sessions); sessions.delete(key); - return [undefined, { ...state, sessions }] as const; + return [true, { ...state, sessions }] as const; }); + if (removed) { + yield* publishEvent({ + type: "closed", + threadId, + terminalId, + sequence: closedEventSequence, + }); + } + if (deleteHistoryOnClose) { yield* deleteHistory(threadId, terminalId); } @@ -1530,7 +1752,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith session: TerminalSessionState & { pid: number }, ) { const terminalPid = session.pid; - const hasRunningSubprocess = yield* subprocessChecker(terminalPid).pipe( + const inspectResult = yield* subprocessInspector(terminalPid).pipe( Effect.map(Option.some), Effect.catch((reason) => Effect.logWarning("failed to check terminal subprocess activity", { @@ -1538,15 +1760,17 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith terminalId: session.terminalId, terminalPid, reason, - }).pipe(Effect.as(Option.none())), + }).pipe(Effect.as(Option.none())), ), ); - if (Option.isNone(hasRunningSubprocess)) { + if (Option.isNone(inspectResult)) { return; } - const updatedAt = yield* nowIso; + const next = inspectResult.value; + const nextChildLabel = next.hasRunningSubprocess ? next.childCommand : null; + const event = yield* modifyManagerState((state) => { const liveSession: Option.Option = Option.fromNullishOr( state.sessions.get(toSessionKey(session.threadId, session.terminalId)), @@ -1555,21 +1779,24 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith Option.isNone(liveSession) || liveSession.value.status !== "running" || liveSession.value.pid !== terminalPid || - liveSession.value.hasRunningSubprocess === hasRunningSubprocess.value + (liveSession.value.hasRunningSubprocess === next.hasRunningSubprocess && + liveSession.value.childCommandLabel === nextChildLabel) ) { return [Option.none(), state] as const; } - liveSession.value.hasRunningSubprocess = hasRunningSubprocess.value; - liveSession.value.updatedAt = updatedAt; + liveSession.value.hasRunningSubprocess = next.hasRunningSubprocess; + liveSession.value.childCommandLabel = nextChildLabel; + const eventStamp = advanceEventSequence(liveSession.value); return [ Option.some({ type: "activity" as const, threadId: liveSession.value.threadId, terminalId: liveSession.value.terminalId, - createdAt: updatedAt, - hasRunningSubprocess: hasRunningSubprocess.value, + sequence: eventStamp.sequence, + hasRunningSubprocess: next.hasRunningSubprocess, + label: terminalWireLabel(liveSession.value), }), state, ] as const; @@ -1633,136 +1860,353 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith }).pipe(Effect.ignoreCause({ log: true })), ); + const openLocked = Effect.fn("terminal.openLocked")(function* (input: TerminalOpenInput) { + const terminalId = input.terminalId; + yield* assertValidCwd(input.cwd); + + const sessionKey = toSessionKey(input.threadId, terminalId); + const existing = yield* getSession(input.threadId, terminalId); + if (Option.isNone(existing)) { + yield* flushPersist(input.threadId, terminalId); + const history = yield* readHistory(input.threadId, terminalId); + const cols = input.cols ?? DEFAULT_OPEN_COLS; + const rows = input.rows ?? DEFAULT_OPEN_ROWS; + const session: TerminalSessionState = { + threadId: input.threadId, + terminalId, + cwd: input.cwd, + worktreePath: input.worktreePath ?? null, + status: "starting", + pid: null, + history, + pendingHistoryControlSequence: "", + pendingProcessEvents: [], + pendingProcessEventIndex: 0, + processEventDrainRunning: false, + exitCode: null, + exitSignal: null, + updatedAt: nowIsoUnsafe(), + eventSequence: 0, + cols, + rows, + process: null, + unsubscribeData: null, + unsubscribeExit: null, + hasRunningSubprocess: false, + childCommandLabel: null, + runtimeEnv: normalizedRuntimeEnv(input.env), + }; + + const createdSession = session; + yield* modifyManagerState((state) => { + const sessions = new Map(state.sessions); + sessions.set(sessionKey, createdSession); + return [undefined, { ...state, sessions }] as const; + }); + + yield* evictInactiveSessionsIfNeeded(); + yield* startSession( + session, + { + threadId: input.threadId, + terminalId, + cwd: input.cwd, + ...(input.worktreePath !== undefined ? { worktreePath: input.worktreePath } : {}), + cols, + rows, + ...(input.env ? { env: input.env } : {}), + }, + "started", + ); + return snapshot(session); + } + + const liveSession = existing.value; + const nextRuntimeEnv = normalizedRuntimeEnv(input.env); + const currentRuntimeEnv = liveSession.runtimeEnv; + const targetCols = input.cols ?? liveSession.cols; + const targetRows = input.rows ?? liveSession.rows; + const runtimeEnvChanged = !Equal.equals(currentRuntimeEnv, nextRuntimeEnv); + const nextWorktreePath = + input.worktreePath !== undefined ? (input.worktreePath ?? null) : liveSession.worktreePath; + const launchContextChanged = + liveSession.cwd !== input.cwd || + runtimeEnvChanged || + liveSession.worktreePath !== nextWorktreePath; + + if (launchContextChanged) { + yield* stopProcess(liveSession); + liveSession.cwd = input.cwd; + liveSession.worktreePath = nextWorktreePath; + liveSession.runtimeEnv = nextRuntimeEnv; + liveSession.history = ""; + liveSession.pendingHistoryControlSequence = ""; + liveSession.pendingProcessEvents = []; + liveSession.pendingProcessEventIndex = 0; + liveSession.processEventDrainRunning = false; + yield* persistHistory(liveSession.threadId, liveSession.terminalId, liveSession.history); + } else if (liveSession.status === "exited" || liveSession.status === "error") { + liveSession.runtimeEnv = nextRuntimeEnv; + liveSession.worktreePath = nextWorktreePath; + liveSession.history = ""; + liveSession.pendingHistoryControlSequence = ""; + liveSession.pendingProcessEvents = []; + liveSession.pendingProcessEventIndex = 0; + liveSession.processEventDrainRunning = false; + yield* persistHistory(liveSession.threadId, liveSession.terminalId, liveSession.history); + } + + if (!liveSession.process) { + yield* startSession( + liveSession, + { + threadId: input.threadId, + terminalId, + cwd: input.cwd, + worktreePath: liveSession.worktreePath, + cols: targetCols, + rows: targetRows, + ...(input.env ? { env: input.env } : {}), + }, + "started", + ); + return snapshot(liveSession); + } + + if (liveSession.cols !== targetCols || liveSession.rows !== targetRows) { + liveSession.cols = targetCols; + liveSession.rows = targetRows; + liveSession.updatedAt = nowIsoUnsafe(); + liveSession.process.resize(targetCols, targetRows); + } + + return snapshot(liveSession); + }); + const open: TerminalManagerShape["open"] = (input) => + withThreadLock(input.threadId, openLocked(input)); + + const openOrAttachForStream = (input: TerminalAttachInput) => withThreadLock( input.threadId, Effect.gen(function* () { - const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID; - yield* assertValidCwd(input.cwd); - - const sessionKey = toSessionKey(input.threadId, terminalId); + const terminalId = input.terminalId; const existing = yield* getSession(input.threadId, terminalId); + if (Option.isNone(existing)) { - yield* flushPersist(input.threadId, terminalId); - const history = yield* readHistory(input.threadId, terminalId); - const cols = input.cols ?? DEFAULT_OPEN_COLS; - const rows = input.rows ?? DEFAULT_OPEN_ROWS; - const createdAt = yield* nowIso; - const session: TerminalSessionState = { - threadId: input.threadId, + if (!input.cwd) { + return yield* new TerminalSessionLookupError({ + threadId: input.threadId, + terminalId, + }); + } + + return yield* openLocked({ + ...input, terminalId, cwd: input.cwd, - worktreePath: input.worktreePath ?? null, - status: "starting", - pid: null, - history, - pendingHistoryControlSequence: "", - pendingProcessEvents: [], - pendingProcessEventIndex: 0, - processEventDrainRunning: false, - exitCode: null, - exitSignal: null, - updatedAt: createdAt, - cols, - rows, - process: null, - unsubscribeData: null, - unsubscribeExit: null, - hasRunningSubprocess: false, - runtimeEnv: normalizedRuntimeEnv(input.env), - }; + }); + } - const createdSession = session; - yield* modifyManagerState((state) => { - const sessions = new Map(state.sessions); - sessions.set(sessionKey, createdSession); - return [undefined, { ...state, sessions }] as const; + const session = existing.value; + const targetCols = input.cols ?? session.cols; + const targetRows = input.rows ?? session.rows; + + if (!session.process && input.cwd && input.restartIfNotRunning === true) { + return yield* openLocked({ + ...input, + terminalId, + cwd: input.cwd, }); + } - yield* evictInactiveSessionsIfNeeded(); - yield* startSession( - session, - { - threadId: input.threadId, - terminalId, - cwd: input.cwd, - ...(input.worktreePath !== undefined ? { worktreePath: input.worktreePath } : {}), - cols, - rows, - ...(input.env ? { env: input.env } : {}), - }, - "started", - ); - return snapshot(session); + if ( + session.process && + session.status === "running" && + (session.cols !== targetCols || session.rows !== targetRows) + ) { + session.cols = targetCols; + session.rows = targetRows; + session.updatedAt = nowIsoUnsafe(); + yield* Effect.sync(() => session.process?.resize(targetCols, targetRows)); } - const liveSession = existing.value; - const nextRuntimeEnv = normalizedRuntimeEnv(input.env); - const currentRuntimeEnv = liveSession.runtimeEnv; - const targetCols = input.cols ?? liveSession.cols; - const targetRows = input.rows ?? liveSession.rows; - const runtimeEnvChanged = !Equal.equals(currentRuntimeEnv, nextRuntimeEnv); - - if (liveSession.cwd !== input.cwd || runtimeEnvChanged) { - yield* stopProcess(liveSession); - liveSession.cwd = input.cwd; - liveSession.worktreePath = input.worktreePath ?? null; - liveSession.runtimeEnv = nextRuntimeEnv; - liveSession.history = ""; - liveSession.pendingHistoryControlSequence = ""; - liveSession.pendingProcessEvents = []; - liveSession.pendingProcessEventIndex = 0; - liveSession.processEventDrainRunning = false; - yield* persistHistory( - liveSession.threadId, - liveSession.terminalId, - liveSession.history, - ); - } else if (liveSession.status === "exited" || liveSession.status === "error") { - liveSession.runtimeEnv = nextRuntimeEnv; - liveSession.worktreePath = input.worktreePath ?? null; - liveSession.history = ""; - liveSession.pendingHistoryControlSequence = ""; - liveSession.pendingProcessEvents = []; - liveSession.pendingProcessEventIndex = 0; - liveSession.processEventDrainRunning = false; - yield* persistHistory( - liveSession.threadId, - liveSession.terminalId, - liveSession.history, - ); + return snapshot(session); + }), + ); + + const readAllTerminalMetadata = () => + readManagerState.pipe( + Effect.map((state) => + [...state.sessions.values()] + .map(summary) + .sort( + (left, right) => + right.updatedAt.localeCompare(left.updatedAt) || + left.threadId.localeCompare(right.threadId) || + left.terminalId.localeCompare(right.terminalId), + ), + ), + ); + + const readTerminalMetadata = (input: { + readonly threadId: string; + readonly terminalId: string; + }) => + getSession(input.threadId, input.terminalId).pipe( + Effect.map((session) => (Option.isSome(session) ? summary(session.value) : null)), + ); + + const subscribe: TerminalManagerShape["subscribe"] = (listener) => + Effect.sync(() => { + terminalEventListeners.add(listener); + return () => { + terminalEventListeners.delete(listener); + }; + }); + + const attachStream: TerminalManagerShape["attachStream"] = (input, listener) => { + let unsubscribe: (() => void) | null = null; + + return Effect.gen(function* () { + const bufferedEvents: TerminalEvent[] = []; + let deliverLive = false; + + const initialSnapshot = yield* openOrAttachForStream(input); + const terminalId = initialSnapshot.terminalId; + + unsubscribe = yield* subscribe((event) => { + if (event.threadId !== input.threadId || event.terminalId !== terminalId) { + return Effect.void; } - if (!liveSession.process) { - yield* startSession( - liveSession, - { - threadId: input.threadId, - terminalId, - cwd: input.cwd, - worktreePath: liveSession.worktreePath, - cols: targetCols, - rows: targetRows, - ...(input.env ? { env: input.env } : {}), - }, - "started", - ); - return snapshot(liveSession); + if (!deliverLive) { + bufferedEvents.push(event); + return Effect.void; } - if (liveSession.cols !== targetCols || liveSession.rows !== targetRows) { - liveSession.cols = targetCols; - liveSession.rows = targetRows; - liveSession.updatedAt = yield* nowIso; - liveSession.process.resize(targetCols, targetRows); + const attachEvent = terminalEventToAttachEvent(event); + return attachEvent ? listener(attachEvent) : Effect.void; + }); + + yield* listener({ + type: "snapshot", + snapshot: initialSnapshot, + }); + + for (const event of bufferedEvents) { + if (isDuplicateAttachSnapshotEvent(event, initialSnapshot)) { + continue; } - return snapshot(liveSession); - }), + const attachEvent = terminalEventToAttachEvent(event); + if (attachEvent) { + yield* listener(attachEvent); + } + } + + deliverLive = true; + return () => { + unsubscribe?.(); + unsubscribe = null; + }; + }).pipe( + Effect.catchCause((cause) => + Effect.flatMap( + Effect.sync(() => { + unsubscribe?.(); + unsubscribe = null; + }), + () => Effect.failCause(cause), + ), + ), + ); + }; + + const metadataEventFromTerminalEvent = ( + event: TerminalEvent, + ): Effect.Effect => { + if (!shouldPublishTerminalMetadataEvent(event)) { + return Effect.succeed(null); + } + + if (event.type === "closed") { + return Effect.succeed({ + type: "remove" as const, + threadId: event.threadId, + terminalId: event.terminalId, + }); + } + + return readTerminalMetadata({ + threadId: event.threadId, + terminalId: event.terminalId, + }).pipe( + Effect.map((terminal) => + terminal + ? { + type: "upsert" as const, + terminal, + } + : null, + ), + ); + }; + + const offerMetadataEvent = ( + listener: (event: TerminalMetadataStreamEvent) => Effect.Effect, + event: TerminalEvent, + ) => + metadataEventFromTerminalEvent(event).pipe( + Effect.flatMap((metadataEvent) => (metadataEvent ? listener(metadataEvent) : Effect.void)), + ); + + const subscribeMetadata: TerminalManagerShape["subscribeMetadata"] = (listener) => { + let unsubscribe: (() => void) | null = null; + + return Effect.gen(function* () { + const bufferedEvents: TerminalEvent[] = []; + let deliverLive = false; + + unsubscribe = yield* subscribe((event) => { + if (!deliverLive) { + bufferedEvents.push(event); + return Effect.void; + } + + return offerMetadataEvent(listener, event); + }); + + const terminals = yield* readAllTerminalMetadata(); + yield* listener({ + type: "snapshot", + terminals, + }); + + for (const event of bufferedEvents) { + yield* offerMetadataEvent(listener, event); + } + + deliverLive = true; + return () => { + unsubscribe?.(); + unsubscribe = null; + }; + }).pipe( + Effect.catchCause((cause) => + Effect.flatMap( + Effect.sync(() => { + unsubscribe?.(); + unsubscribe = null; + }), + () => Effect.failCause(cause), + ), + ), ); + }; const write: TerminalManagerShape["write"] = Effect.fn("terminal.write")(function* (input) { - const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID; + const terminalId = input.terminalId; const session = yield* requireSession(input.threadId, terminalId); const process = session.process; if (!process || session.status !== "running") { @@ -1776,7 +2220,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith }); const resize: TerminalManagerShape["resize"] = Effect.fn("terminal.resize")(function* (input) { - const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID; + const terminalId = input.terminalId; const session = yield* requireSession(input.threadId, terminalId); const process = session.process; if (!process || session.status !== "running") { @@ -1787,7 +2231,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith } session.cols = input.cols; session.rows = input.rows; - session.updatedAt = yield* nowIso; + session.updatedAt = nowIsoUnsafe(); yield* Effect.sync(() => process.resize(input.cols, input.rows)); }); @@ -1795,21 +2239,20 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith withThreadLock( input.threadId, Effect.gen(function* () { - const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID; + const terminalId = input.terminalId; const session = yield* requireSession(input.threadId, terminalId); session.history = ""; session.pendingHistoryControlSequence = ""; session.pendingProcessEvents = []; session.pendingProcessEventIndex = 0; session.processEventDrainRunning = false; - const clearedAt = yield* nowIso; - session.updatedAt = clearedAt; + const eventStamp = advanceEventSequence(session); yield* persistHistory(input.threadId, terminalId, session.history); yield* publishEvent({ type: "cleared", threadId: input.threadId, terminalId, - createdAt: clearedAt, + sequence: eventStamp.sequence, }); }), ); @@ -1819,7 +2262,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith input.threadId, Effect.gen(function* () { yield* increment(terminalRestartsTotal, { scope: "thread" }); - const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID; + const terminalId = input.terminalId; yield* assertValidCwd(input.cwd); const sessionKey = toSessionKey(input.threadId, terminalId); @@ -1828,7 +2271,6 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith if (Option.isNone(existingSession)) { const cols = input.cols ?? DEFAULT_OPEN_COLS; const rows = input.rows ?? DEFAULT_OPEN_ROWS; - const createdAt = yield* nowIso; session = { threadId: input.threadId, terminalId, @@ -1843,13 +2285,15 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith processEventDrainRunning: false, exitCode: null, exitSignal: null, - updatedAt: createdAt, + updatedAt: nowIsoUnsafe(), + eventSequence: 0, cols, rows, process: null, unsubscribeData: null, unsubscribeExit: null, hasRunningSubprocess: false, + childCommandLabel: null, runtimeEnv: normalizedRuntimeEnv(input.env), }; const createdSession = session; @@ -1917,18 +2361,14 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith return { open, + attachStream, write, resize, clear, restart, close, - subscribe: (listener) => - Effect.sync(() => { - terminalEventListeners.add(listener); - return () => { - terminalEventListeners.delete(listener); - }; - }), + subscribe, + subscribeMetadata, } satisfies TerminalManagerShape; }, ); diff --git a/apps/server/src/terminal/Services/Manager.ts b/apps/server/src/terminal/Services/Manager.ts index 6db23d571b2..51c66f49f7c 100644 --- a/apps/server/src/terminal/Services/Manager.ts +++ b/apps/server/src/terminal/Services/Manager.ts @@ -7,12 +7,15 @@ * @module TerminalManager */ import { + TerminalAttachInput, + TerminalAttachStreamEvent, TerminalClearInput, TerminalCloseInput, TerminalEvent, TerminalCwdError, TerminalError, TerminalHistoryError, + TerminalMetadataStreamEvent, TerminalNotRunningError, TerminalOpenInput, TerminalResizeInput, @@ -79,6 +82,16 @@ export interface TerminalManagerShape { input: TerminalOpenInput, ) => Effect.Effect; + /** + * Attach to a terminal and stream its initial snapshot followed by live events. + * + * Returns an unsubscribe function. + */ + readonly attachStream: ( + input: TerminalAttachInput, + listener: (event: TerminalAttachStreamEvent) => Effect.Effect, + ) => Effect.Effect<() => void, TerminalError>; + /** * Write input bytes to a terminal session. */ @@ -118,6 +131,15 @@ export interface TerminalManagerShape { readonly subscribe: ( listener: (event: TerminalEvent) => Effect.Effect, ) => Effect.Effect<() => void>; + + /** + * Subscribe to lightweight terminal metadata with an initial full snapshot. + * + * Returns an unsubscribe function. + */ + readonly subscribeMetadata: ( + listener: (event: TerminalMetadataStreamEvent) => Effect.Effect, + ) => Effect.Effect<() => void>; } /** diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 2bd2d5c00da..f9c37fcfd24 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -14,6 +14,8 @@ import { type VcsCreateRefResult, type VcsCreateWorktreeInput, type VcsCreateWorktreeResult, + type ReviewDiffPreviewInput, + type ReviewDiffPreviewResult, type VcsInitInput, type VcsListRefsInput, type VcsListRefsResult, @@ -175,6 +177,9 @@ export interface GitVcsDriverShape { cwd: string, baseRef: string, ) => Effect.Effect; + readonly getReviewDiffPreview: ( + input: ReviewDiffPreviewInput, + ) => Effect.Effect; readonly readConfigValue: ( cwd: string, key: string, diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 00f40a69aa7..1909b3b5033 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -9,6 +9,7 @@ import * as Scope from "effect/Scope"; import { GitCommandError } from "@t3tools/contracts"; import { ServerConfig } from "../config.ts"; +import { splitNullSeparatedGitStdoutPaths } from "./GitVcsDriverCore.ts"; import * as GitVcsDriver from "./GitVcsDriver.ts"; const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { @@ -77,6 +78,30 @@ const initRepoWithCommit = ( }); it.layer(TestLayer)("GitVcsDriver core integration", (it) => { + describe("review diff previews", () => { + it.effect("drops an unterminated path from truncated NUL-separated git output", () => + Effect.sync(() => { + const paths = splitNullSeparatedGitStdoutPaths({ + stdout: "complete.txt\0partial", + stdoutTruncated: true, + }); + + assert.deepStrictEqual(paths, ["complete.txt"]); + }), + ); + + it.effect("keeps the final path when NUL-separated git output is complete", () => + Effect.sync(() => { + const paths = splitNullSeparatedGitStdoutPaths({ + stdout: "complete.txt\0final.txt", + stdoutTruncated: false, + }); + + assert.deepStrictEqual(paths, ["complete.txt", "final.txt"]); + }), + ); + }); + describe("repository status", () => { it.effect("reports non-repository directories without failing", () => Effect.gen(function* () { diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 3a05dea633f..54486232043 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -14,9 +14,15 @@ import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; +import { createHash } from "node:crypto"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { GitCommandError, type VcsRef } from "@t3tools/contracts"; +import { + GitCommandError, + type ReviewDiffPreviewInput, + type ReviewDiffPreviewSource, + type VcsRef, +} from "@t3tools/contracts"; import { dedupeRemoteBranchesWithLocalMatches } from "@t3tools/shared/git"; import { compactTraceAttributes } from "@t3tools/shared/observability"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; @@ -28,7 +34,6 @@ import { parseRemoteRefWithRemoteNames, } from "../git/remoteRefs.ts"; import { ServerConfig } from "../config.ts"; -const isGitCommandError = Schema.is(GitCommandError); const DEFAULT_TIMEOUT_MS = 30_000; const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; @@ -37,8 +42,15 @@ const PREPARED_COMMIT_PATCH_MAX_OUTPUT_BYTES = 49_000; const RANGE_COMMIT_SUMMARY_MAX_OUTPUT_BYTES = 19_000; const RANGE_DIFF_SUMMARY_MAX_OUTPUT_BYTES = 19_000; const RANGE_DIFF_PATCH_MAX_OUTPUT_BYTES = 59_000; +const REVIEW_DIFF_PATCH_MAX_OUTPUT_BYTES = 180_000; +const REVIEW_UNTRACKED_DIFF_MAX_OUTPUT_BYTES = 40_000; +const WORKSPACE_FILES_MAX_OUTPUT_BYTES = 16 * 1024 * 1024; const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15); const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5); + +function reviewDiffHash(diff: string): string { + return createHash("sha256").update(diff).digest("hex"); +} const STATUS_UPSTREAM_REFRESH_FAILURE_COOLDOWN = Duration.seconds(5); const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048; const STATUS_UPSTREAM_REFRESH_ENV = Object.freeze({ @@ -185,6 +197,23 @@ function paginateBranches(input: { }; } +function splitNullSeparatedPaths(input: string, truncated: boolean): string[] { + const parts = input.split("\0"); + if (parts.length === 0) return []; + + if (truncated && parts[parts.length - 1]?.length) { + parts.pop(); + } + + return parts.filter((value) => value.length > 0); +} + +export function splitNullSeparatedGitStdoutPaths( + result: Pick, +): string[] { + return splitNullSeparatedPaths(result.stdout, result.stdoutTruncated); +} + function sanitizeRemoteName(value: string): string { const sanitized = value .trim() @@ -332,7 +361,7 @@ function toGitCommandError( detail: string, ) { return (cause: unknown) => - isGitCommandError(cause) + Schema.is(GitCommandError)(cause) ? cause : new GitCommandError({ operation: input.operation, @@ -1630,6 +1659,147 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }; }); + const readUntrackedReviewDiffs = Effect.fn("readUntrackedReviewDiffs")(function* (cwd: string) { + const untrackedResult = yield* executeGit( + "GitVcsDriver.readUntrackedReviewDiffs.list", + cwd, + ["ls-files", "--others", "--exclude-standard", "-z"], + { + maxOutputBytes: WORKSPACE_FILES_MAX_OUTPUT_BYTES, + truncateOutputAtMaxBytes: true, + }, + ); + const untrackedPaths = splitNullSeparatedGitStdoutPaths(untrackedResult); + if (untrackedPaths.length === 0) { + return { diff: "", truncated: untrackedResult.stdoutTruncated }; + } + + const diffs = yield* Effect.forEach( + untrackedPaths, + (relativePath) => + executeGit( + "GitVcsDriver.readUntrackedReviewDiffs.diff", + cwd, + ["diff", "--no-index", "--patch", "--minimal", "--", "/dev/null", relativePath], + { + allowNonZeroExit: true, + maxOutputBytes: REVIEW_UNTRACKED_DIFF_MAX_OUTPUT_BYTES, + truncateOutputAtMaxBytes: true, + }, + ), + { concurrency: 4 }, + ); + + return { + diff: diffs + .map((result) => result.stdout) + .filter((diff) => diff.trim().length > 0) + .join("\n"), + truncated: untrackedResult.stdoutTruncated || diffs.some((result) => result.stdoutTruncated), + }; + }); + + const getReviewDiffPreview = Effect.fn("getReviewDiffPreview")(function* ( + input: ReviewDiffPreviewInput, + ) { + const details = yield* statusDetailsLocal(input.cwd); + if (!details.isRepo) { + return { + cwd: input.cwd, + generatedAt: yield* DateTime.now, + sources: [], + }; + } + + const branch = details.branch; + const baseRef = + input.baseRef ?? + (branch + ? yield* resolveBaseBranchForNoUpstream(input.cwd, branch).pipe( + Effect.catch(() => Effect.succeed(null)), + ) + : null); + + const dirtyTrackedResult = yield* executeGit( + "GitVcsDriver.getReviewDiffPreview.dirtyTracked", + input.cwd, + ["diff", "--patch", "--minimal", "HEAD", "--"], + { + maxOutputBytes: REVIEW_DIFF_PATCH_MAX_OUTPUT_BYTES, + truncateOutputAtMaxBytes: true, + }, + ).pipe( + Effect.catch(() => + Effect.succeed({ + exitCode: 0, + stdout: "", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }), + ), + ); + const dirtyUntracked = yield* readUntrackedReviewDiffs(input.cwd).pipe( + Effect.catch(() => Effect.succeed({ diff: "", truncated: false })), + ); + const dirtyDiff = [dirtyTrackedResult.stdout.trimEnd(), dirtyUntracked.diff.trimEnd()] + .filter((diff) => diff.length > 0) + .join("\n"); + + const baseResult = + baseRef && branch + ? yield* executeGit( + "GitVcsDriver.getReviewDiffPreview.base", + input.cwd, + ["diff", "--patch", "--minimal", `${baseRef}...HEAD`], + { + maxOutputBytes: REVIEW_DIFF_PATCH_MAX_OUTPUT_BYTES, + truncateOutputAtMaxBytes: true, + }, + ).pipe( + Effect.catch(() => + Effect.succeed({ + exitCode: 0, + stdout: "", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }), + ), + ) + : null; + const baseDiff = baseResult?.stdout ?? ""; + + const sources: ReviewDiffPreviewSource[] = [ + { + id: "working-tree", + kind: "working-tree", + title: "Dirty worktree", + baseRef: "HEAD", + headRef: null, + diff: dirtyDiff, + diffHash: reviewDiffHash(dirtyDiff), + truncated: dirtyTrackedResult.stdoutTruncated || dirtyUntracked.truncated, + }, + { + id: "branch-range", + kind: "branch-range", + title: baseRef ? `Against ${baseRef}` : "Against base branch", + baseRef, + headRef: branch ?? "HEAD", + diff: baseDiff, + diffHash: reviewDiffHash(baseDiff), + truncated: baseResult?.stdoutTruncated ?? false, + }, + ]; + + return { + cwd: input.cwd, + generatedAt: yield* DateTime.now, + sources, + }; + }); + const readConfigValue: GitVcsDriver.GitVcsDriverShape["readConfigValue"] = (cwd, key) => runGitStdout("GitVcsDriver.readConfigValue", cwd, ["config", "--get", key], true).pipe( Effect.map((stdout) => stdout.trim()), @@ -2124,6 +2294,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* pushCurrentBranch, pullCurrentBranch, readRangeContext, + getReviewDiffPreview, readConfigValue, listRefs, createWorktree, diff --git a/apps/server/src/vcs/VcsDriver.ts b/apps/server/src/vcs/VcsDriver.ts index 3bc524ca019..9eb2febd45f 100644 --- a/apps/server/src/vcs/VcsDriver.ts +++ b/apps/server/src/vcs/VcsDriver.ts @@ -7,6 +7,8 @@ import type { VcsInitInput, VcsListRemotesResult, VcsListWorkspaceFilesResult, + ReviewDiffPreviewInput, + ReviewDiffPreviewResult, VcsRepositoryIdentity, } from "@t3tools/contracts"; import * as VcsProcess from "./VcsProcess.ts"; @@ -27,6 +29,9 @@ export interface VcsDriverShape { relativePaths: ReadonlyArray, ) => Effect.Effect, VcsError>; readonly initRepository: (input: VcsInitInput) => Effect.Effect; + readonly getDiffPreview?: ( + input: ReviewDiffPreviewInput, + ) => Effect.Effect; } export class VcsDriver extends Context.Service()("t3/vcs/VcsDriver") {} diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 001c9baff9e..1f9e15b1b54 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -29,7 +29,10 @@ import { OrchestrationReplayEventsError, FilesystemBrowseError, ThreadId, + type TerminalAttachStreamEvent, + type TerminalError, type TerminalEvent, + type TerminalMetadataStreamEvent, WS_METHODS, WsRpcGroup, } from "@t3tools/contracts"; @@ -61,6 +64,7 @@ import { WorkspacePathOutsideRootError } from "./workspace/Services/WorkspacePat import { VcsStatusBroadcaster } from "./vcs/VcsStatusBroadcaster.ts"; import { VcsProvisioningService } from "./vcs/VcsProvisioningService.ts"; import { GitWorkflowService } from "./git/GitWorkflowService.ts"; +import { ReviewService } from "./review/ReviewService.ts"; import { ProjectSetupScriptRunner } from "./project/Services/ProjectSetupScriptRunner.ts"; import { RepositoryIdentityResolver } from "./project/Services/RepositoryIdentityResolver.ts"; import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts"; @@ -165,6 +169,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => const keybindings = yield* Keybindings; const open = yield* Open; const gitWorkflow = yield* GitWorkflowService; + const review = yield* ReviewService; const vcsProvisioning = yield* VcsProvisioningService; const vcsStatusBroadcaster = yield* VcsStatusBroadcaster; const terminalManager = yield* TerminalManager; @@ -1092,10 +1097,25 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => .pipe(Effect.tap(() => refreshGitStatus(input.cwd))), { "rpc.aggregate": "vcs" }, ), + [WS_METHODS.reviewGetDiffPreview]: (input) => + observeRpcEffect(WS_METHODS.reviewGetDiffPreview, review.getDiffPreview(input), { + "rpc.aggregate": "review", + }), [WS_METHODS.terminalOpen]: (input) => observeRpcEffect(WS_METHODS.terminalOpen, terminalManager.open(input), { "rpc.aggregate": "terminal", }), + [WS_METHODS.terminalAttach]: (input) => + observeRpcStream( + WS_METHODS.terminalAttach, + Stream.callback((queue) => + Effect.acquireRelease( + terminalManager.attachStream(input, (event) => Queue.offer(queue, event)), + (unsubscribe) => Effect.sync(unsubscribe), + ), + ), + { "rpc.aggregate": "terminal" }, + ), [WS_METHODS.terminalWrite]: (input) => observeRpcEffect(WS_METHODS.terminalWrite, terminalManager.write(input), { "rpc.aggregate": "terminal", @@ -1127,6 +1147,17 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => ), { "rpc.aggregate": "terminal" }, ), + [WS_METHODS.subscribeTerminalMetadata]: (_input) => + observeRpcStream( + WS_METHODS.subscribeTerminalMetadata, + Stream.callback((queue) => + Effect.acquireRelease( + terminalManager.subscribeMetadata((event) => Queue.offer(queue, event)), + (unsubscribe) => Effect.sync(unsubscribe), + ), + ), + { "rpc.aggregate": "terminal" }, + ), [WS_METHODS.subscribeServerConfig]: (_input) => observeRpcStreamEffect( WS_METHODS.subscribeServerConfig, diff --git a/apps/web/package.json b/apps/web/package.json index fe91425c019..c5090142a52 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -35,8 +35,8 @@ "effect": "catalog:", "lexical": "^0.41.0", "lucide-react": "^0.564.0", - "react": "^19.0.0", - "react-dom": "^19.0.0", + "react": "19.2.5", + "react-dom": "19.2.5", "react-markdown": "^10.1.0", "remark-gfm": "^4.0.1", "tailwind-merge": "^3.4.0", @@ -48,8 +48,8 @@ "@tailwindcss/vite": "^4.0.0", "@tanstack/router-plugin": "^1.161.0", "@types/babel__core": "^7.20.5", - "@types/react": "^19.0.0", - "@types/react-dom": "^19.0.0", + "@types/react": "~19.2.0", + "@types/react-dom": "~19.2.0", "@vercel/config": "^0.3.0", "@vitejs/plugin-react": "^6.0.0", "@vitest/browser-playwright": "^4.0.18", diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 2e30edfc02c..152df1bf3e5 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -1,6 +1,5 @@ import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime"; import type { EnvironmentId, VcsRef, ThreadId } from "@t3tools/contracts"; -import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; import { LegendList, type LegendListRef } from "@legendapp/list/react"; import { ChevronDownIcon } from "lucide-react"; import { @@ -16,8 +15,8 @@ import { import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; import { readEnvironmentApi } from "../environmentApi"; -import { gitBranchSearchInfiniteQueryOptions, gitQueryKeys } from "../lib/gitReactQuery"; -import { useGitStatus } from "../lib/gitStatusState"; +import { useVcsStatus } from "../lib/vcsStatusState"; +import { useVcsRefs, vcsRefManager } from "../lib/vcsRefState"; import { newCommandId } from "../lib/utils"; import { cn } from "../lib/utils"; import { parsePullRequestReference } from "../pullRequestReference"; @@ -59,6 +58,8 @@ interface BranchToolbarBranchSelectorProps { onComposerFocusRequest?: () => void; } +const EMPTY_REFS: ReadonlyArray = []; + function toBranchActionErrorMessage(error: unknown): string { return error instanceof Error ? error.message : "An error occurred."; } @@ -196,39 +197,27 @@ export function BranchToolbarBranchSelector({ // --------------------------------------------------------------------------- // Git ref queries // --------------------------------------------------------------------------- - const queryClient = useQueryClient(); const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false); const [branchQuery, setBranchQuery] = useState(""); const deferredBranchQuery = useDeferredValue(branchQuery); - const branchStatusQuery = useGitStatus({ environmentId, cwd: branchCwd }); + const branchStatusQuery = useVcsStatus({ environmentId, cwd: branchCwd }); const trimmedBranchQuery = branchQuery.trim(); const deferredTrimmedBranchQuery = deferredBranchQuery.trim(); - - useEffect(() => { - if (!branchCwd) return; - void queryClient.prefetchInfiniteQuery( - gitBranchSearchInfiniteQueryOptions({ environmentId, cwd: branchCwd, query: "" }), - ); - }, [branchCwd, environmentId, queryClient]); - - const { - data: branchesSearchData, - fetchNextPage, - hasNextPage, - isFetchingNextPage, - isPending: isBranchesSearchPending, - } = useInfiniteQuery( - gitBranchSearchInfiniteQueryOptions({ + const branchRefTarget = useMemo( + () => ({ environmentId, cwd: branchCwd, query: deferredTrimmedBranchQuery, }), + [branchCwd, deferredTrimmedBranchQuery, environmentId], ); - const refs = useMemo( - () => branchesSearchData?.pages.flatMap((page) => page.refs) ?? [], - [branchesSearchData?.pages], - ); + const branchRefState = useVcsRefs(branchRefTarget); + const refs = branchRefState.data?.refs ?? EMPTY_REFS; + const hasNextPage = + branchRefState.data?.nextCursor !== null && branchRefState.data?.nextCursor !== undefined; + const [isFetchingNextPage, setIsFetchingNextPage] = useState(false); + const isInitialBranchesLoadPending = branchRefState.isPending && branchRefState.data === null; const currentGitBranch = branchStatusQuery.data?.refName ?? refs.find((refName) => refName.current)?.name ?? null; const sourceControlPresentation = useMemo( @@ -293,8 +282,8 @@ export function BranchToolbarBranchSelector({ ); const [isBranchActionPending, startBranchActionTransition] = useTransition(); const shouldVirtualizeBranchList = filteredBranchPickerItems.length > 40; - const totalBranchCount = branchesSearchData?.pages[0]?.totalCount ?? 0; - const branchStatusText = isBranchesSearchPending + const totalBranchCount = branchRefState.data?.totalCount ?? 0; + const branchStatusText = isInitialBranchesLoadPending ? "Loading refs..." : isFetchingNextPage ? "Loading more refs..." @@ -308,8 +297,8 @@ export function BranchToolbarBranchSelector({ const runBranchAction = (action: () => Promise) => { startBranchActionTransition(async () => { await action().catch(() => undefined); - await queryClient - .invalidateQueries({ queryKey: gitQueryKeys.refs(environmentId, branchCwd) }) + await vcsRefManager + .load(branchRefTarget, undefined, { limit: 100, preserveLoadedRefs: true }) .catch(() => undefined); }); }; @@ -425,14 +414,25 @@ export function BranchToolbarBranchSelector({ setBranchQuery(""); return; } - void queryClient.invalidateQueries({ - queryKey: gitQueryKeys.refs(environmentId, branchCwd), - }); + void vcsRefManager + .load(branchRefTarget, undefined, { limit: 100, preserveLoadedRefs: true }) + .catch(() => undefined); }, - [branchCwd, environmentId, queryClient], + [branchRefTarget], ); const branchListScrollElementRef = useRef(null); + const fetchNextBranchPage = useCallback(() => { + if (!hasNextPage || isFetchingNextPage) { + return; + } + + setIsFetchingNextPage(true); + void vcsRefManager + .loadNext(branchRefTarget, undefined, { limit: 100 }) + .catch(() => undefined) + .finally(() => setIsFetchingNextPage(false)); + }, [branchRefTarget, hasNextPage, isFetchingNextPage]); const maybeFetchNextBranchPage = useCallback(() => { if (!isBranchMenuOpen || !hasNextPage || isFetchingNextPage) { return; @@ -449,8 +449,8 @@ export function BranchToolbarBranchSelector({ return; } - void fetchNextPage().catch(() => undefined); - }, [fetchNextPage, hasNextPage, isBranchMenuOpen, isFetchingNextPage]); + fetchNextBranchPage(); + }, [fetchNextBranchPage, hasNextPage, isBranchMenuOpen, isFetchingNextPage]); const branchListRef = useRef(null); const setBranchListRef = useCallback((element: HTMLDivElement | null) => { branchListScrollElementRef.current = (element?.parentElement as HTMLDivElement | null) ?? null; @@ -592,7 +592,7 @@ export function BranchToolbarBranchSelector({ } className={cn("min-w-0 text-muted-foreground/70 hover:text-foreground/80", className)} - disabled={(isBranchesSearchPending && refs.length === 0) || isBranchActionPending} + disabled={isInitialBranchesLoadPending || isBranchActionPending} > {triggerLabel} @@ -622,7 +622,7 @@ export function BranchToolbarBranchSelector({ drawDistance={336} onEndReached={() => { if (hasNextPage && !isFetchingNextPage) { - void fetchNextPage().catch(() => undefined); + fetchNextBranchPage(); } }} style={{ maxHeight: "14rem" }} diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index bb62d8edbc0..f9a48dbd82a 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -12,12 +12,14 @@ import { ProviderDriverKind, ProviderInstanceId, type ServerConfig, + type TerminalMetadataStreamEvent, type ServerLifecycleWelcomePayload, type ThreadId, type TurnId, WS_METHODS, OrchestrationSessionStatus, DEFAULT_SERVER_SETTINGS, + DEFAULT_TERMINAL_ID, ServerConfig as ServerConfigSchema, } from "@t3tools/contracts"; import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime"; @@ -55,19 +57,46 @@ import { getServerConfig } from "../rpc/serverState"; import { getRouter } from "../router"; import { deriveLogicalProjectKeyFromSettings } from "../logicalProject"; import { selectBootstrapCompleteForActiveEnvironment, useStore } from "../store"; -import { useTerminalStateStore } from "../terminalStateStore"; +import { terminalSessionManager } from "../terminalSessionState"; +import { useTerminalUiStateStore } from "../terminalUiStateStore"; import { useUiStateStore } from "../uiStateStore"; import { createAuthenticatedSessionHandlers } from "../../test/authHttpHandlers"; import { BrowserWsRpcHarness, type NormalizedWsRpcRequestBody } from "../../test/wsRpcHarness"; import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; -vi.mock("../lib/gitStatusState", () => ({ - useGitStatus: () => ({ data: null, error: null, cause: null, isPending: false }), - useGitStatuses: () => new Map(), - refreshGitStatus: () => Promise.resolve(null), - resetGitStatusStateForTests: () => undefined, -})); +vi.mock("../lib/vcsStatusState", () => { + const status = { + data: { + isRepo: true, + sourceControlProvider: { + kind: "github", + name: "GitHub", + baseUrl: "https://github.com", + }, + hasPrimaryRemote: true, + isDefaultRef: true, + refName: "main", + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + pr: null, + }, + error: null, + cause: null, + isPending: false, + }; + + return { + getVcsStatusSnapshot: () => status, + useVcsStatus: () => status, + useVcsStatuses: () => new Map(), + refreshVcsStatus: () => Promise.resolve(null), + resetVcsStatusStateForTests: () => undefined, + }; +}); const THREAD_ID = "thread-browser-test" as ThreadId; const THREAD_TITLE = "Browser test thread"; @@ -101,6 +130,7 @@ interface TestFixture { snapshot: OrchestrationReadModel; serverConfig: ServerConfig; welcome: ServerLifecycleWelcomePayload; + terminalMetadataEvents: ReadonlyArray; } let fixture: TestFixture; @@ -213,6 +243,7 @@ function createMockEnvironmentApi(input: { sourceControl: {} as EnvironmentApi["sourceControl"], vcs: {} as EnvironmentApi["vcs"], git: {} as EnvironmentApi["git"], + review: {} as EnvironmentApi["review"], orchestration: { dispatchCommand: input.dispatchCommand, getTurnDiff: (() => { @@ -398,6 +429,7 @@ function buildFixture(snapshot: OrchestrationReadModel): TestFixture { bootstrapProjectId: PROJECT_ID, bootstrapThreadId: THREAD_ID, }, + terminalMetadataEvents: [], }; } @@ -1068,6 +1100,7 @@ function resolveWsRpc(body: NormalizedWsRpcRequestBody): unknown { history: "", exitCode: null, exitSignal: null, + label: "Terminal 1", updatedAt: NOW_ISO, }; } @@ -1691,6 +1724,9 @@ describe("ChatView timeline estimator parity (full app)", () => { ] : []; } + if (request._tag === WS_METHODS.subscribeTerminalMetadata) { + return fixture.terminalMetadataEvents; + } return []; }, }); @@ -1724,12 +1760,9 @@ describe("ChatView timeline estimator parity (full app)", () => { projectOrder: [], threadLastVisitedAtById: {}, }); - useTerminalStateStore.persist.clearStorage(); - useTerminalStateStore.setState({ - terminalStateByThreadKey: {}, - terminalLaunchContextByThreadKey: {}, - terminalEventEntriesByKey: {}, - nextTerminalEventId: 1, + useTerminalUiStateStore.persist.clearStorage(); + useTerminalUiStateStore.setState({ + terminalUiStateByThreadKey: {}, }); }); @@ -1906,37 +1939,50 @@ describe("ChatView timeline estimator parity (full app)", () => { }); } - useTerminalStateStore.setState({ - terminalStateByThreadKey: { + useTerminalUiStateStore.setState({ + terminalUiStateByThreadKey: { [THREAD_KEY]: { terminalOpen: true, terminalHeight: 280, terminalIds: ["default"], - runningTerminalIds: [], activeTerminalId: "default", terminalGroups: [{ id: "group-default", terminalIds: ["default"] }], activeTerminalGroupId: "group-default", }, }, - terminalLaunchContextByThreadKey: { - [THREAD_KEY]: { - cwd: "/repo/project", - worktreePath: null, - }, - }, }); const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, snapshot, + configureFixture: (nextFixture) => { + nextFixture.terminalMetadataEvents = [ + { + type: "upsert", + terminal: { + threadId: THREAD_ID, + terminalId: DEFAULT_TERMINAL_ID, + cwd: "/repo/project", + worktreePath: null, + status: "running", + pid: 123, + exitCode: null, + exitSignal: null, + hasRunningSubprocess: false, + label: "Terminal 1", + updatedAt: isoAt(0), + }, + }, + ]; + }, }); try { await vi.waitFor( () => { - const openRequest = wsRequests.find( - (request) => request._tag === WS_METHODS.terminalOpen, - ) as + const attachRequest = wsRequests + .toReversed() + .find((request) => request._tag === WS_METHODS.terminalAttach) as | { _tag: string; cwd?: string; @@ -1944,15 +1990,15 @@ describe("ChatView timeline estimator parity (full app)", () => { env?: Record; } | undefined; - expect(openRequest).toMatchObject({ - _tag: WS_METHODS.terminalOpen, + expect(attachRequest).toMatchObject({ + _tag: WS_METHODS.terminalAttach, cwd: "/repo/project", worktreePath: null, env: { T3CODE_PROJECT_ROOT: "/repo/project", }, }); - expect(openRequest?.env?.T3CODE_WORKTREE_PATH).toBeUndefined(); + expect(attachRequest?.env?.T3CODE_WORKTREE_PATH).toBeUndefined(); }, { timeout: 8_000, interval: 16 }, ); @@ -2483,8 +2529,8 @@ describe("ChatView timeline estimator parity (full app)", () => { }); it("sends bootstrap turn-starts and waits for server setup on first-send worktree drafts", async () => { - useTerminalStateStore.setState({ - terminalStateByThreadKey: {}, + useTerminalUiStateStore.setState({ + terminalUiStateByThreadKey: {}, }); useComposerDraftStore.setState({ draftThreadsByThreadKey: { @@ -2987,8 +3033,8 @@ describe("ChatView timeline estimator parity (full app)", () => { }); it("shows the send state once bootstrap dispatch is in flight", async () => { - useTerminalStateStore.setState({ - terminalStateByThreadKey: {}, + useTerminalUiStateStore.setState({ + terminalUiStateByThreadKey: {}, }); useComposerDraftStore.setState({ draftThreadsByThreadKey: { @@ -3811,6 +3857,68 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("shows the sidebar terminal indicator from terminal metadata activity", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-terminal-metadata-indicator" as MessageId, + targetText: "terminal metadata indicator target", + }), + configureFixture: (nextFixture) => { + nextFixture.terminalMetadataEvents = [ + { + type: "upsert", + terminal: { + threadId: THREAD_ID, + terminalId: DEFAULT_TERMINAL_ID, + cwd: "/repo/project", + worktreePath: null, + status: "running", + pid: 123, + exitCode: null, + exitSignal: null, + hasRunningSubprocess: true, + label: "Terminal 1", + updatedAt: isoAt(1_200), + }, + }, + ]; + }, + }); + + try { + await vi.waitFor( + () => { + expect( + terminalSessionManager.listSessions({ + environmentId: LOCAL_ENVIRONMENT_ID, + threadId: THREAD_ID, + }), + ).toMatchObject([ + { + state: { + hasRunningSubprocess: true, + }, + }, + ]); + }, + { timeout: 8_000, interval: 16 }, + ); + + await vi.waitFor( + () => { + const terminalIndicator = document.querySelector( + '[aria-label="Terminal process running"]', + ); + expect(terminalIndicator).not.toBeNull(); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + it("shows the confirm archive action after clicking the archive button", async () => { localStorage.setItem( "t3code:client-settings:v1", diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 6b84aa11ca6..be39d4b2087 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -34,11 +34,12 @@ import { } from "@t3tools/shared/model"; import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; import { truncate } from "@t3tools/shared/String"; +import { nextTerminalId, resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels"; import { Debouncer } from "@tanstack/react-pacer"; import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useNavigate, useSearch } from "@tanstack/react-router"; import { useShallow } from "zustand/react/shallow"; -import { useGitStatus } from "~/lib/gitStatusState"; +import { useVcsStatus } from "~/lib/vcsStatusState"; import { usePrimaryEnvironmentId } from "../environments/primary"; import { readEnvironmentApi } from "../environmentApi"; import { isElectron } from "../env"; @@ -105,7 +106,7 @@ import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings" import PlanSidebar from "./PlanSidebar"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; import { ChevronDownIcon, TriangleAlertIcon, WifiOffIcon } from "lucide-react"; -import { cn, randomUUID } from "~/lib/utils"; +import { cn } from "~/lib/utils"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; import { type NewProjectScriptInput } from "./ProjectScriptsControl"; @@ -138,7 +139,8 @@ import { type TerminalContextDraft, type TerminalContextSelection, } from "../lib/terminalContext"; -import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; +import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; +import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../terminalSessionState"; import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer"; import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; @@ -425,6 +427,45 @@ function useLocalDispatchState(input: { }; } +/** Same terminal ids (order ignored) — avoids reconcile when only server session ordering differs. */ +function terminalIdListsEqual(left: readonly string[], right: readonly string[]): boolean { + if (left.length !== right.length) { + return false; + } + if (left.length === 0) { + return true; + } + const sortedLeft = [...left].sort((a, b) => a.localeCompare(b)); + const sortedRight = [...right].sort((a, b) => a.localeCompare(b)); + for (let index = 0; index < sortedLeft.length; index += 1) { + if (sortedLeft[index] !== sortedRight[index]) { + return false; + } + } + return true; +} + +/** + * Server knows about fewer sessions than the client, but every server id still exists locally. + * Typical right after `terminal.open`: known-session list lags; reconciling would drop the new id + * and later re-add it as a separate group (no split layout). + */ +function serverTerminalIdsStrictSubsetOfClient( + serverIds: readonly string[], + clientIds: readonly string[], +): boolean { + if (serverIds.length >= clientIds.length || clientIds.length === 0) { + return false; + } + const clientSet = new Set(clientIds); + for (const id of serverIds) { + if (!clientSet.has(id)) { + return false; + } + } + return true; +} + interface PersistentThreadTerminalDrawerProps { threadRef: { environmentId: EnvironmentId; threadId: ThreadId }; threadId: ThreadId; @@ -458,14 +499,77 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) : null; const project = useStore(useMemo(() => createProjectSelectorByRef(projectRef), [projectRef])); - const terminalState = useTerminalStateStore((state) => - selectThreadTerminalState(state.terminalStateByThreadKey, threadRef), - ); - const storeSetTerminalHeight = useTerminalStateStore((state) => state.setTerminalHeight); - const storeSplitTerminal = useTerminalStateStore((state) => state.splitTerminal); - const storeNewTerminal = useTerminalStateStore((state) => state.newTerminal); - const storeSetActiveTerminal = useTerminalStateStore((state) => state.setActiveTerminal); - const storeCloseTerminal = useTerminalStateStore((state) => state.closeTerminal); + const terminalUiState = useTerminalUiStateStore((state) => + selectThreadTerminalUiState(state.terminalUiStateByThreadKey, threadRef), + ); + const knownTerminalSessions = useKnownTerminalSessions({ + environmentId: threadRef.environmentId, + threadId, + }); + const terminalLabelsById = useMemo(() => { + const next = new Map(); + for (const session of knownTerminalSessions) { + next.set( + session.target.terminalId, + resolveTerminalSessionLabel(session.target.terminalId, session.state.summary), + ); + } + return next; + }, [knownTerminalSessions]); + const terminalLaunchLocationsById = useMemo(() => { + const next = new Map< + string, + { + readonly cwd: string; + readonly worktreePath: string | null; + readonly runtimeEnv: Record; + } + >(); + if (!project) { + return next; + } + + for (const session of knownTerminalSessions) { + const summary = session.state.summary; + if (!summary) { + continue; + } + const worktreePathForLaunch = + launchContext !== null ? launchContext.worktreePath : summary.worktreePath; + next.set(session.target.terminalId, { + cwd: launchContext?.cwd ?? summary.cwd, + worktreePath: worktreePathForLaunch, + runtimeEnv: projectScriptRuntimeEnv({ + project: { cwd: project.cwd }, + worktreePath: worktreePathForLaunch, + }), + }); + } + + return next; + }, [knownTerminalSessions, launchContext, project]); + const serverOrderedTerminalIds = useMemo( + () => knownTerminalSessions.map((session) => session.target.terminalId), + [knownTerminalSessions], + ); + const storeSetTerminalHeight = useTerminalUiStateStore((state) => state.setTerminalHeight); + const storeSplitTerminal = useTerminalUiStateStore((state) => state.splitTerminal); + const storeNewTerminal = useTerminalUiStateStore((state) => state.newTerminal); + const storeSetActiveTerminal = useTerminalUiStateStore((state) => state.setActiveTerminal); + const storeCloseTerminal = useTerminalUiStateStore((state) => state.closeTerminal); + const reconcileTerminalIds = useTerminalUiStateStore((state) => state.reconcileTerminalIds); + + useEffect(() => { + if (terminalIdListsEqual(serverOrderedTerminalIds, terminalUiState.terminalIds)) { + return; + } + if ( + serverTerminalIdsStrictSubsetOfClient(serverOrderedTerminalIds, terminalUiState.terminalIds) + ) { + return; + } + reconcileTerminalIds(threadRef, serverOrderedTerminalIds); + }, [reconcileTerminalIds, serverOrderedTerminalIds, terminalUiState.terminalIds, threadRef]); const [localFocusRequestId, setLocalFocusRequestId] = useState(0); const worktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; const effectiveWorktreePath = useMemo(() => { @@ -511,14 +615,68 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra ); const splitTerminal = useCallback(() => { - storeSplitTerminal(threadRef, `terminal-${randomUUID()}`); + const api = readEnvironmentApi(threadRef.environmentId); + if (!api || !cwd) { + return; + } + const terminalId = nextTerminalId(serverOrderedTerminalIds); + storeSplitTerminal(threadRef, terminalId); bumpFocusRequestId(); - }, [bumpFocusRequestId, storeSplitTerminal, threadRef]); + void (async () => { + try { + await api.terminal.open({ + threadId, + terminalId, + cwd, + ...(effectiveWorktreePath != null ? { worktreePath: effectiveWorktreePath } : {}), + env: runtimeEnv, + }); + } catch { + // Opening failed; the tab is already in the store — user can retry or close it. + } + })(); + }, [ + bumpFocusRequestId, + cwd, + effectiveWorktreePath, + runtimeEnv, + serverOrderedTerminalIds, + storeSplitTerminal, + threadId, + threadRef, + ]); const createNewTerminal = useCallback(() => { - storeNewTerminal(threadRef, `terminal-${randomUUID()}`); + const api = readEnvironmentApi(threadRef.environmentId); + if (!api || !cwd) { + return; + } + const terminalId = nextTerminalId(serverOrderedTerminalIds); + storeNewTerminal(threadRef, terminalId); bumpFocusRequestId(); - }, [bumpFocusRequestId, storeNewTerminal, threadRef]); + void (async () => { + try { + await api.terminal.open({ + threadId, + terminalId, + cwd, + ...(effectiveWorktreePath != null ? { worktreePath: effectiveWorktreePath } : {}), + env: runtimeEnv, + }); + } catch { + // Opening failed; the tab is already in the store — user can retry or close it. + } + })(); + }, [ + bumpFocusRequestId, + cwd, + effectiveWorktreePath, + runtimeEnv, + serverOrderedTerminalIds, + storeNewTerminal, + threadId, + threadRef, + ]); const activateTerminal = useCallback( (terminalId: string) => { @@ -532,7 +690,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra (terminalId: string) => { const api = readEnvironmentApi(threadRef.environmentId); if (!api) return; - const isFinalTerminal = terminalState.terminalIds.length <= 1; + const isFinalTerminal = terminalUiState.terminalIds.length <= 1; const fallbackExitWrite = () => api.terminal.write({ threadId, terminalId, data: "exit\n" }).catch(() => undefined); @@ -554,7 +712,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra storeCloseTerminal(threadRef, terminalId); bumpFocusRequestId(); }, - [bumpFocusRequestId, storeCloseTerminal, terminalState.terminalIds.length, threadId, threadRef], + [bumpFocusRequestId, storeCloseTerminal, terminalUiState.terminalIds, threadId, threadRef], ); const handleAddTerminalContext = useCallback( @@ -567,7 +725,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra [onAddTerminalContext, visible], ); - if (!project || !terminalState.terminalOpen || !cwd) { + if (!project || !terminalUiState.terminalOpen || !cwd) { return null; } @@ -580,11 +738,12 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra worktreePath={effectiveWorktreePath} runtimeEnv={runtimeEnv} visible={visible} - height={terminalState.terminalHeight} - terminalIds={terminalState.terminalIds} - activeTerminalId={terminalState.activeTerminalId} - terminalGroups={terminalState.terminalGroups} - activeTerminalGroupId={terminalState.activeTerminalGroupId} + height={terminalUiState.terminalHeight} + // Known-session order is MRU and changes on focus; persisted store order keeps sidebar labels stable. + terminalIds={terminalUiState.terminalIds} + activeTerminalId={terminalUiState.activeTerminalId} + terminalGroups={terminalUiState.terminalGroups} + activeTerminalGroupId={terminalUiState.activeTerminalGroupId} focusRequestId={focusRequestId + localFocusRequestId + (visible ? 1 : 0)} onSplitTerminal={splitTerminal} onNewTerminal={createNewTerminal} @@ -596,6 +755,8 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra onCloseTerminal={closeTerminal} onHeightChange={setTerminalHeight} onAddTerminalContext={handleAddTerminalContext} + terminalLabelsById={terminalLabelsById} + terminalLaunchLocationsById={terminalLaunchLocationsById} /> ); @@ -710,9 +871,8 @@ export default function ChatView(props: ChatViewProps) { const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); const [pullRequestDialogState, setPullRequestDialogState] = useState(null); - const [terminalLaunchContext, setTerminalLaunchContext] = useState( - null, - ); + const [terminalUiLaunchContext, setTerminalUiLaunchContext] = + useState(null); const [attachmentPreviewHandoffByMessageId, setAttachmentPreviewHandoffByMessageId] = useState< Record >({}); @@ -729,23 +889,24 @@ export default function ChatView(props: ChatViewProps) { const attachmentPreviewHandoffByMessageIdRef = useRef>({}); const attachmentPreviewPromotionInFlightByMessageIdRef = useRef>({}); const sendInFlightRef = useRef(false); - const terminalOpenByThreadRef = useRef>({}); + const terminalUiOpenByThreadRef = useRef>({}); - const terminalState = useTerminalStateStore((state) => - selectThreadTerminalState(state.terminalStateByThreadKey, routeThreadRef), + const terminalUiState = useTerminalUiStateStore((state) => + selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef), ); - const openTerminalThreadKeys = useTerminalStateStore( + const openTerminalThreadKeys = useTerminalUiStateStore( useShallow((state) => - Object.entries(state.terminalStateByThreadKey).flatMap(([nextThreadKey, nextTerminalState]) => - nextTerminalState.terminalOpen ? [nextThreadKey] : [], + Object.entries(state.terminalUiStateByThreadKey).flatMap( + ([nextThreadKey, nextTerminalUiState]) => + nextTerminalUiState.terminalOpen ? [nextThreadKey] : [], ), ), ); - const storeSetTerminalOpen = useTerminalStateStore((s) => s.setTerminalOpen); - const storeSplitTerminal = useTerminalStateStore((s) => s.splitTerminal); - const storeNewTerminal = useTerminalStateStore((s) => s.newTerminal); - const storeSetActiveTerminal = useTerminalStateStore((s) => s.setActiveTerminal); - const storeCloseTerminal = useTerminalStateStore((s) => s.closeTerminal); + const storeSetTerminalOpen = useTerminalUiStateStore((s) => s.setTerminalOpen); + const storeSplitTerminal = useTerminalUiStateStore((s) => s.splitTerminal); + const storeNewTerminal = useTerminalUiStateStore((s) => s.newTerminal); + const storeSetActiveTerminal = useTerminalUiStateStore((s) => s.setActiveTerminal); + const storeCloseTerminal = useTerminalUiStateStore((s) => s.closeTerminal); const serverThreadKeys = useStore( useShallow((state) => selectThreadsAcrossEnvironments(state).map((thread) => @@ -753,12 +914,6 @@ export default function ChatView(props: ChatViewProps) { ), ), ); - const storeServerTerminalLaunchContext = useTerminalStateStore( - (s) => s.terminalLaunchContextByThreadKey[scopedThreadKey(routeThreadRef)] ?? null, - ); - const storeClearTerminalLaunchContext = useTerminalStateStore( - (s) => s.clearTerminalLaunchContext, - ); const draftThreadsByThreadKey = useComposerDraftStore((store) => store.draftThreadsByThreadKey); const draftThreadKeys = useMemo( () => @@ -811,11 +966,60 @@ export default function ChatView(props: ChatViewProps) { const canCheckoutPullRequestIntoThread = isLocalDraftThread; const diffOpen = rawSearch.diff === "1"; const activeThreadId = activeThread?.id ?? null; + const runningTerminalIds = useThreadRunningTerminalIds({ + environmentId: activeThread?.environmentId ?? null, + threadId: activeThreadId, + }); + const activeThreadKnownSessionsRaw = useKnownTerminalSessions({ + environmentId: activeThread?.environmentId ?? null, + threadId: activeThreadId, + }); + const activeThreadKnownSessions = useMemo(() => { + if (activeThreadId === null) { + return []; + } + return activeThreadKnownSessionsRaw.filter( + (session) => session.target.threadId === activeThreadId, + ); + }, [activeThreadId, activeThreadKnownSessionsRaw]); + const activeServerOrderedTerminalIds = useMemo( + () => activeThreadKnownSessions.map((session) => session.target.terminalId), + [activeThreadKnownSessions], + ); + const activeKnownTerminalIds = useMemo( + () => [...new Set([...activeServerOrderedTerminalIds, ...terminalUiState.terminalIds])], + [activeServerOrderedTerminalIds, terminalUiState.terminalIds], + ); + const reconcileTerminalIds = useTerminalUiStateStore((state) => state.reconcileTerminalIds); const activeThreadRef = useMemo( () => (activeThread ? scopeThreadRef(activeThread.environmentId, activeThread.id) : null), [activeThread], ); const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null; + + useEffect(() => { + if (!activeThreadRef) { + return; + } + if (terminalIdListsEqual(activeServerOrderedTerminalIds, terminalUiState.terminalIds)) { + return; + } + if ( + serverTerminalIdsStrictSubsetOfClient( + activeServerOrderedTerminalIds, + terminalUiState.terminalIds, + ) + ) { + return; + } + reconcileTerminalIds(activeThreadRef, activeServerOrderedTerminalIds); + }, [ + activeThreadRef, + activeServerOrderedTerminalIds, + reconcileTerminalIds, + terminalUiState.terminalIds, + ]); + const existingOpenTerminalThreadKeys = useMemo(() => { const existingThreadKeys = new Set([...serverThreadKeys, ...draftThreadKeys]); return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey)); @@ -840,7 +1044,7 @@ export default function ChatView(props: ChatViewProps) { currentThreadIds, openThreadIds: existingOpenTerminalThreadKeys, activeThreadId: activeThreadKey, - activeThreadTerminalOpen: Boolean(activeThreadKey && terminalState.terminalOpen), + activeThreadTerminalOpen: Boolean(activeThreadKey && terminalUiState.terminalOpen), maxHiddenThreadCount: MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, }); return currentThreadIds.length === nextThreadIds.length && @@ -848,7 +1052,7 @@ export default function ChatView(props: ChatViewProps) { ? currentThreadIds : nextThreadIds; }); - }, [activeThreadKey, existingOpenTerminalThreadKeys, terminalState.terminalOpen]); + }, [activeThreadKey, existingOpenTerminalThreadKeys, terminalUiState.terminalOpen]); const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null); const activeProjectRef = activeThread ? scopeProjectRef(activeThread.environmentId, activeThread.projectId) @@ -1630,7 +1834,7 @@ export default function ChatView(props: ChatViewProps) { worktreePath: activeThread?.worktreePath ?? null, }) : null; - const gitStatusQuery = useGitStatus({ environmentId, cwd: gitCwd }); + const gitStatusQuery = useVcsStatus({ environmentId, cwd: gitCwd }); const keybindings = useServerKeybindings(); const availableEditors = useServerAvailableEditors(); // Prefer an instance-id match so a custom Codex instance (e.g. @@ -1655,28 +1859,26 @@ export default function ChatView(props: ChatViewProps) { const activeThreadWorktreePath = activeThread?.worktreePath ?? null; const activeWorkspaceRoot = activeThreadWorktreePath ?? activeProjectCwd ?? undefined; const activeTerminalLaunchContext = - terminalLaunchContext?.threadId === activeThreadId - ? terminalLaunchContext - : (storeServerTerminalLaunchContext ?? null); + terminalUiLaunchContext?.threadId === activeThreadId ? terminalUiLaunchContext : null; // Default true while loading to avoid toolbar flicker. const isGitRepo = gitStatusQuery.data?.isRepo ?? true; const terminalShortcutLabelOptions = useMemo( () => ({ context: { terminalFocus: true, - terminalOpen: Boolean(terminalState.terminalOpen), + terminalOpen: Boolean(terminalUiState.terminalOpen), }, }), - [terminalState.terminalOpen], + [terminalUiState.terminalOpen], ); const nonTerminalShortcutLabelOptions = useMemo( () => ({ context: { terminalFocus: false, - terminalOpen: Boolean(terminalState.terminalOpen), + terminalOpen: Boolean(terminalUiState.terminalOpen), }, }), - [terminalState.terminalOpen], + [terminalUiState.terminalOpen], ); const terminalToggleShortcutLabel = useMemo( () => shortcutLabelForCommand(keybindings, "terminal.toggle"), @@ -1743,11 +1945,11 @@ export default function ChatView(props: ChatViewProps) { ); const activeTerminalGroup = - terminalState.terminalGroups.find( - (group) => group.id === terminalState.activeTerminalGroupId, + terminalUiState.terminalGroups.find( + (group) => group.id === terminalUiState.activeTerminalGroupId, ) ?? - terminalState.terminalGroups.find((group) => - group.terminalIds.includes(terminalState.activeTerminalId), + terminalUiState.terminalGroups.find((group) => + group.terminalIds.includes(terminalUiState.activeTerminalId), ) ?? null; const hasReachedSplitLimit = @@ -1799,25 +2001,96 @@ export default function ChatView(props: ChatViewProps) { ); const toggleTerminalVisibility = useCallback(() => { if (!activeThreadRef) return; - setTerminalOpen(!terminalState.terminalOpen); - }, [activeThreadRef, setTerminalOpen, terminalState.terminalOpen]); + setTerminalOpen(!terminalUiState.terminalOpen); + }, [activeThreadRef, setTerminalOpen, terminalUiState.terminalOpen]); const splitTerminal = useCallback(() => { - if (!activeThreadRef || hasReachedSplitLimit) return; - const terminalId = `terminal-${randomUUID()}`; + if (!activeThreadRef || hasReachedSplitLimit || !activeThreadId || !activeProject) { + return; + } + const cwdForOpen = gitCwd ?? activeProject.cwd; + if (!cwdForOpen) { + return; + } + const api = readEnvironmentApi(environmentId); + if (!api) { + return; + } + const terminalId = nextTerminalId(activeKnownTerminalIds); storeSplitTerminal(activeThreadRef, terminalId); setTerminalFocusRequestId((value) => value + 1); - }, [activeThreadRef, hasReachedSplitLimit, storeSplitTerminal]); + void (async () => { + try { + await api.terminal.open({ + threadId: activeThreadId, + terminalId, + cwd: cwdForOpen, + ...(activeThreadWorktreePath != null ? { worktreePath: activeThreadWorktreePath } : {}), + env: projectScriptRuntimeEnv({ + project: { cwd: activeProject.cwd }, + worktreePath: activeThreadWorktreePath, + }), + }); + } catch { + // Opening failed; the tab is already in the store — user can retry or close it. + } + })(); + }, [ + activeProject, + activeKnownTerminalIds, + activeThreadId, + activeThreadRef, + activeThreadWorktreePath, + environmentId, + gitCwd, + hasReachedSplitLimit, + storeSplitTerminal, + ]); const createNewTerminal = useCallback(() => { - if (!activeThreadRef) return; - const terminalId = `terminal-${randomUUID()}`; + if (!activeThreadRef || !activeThreadId || !activeProject) { + return; + } + const cwdForOpen = gitCwd ?? activeProject.cwd; + if (!cwdForOpen) { + return; + } + const api = readEnvironmentApi(environmentId); + if (!api) { + return; + } + const terminalId = nextTerminalId(activeKnownTerminalIds); storeNewTerminal(activeThreadRef, terminalId); setTerminalFocusRequestId((value) => value + 1); - }, [activeThreadRef, storeNewTerminal]); + void (async () => { + try { + await api.terminal.open({ + threadId: activeThreadId, + terminalId, + cwd: cwdForOpen, + ...(activeThreadWorktreePath != null ? { worktreePath: activeThreadWorktreePath } : {}), + env: projectScriptRuntimeEnv({ + project: { cwd: activeProject.cwd }, + worktreePath: activeThreadWorktreePath, + }), + }); + } catch { + // Opening failed; the tab is already in the store — user can retry or close it. + } + })(); + }, [ + activeProject, + activeKnownTerminalIds, + activeThreadId, + activeThreadRef, + activeThreadWorktreePath, + environmentId, + gitCwd, + storeNewTerminal, + ]); const closeTerminal = useCallback( (terminalId: string) => { const api = readEnvironmentApi(environmentId); - if (!activeThreadId || !api) return; - const isFinalTerminal = terminalState.terminalIds.length <= 1; + if (!activeThreadId || !api || !activeThreadRef) return; + const isFinalTerminal = activeKnownTerminalIds.length <= 1; const fallbackExitWrite = () => api.terminal .write({ threadId: activeThreadId, terminalId, data: "exit\n" }) @@ -1838,18 +2111,10 @@ export default function ChatView(props: ChatViewProps) { } else { void fallbackExitWrite(); } - if (activeThreadRef) { - storeCloseTerminal(activeThreadRef, terminalId); - } + storeCloseTerminal(activeThreadRef, terminalId); setTerminalFocusRequestId((value) => value + 1); }, - [ - activeThreadId, - activeThreadRef, - environmentId, - storeCloseTerminal, - terminalState.terminalIds.length, - ], + [activeThreadId, activeThreadRef, activeKnownTerminalIds, environmentId, storeCloseTerminal], ); const runProjectScript = useCallback( async ( @@ -1872,18 +2137,13 @@ export default function ChatView(props: ChatViewProps) { } const targetCwd = options?.cwd ?? gitCwd ?? activeProject.cwd; const baseTerminalId = - terminalState.activeTerminalId || - terminalState.terminalIds[0] || - DEFAULT_THREAD_TERMINAL_ID; - const isBaseTerminalBusy = terminalState.runningTerminalIds.includes(baseTerminalId); + terminalUiState.activeTerminalId || activeKnownTerminalIds[0] || DEFAULT_THREAD_TERMINAL_ID; + const isBaseTerminalBusy = runningTerminalIds.includes(baseTerminalId); const wantsNewTerminal = Boolean(options?.preferNewTerminal) || isBaseTerminalBusy; const shouldCreateNewTerminal = wantsNewTerminal; - const targetTerminalId = shouldCreateNewTerminal - ? `terminal-${randomUUID()}` - : baseTerminalId; const targetWorktreePath = options?.worktreePath ?? activeThread.worktreePath ?? null; - setTerminalLaunchContext({ + setTerminalUiLaunchContext({ threadId: activeThreadId, cwd: targetCwd, worktreePath: targetWorktreePath, @@ -1892,11 +2152,6 @@ export default function ChatView(props: ChatViewProps) { if (!activeThreadRef) { return; } - if (shouldCreateNewTerminal) { - storeNewTerminal(activeThreadRef, targetTerminalId); - } else { - storeSetActiveTerminal(activeThreadRef, targetTerminalId); - } setTerminalFocusRequestId((value) => value + 1); const runtimeEnv = projectScriptRuntimeEnv({ @@ -1906,6 +2161,9 @@ export default function ChatView(props: ChatViewProps) { worktreePath: targetWorktreePath, ...(options?.env ? { extraEnv: options.env } : {}), }); + const targetTerminalId = shouldCreateNewTerminal + ? nextTerminalId(activeKnownTerminalIds) + : baseTerminalId; const openTerminalInput: TerminalOpenInput = shouldCreateNewTerminal ? { threadId: activeThreadId, @@ -1924,6 +2182,12 @@ export default function ChatView(props: ChatViewProps) { env: runtimeEnv, }; + if (shouldCreateNewTerminal) { + storeNewTerminal(activeThreadRef, targetTerminalId); + } else { + storeSetActiveTerminal(activeThreadRef, targetTerminalId); + } + try { await api.terminal.open(openTerminalInput); await api.terminal.write({ @@ -1950,9 +2214,9 @@ export default function ChatView(props: ChatViewProps) { storeSetActiveTerminal, setLastInvokedScriptByProjectId, environmentId, - terminalState.activeTerminalId, - terminalState.runningTerminalIds, - terminalState.terminalIds, + activeKnownTerminalIds, + runningTerminalIds, + terminalUiState.activeTerminalId, ], ); @@ -2263,14 +2527,14 @@ export default function ChatView(props: ChatViewProps) { }, [activeThread?.id]); useEffect(() => { - if (!activeThread?.id || terminalState.terminalOpen) return; + if (!activeThread?.id || terminalUiState.terminalOpen) return; const frame = window.requestAnimationFrame(() => { focusComposer(); }); return () => { window.cancelAnimationFrame(frame); }; - }, [activeThread?.id, focusComposer, terminalState.terminalOpen]); + }, [activeThread?.id, focusComposer, terminalUiState.terminalOpen]); useEffect(() => { if (!activeThread?.id) return; @@ -2355,22 +2619,21 @@ export default function ChatView(props: ChatViewProps) { useEffect(() => { if (!activeThreadId) { - setTerminalLaunchContext(null); - storeClearTerminalLaunchContext(routeThreadRef); + setTerminalUiLaunchContext(null); return; } - setTerminalLaunchContext((current) => { + setTerminalUiLaunchContext((current) => { if (!current) return current; if (current.threadId === activeThreadId) return current; return null; }); - }, [activeThreadId, routeThreadRef, storeClearTerminalLaunchContext]); + }, [activeThreadId]); useEffect(() => { if (!activeThreadId || !activeProjectCwd) { return; } - setTerminalLaunchContext((current) => { + setTerminalUiLaunchContext((current) => { if (!current || current.threadId !== activeThreadId) { return current; } @@ -2382,72 +2645,32 @@ export default function ChatView(props: ChatViewProps) { settledCwd === current.cwd && (activeThreadWorktreePath ?? null) === current.worktreePath ) { - if (activeThreadRef) { - storeClearTerminalLaunchContext(activeThreadRef); - } return null; } return current; }); - }, [ - activeProjectCwd, - activeThreadId, - activeThreadRef, - activeThreadWorktreePath, - storeClearTerminalLaunchContext, - ]); + }, [activeProjectCwd, activeThreadId, activeThreadWorktreePath]); useEffect(() => { - if (!activeThreadId || !activeProjectCwd || !storeServerTerminalLaunchContext) { + if (terminalUiState.terminalOpen) { return; } - const settledCwd = projectScriptCwd({ - project: { cwd: activeProjectCwd }, - worktreePath: activeThreadWorktreePath, - }); - if ( - settledCwd === storeServerTerminalLaunchContext.cwd && - (activeThreadWorktreePath ?? null) === storeServerTerminalLaunchContext.worktreePath - ) { - if (activeThreadRef) { - storeClearTerminalLaunchContext(activeThreadRef); - } - } - }, [ - activeProjectCwd, - activeThreadId, - activeThreadRef, - activeThreadWorktreePath, - storeClearTerminalLaunchContext, - storeServerTerminalLaunchContext, - ]); - - useEffect(() => { - if (terminalState.terminalOpen) { - return; - } - if (activeThreadRef) { - storeClearTerminalLaunchContext(activeThreadRef); - } - setTerminalLaunchContext((current) => (current?.threadId === activeThreadId ? null : current)); - }, [ - activeThreadId, - activeThreadRef, - storeClearTerminalLaunchContext, - terminalState.terminalOpen, - ]); + setTerminalUiLaunchContext((current) => + current?.threadId === activeThreadId ? null : current, + ); + }, [activeThreadId, terminalUiState.terminalOpen]); useEffect(() => { if (!activeThreadKey) return; - const previous = terminalOpenByThreadRef.current[activeThreadKey] ?? false; - const current = Boolean(terminalState.terminalOpen); + const previous = terminalUiOpenByThreadRef.current[activeThreadKey] ?? false; + const current = Boolean(terminalUiState.terminalOpen); if (!previous && current) { - terminalOpenByThreadRef.current[activeThreadKey] = current; + terminalUiOpenByThreadRef.current[activeThreadKey] = current; setTerminalFocusRequestId((value) => value + 1); return; } else if (previous && !current) { - terminalOpenByThreadRef.current[activeThreadKey] = current; + terminalUiOpenByThreadRef.current[activeThreadKey] = current; const frame = window.requestAnimationFrame(() => { focusComposer(); }); @@ -2456,8 +2679,8 @@ export default function ChatView(props: ChatViewProps) { }; } - terminalOpenByThreadRef.current[activeThreadKey] = current; - }, [activeThreadKey, focusComposer, terminalState.terminalOpen]); + terminalUiOpenByThreadRef.current[activeThreadKey] = current; + }, [activeThreadKey, focusComposer, terminalUiState.terminalOpen]); useEffect(() => { const handler = (event: globalThis.KeyboardEvent) => { @@ -2466,7 +2689,7 @@ export default function ChatView(props: ChatViewProps) { } const shortcutContext = { terminalFocus: isTerminalFocused(), - terminalOpen: Boolean(terminalState.terminalOpen), + terminalOpen: Boolean(terminalUiState.terminalOpen), modelPickerOpen: composerRef.current?.isModelPickerOpen() ?? false, }; @@ -2485,7 +2708,7 @@ export default function ChatView(props: ChatViewProps) { if (command === "terminal.split") { event.preventDefault(); event.stopPropagation(); - if (!terminalState.terminalOpen) { + if (!terminalUiState.terminalOpen) { setTerminalOpen(true); } splitTerminal(); @@ -2495,15 +2718,15 @@ export default function ChatView(props: ChatViewProps) { if (command === "terminal.close") { event.preventDefault(); event.stopPropagation(); - if (!terminalState.terminalOpen) return; - closeTerminal(terminalState.activeTerminalId); + if (!terminalUiState.terminalOpen) return; + closeTerminal(terminalUiState.activeTerminalId); return; } if (command === "terminal.new") { event.preventDefault(); event.stopPropagation(); - if (!terminalState.terminalOpen) { + if (!terminalUiState.terminalOpen) { setTerminalOpen(true); } createNewTerminal(); @@ -2536,8 +2759,8 @@ export default function ChatView(props: ChatViewProps) { return () => window.removeEventListener("keydown", handler, true); }, [ activeProject, - terminalState.terminalOpen, - terminalState.activeTerminalId, + terminalUiState.terminalOpen, + terminalUiState.activeTerminalId, activeThreadId, closeTerminal, createNewTerminal, @@ -3526,7 +3749,7 @@ export default function ChatView(props: ChatViewProps) { keybindings={keybindings} availableEditors={availableEditors} terminalAvailable={activeProject !== undefined} - terminalOpen={terminalState.terminalOpen} + terminalOpen={terminalUiState.terminalOpen} terminalToggleShortcutLabel={terminalToggleShortcutLabel} diffToggleShortcutLabel={diffPanelShortcutLabel} gitCwd={gitCwd} @@ -3648,7 +3871,7 @@ export default function ChatView(props: ChatViewProps) { resolvedTheme={resolvedTheme} settings={settings} keybindings={keybindings} - terminalOpen={Boolean(terminalState.terminalOpen)} + terminalOpen={Boolean(terminalUiState.terminalOpen)} gitCwd={gitCwd} promptRef={promptRef} composerImagesRef={composerImagesRef} @@ -3744,7 +3967,7 @@ export default function ChatView(props: ChatViewProps) { key={mountedThreadKey} threadRef={mountedThreadRef} threadId={mountedThreadRef.threadId} - visible={mountedThreadKey === activeThreadKey && terminalState.terminalOpen} + visible={mountedThreadKey === activeThreadKey && terminalUiState.terminalOpen} launchContext={ mountedThreadKey === activeThreadKey ? (activeTerminalLaunchContext ?? null) : null } diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 1b3936ec7e5..8c2ae25d97f 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -79,7 +79,7 @@ import { selectSidebarThreadsAcrossEnvironments, useStore, } from "../store"; -import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; +import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; import { buildThreadRouteParams, resolveThreadRouteTarget } from "../threadRoutes"; import { ADDON_ICON_CLASS, @@ -337,9 +337,9 @@ export function CommandPalette({ children }: { children: ReactNode }) { select: (params) => resolveThreadRouteTarget(params), }); const routeThreadRef = routeTarget?.kind === "server" ? routeTarget.threadRef : null; - const terminalOpen = useTerminalStateStore((state) => + const terminalOpen = useTerminalUiStateStore((state) => routeThreadRef - ? selectThreadTerminalState(state.terminalStateByThreadKey, routeThreadRef).terminalOpen + ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen : false, ); diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index f178a69fb43..9b506d3c0f4 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -1,6 +1,4 @@ -import { parsePatchFiles } from "@pierre/diffs"; -import { FileDiff, type FileDiffMetadata, Virtualizer } from "@pierre/diffs/react"; -import { useQuery } from "@tanstack/react-query"; +import { FileDiff, Virtualizer } from "@pierre/diffs/react"; import { useNavigate, useParams, useSearch } from "@tanstack/react-router"; import { scopeThreadRef } from "@t3tools/client-runtime"; import type { TurnId } from "@t3tools/contracts"; @@ -22,15 +20,20 @@ import { useState, } from "react"; import { openInPreferredEditor } from "../editorPreferences"; -import { useGitStatus } from "~/lib/gitStatusState"; -import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery"; +import { useCheckpointDiff } from "~/lib/checkpointDiffState"; +import { useVcsStatus } from "~/lib/vcsStatusState"; import { cn } from "~/lib/utils"; import { readLocalApi } from "../localApi"; import { resolvePathLinkTarget } from "../terminal-links"; import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; import { useTheme } from "../hooks/useTheme"; -import { buildPatchCacheKey } from "../lib/diffRendering"; -import { resolveDiffThemeName } from "../lib/diffRendering"; +import { + buildFileDiffRenderKey, + getDiffCollapseIconClassName, + getRenderablePatch, + resolveDiffThemeName, + resolveFileDiffPath, +} from "../lib/diffRendering"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; import { selectProjectByRef, useStore } from "../store"; import { createThreadSelectorByRef } from "../storeSelectors"; @@ -107,76 +110,6 @@ const DIFF_PANEL_UNSAFE_CSS = ` } `; -type RenderablePatch = - | { - kind: "files"; - files: FileDiffMetadata[]; - } - | { - kind: "raw"; - text: string; - reason: string; - }; - -function getRenderablePatch( - patch: string | undefined, - cacheScope = "diff-panel", -): RenderablePatch | null { - if (!patch) return null; - const normalizedPatch = patch.trim(); - if (normalizedPatch.length === 0) return null; - - try { - const parsedPatches = parsePatchFiles( - normalizedPatch, - buildPatchCacheKey(normalizedPatch, cacheScope), - ); - const files = parsedPatches.flatMap((parsedPatch) => parsedPatch.files); - if (files.length > 0) { - return { kind: "files", files }; - } - - return { - kind: "raw", - text: normalizedPatch, - reason: "Unsupported diff format. Showing raw patch.", - }; - } catch { - return { - kind: "raw", - text: normalizedPatch, - reason: "Failed to parse patch. Showing raw patch.", - }; - } -} - -function resolveFileDiffPath(fileDiff: FileDiffMetadata): string { - const raw = fileDiff.name ?? fileDiff.prevName ?? ""; - if (raw.startsWith("a/") || raw.startsWith("b/")) { - return raw.slice(2); - } - return raw; -} - -function buildFileDiffRenderKey(fileDiff: FileDiffMetadata): string { - return fileDiff.cacheKey ?? `${fileDiff.prevName ?? "none"}:${fileDiff.name}`; -} - -function getDiffCollapseIconClassName(fileDiff: FileDiffMetadata): string { - switch (fileDiff.type) { - case "new": - return "text-[var(--diffs-addition-base)]"; - case "deleted": - return "text-[var(--diffs-deletion-base)]"; - case "change": - case "rename-pure": - case "rename-changed": - return "text-[var(--diffs-modified-base)]"; - default: - return "text-muted-foreground/80"; - } -} - interface DiffPanelProps { mode?: DiffPanelMode; } @@ -218,7 +151,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { : undefined, ); const activeCwd = activeThread?.worktreePath ?? activeProject?.cwd; - const gitStatusQuery = useGitStatus({ + const gitStatusQuery = useVcsStatus({ environmentId: activeThread?.environmentId ?? null, cwd: activeCwd ?? null, }); @@ -292,30 +225,21 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { } return `conversation:${orderedTurnDiffSummaries.map((summary) => summary.turnId).join(",")}`; }, [orderedTurnDiffSummaries, selectedTurn]); - const activeCheckpointDiffQuery = useQuery( - checkpointDiffQueryOptions({ + const activeCheckpointDiff = useCheckpointDiff( + { environmentId: activeThread?.environmentId ?? null, threadId: activeThreadId, fromTurnCount: activeCheckpointRange?.fromTurnCount ?? null, toTurnCount: activeCheckpointRange?.toTurnCount ?? null, ignoreWhitespace: diffIgnoreWhitespace, cacheScope: selectedTurn ? `turn:${selectedTurn.turnId}` : conversationCacheScope, - enabled: isGitRepo, - }), + }, + { enabled: isGitRepo }, ); - const selectedTurnCheckpointDiff = selectedTurn - ? activeCheckpointDiffQuery.data?.diff - : undefined; - const conversationCheckpointDiff = selectedTurn - ? undefined - : activeCheckpointDiffQuery.data?.diff; - const isLoadingCheckpointDiff = activeCheckpointDiffQuery.isLoading; - const checkpointDiffError = - activeCheckpointDiffQuery.error instanceof Error - ? activeCheckpointDiffQuery.error.message - : activeCheckpointDiffQuery.error - ? "Failed to load checkpoint diff." - : null; + const selectedTurnCheckpointDiff = selectedTurn ? activeCheckpointDiff.data?.diff : undefined; + const conversationCheckpointDiff = selectedTurn ? undefined : activeCheckpointDiff.data?.diff; + const isLoadingCheckpointDiff = activeCheckpointDiff.isPending; + const checkpointDiffError = activeCheckpointDiff.error; const selectedPatch = selectedTurn ? selectedTurnCheckpointDiff : conversationCheckpointDiff; const hasResolvedPatch = typeof selectedPatch === "string"; diff --git a/apps/web/src/components/GitActionsControl.browser.tsx b/apps/web/src/components/GitActionsControl.browser.tsx index a2a801d54af..6447679ad19 100644 --- a/apps/web/src/components/GitActionsControl.browser.tsx +++ b/apps/web/src/components/GitActionsControl.browser.tsx @@ -26,9 +26,9 @@ const { activeRunStackedActionDeferredRef, activeDraftThreadRef, hasServerThreadRef, - invalidateGitQueriesSpy, - refreshGitStatusSpy, - runStackedActionMutateAsyncSpy, + invalidateSourceControlStateSpy, + refreshVcsStatusSpy, + runStackedActionSpy, setDraftThreadContextSpy, setThreadBranchSpy, toastAddSpy, @@ -39,9 +39,9 @@ const { activeRunStackedActionDeferredRef: { current: createDeferredPromise() }, activeDraftThreadRef: { current: null as unknown }, hasServerThreadRef: { current: true }, - invalidateGitQueriesSpy: vi.fn(() => Promise.resolve()), - refreshGitStatusSpy: vi.fn(() => Promise.resolve(null)), - runStackedActionMutateAsyncSpy: vi.fn(() => activeRunStackedActionDeferredRef.current.promise), + invalidateSourceControlStateSpy: vi.fn(() => Promise.resolve()), + refreshVcsStatusSpy: vi.fn(() => Promise.resolve(null)), + runStackedActionSpy: vi.fn(() => activeRunStackedActionDeferredRef.current.promise), setDraftThreadContextSpy: vi.fn(), setThreadBranchSpy: vi.fn(), toastAddSpy: vi.fn(() => "toast-1"), @@ -50,39 +50,6 @@ const { toastUpdateSpy: vi.fn(), })); -vi.mock("@tanstack/react-query", async () => { - const actual = - await vi.importActual("@tanstack/react-query"); - - return { - ...actual, - useIsMutating: vi.fn(() => 0), - useMutation: vi.fn((options: { __kind?: string }) => { - if (options.__kind === "run-stacked-action") { - return { - mutateAsync: runStackedActionMutateAsyncSpy, - isPending: false, - }; - } - - if (options.__kind === "pull") { - return { - mutateAsync: vi.fn(), - isPending: false, - }; - } - - return { - mutate: vi.fn(), - mutateAsync: vi.fn(), - isPending: false, - }; - }), - useQuery: vi.fn(() => ({ data: null, error: null })), - useQueryClient: vi.fn(() => ({})), - }; -}); - vi.mock("~/components/ui/toast", () => ({ toastManager: { add: toastAddSpy, @@ -97,23 +64,39 @@ vi.mock("~/editorPreferences", () => ({ openInPreferredEditor: vi.fn(), })); -vi.mock("~/lib/gitReactQuery", () => ({ - gitInitMutationOptions: vi.fn(() => ({ __kind: "init" })), - gitMutationKeys: { - publishRepository: vi.fn(() => ["publish-repository"]), - pull: vi.fn(() => ["pull"]), - runStackedAction: vi.fn(() => ["run-stacked-action"]), - }, - gitPullMutationOptions: vi.fn(() => ({ __kind: "pull" })), - gitRunStackedActionMutationOptions: vi.fn(() => ({ __kind: "run-stacked-action" })), - invalidateGitQueries: invalidateGitQueriesSpy, - sourceControlPublishRepositoryMutationOptions: vi.fn(() => ({ __kind: "publish-repository" })), +vi.mock("~/lib/sourceControlActions", () => ({ + invalidateSourceControlState: invalidateSourceControlStateSpy, + useGitStackedAction: vi.fn(() => ({ + error: null, + isPending: false, + resetError: vi.fn(), + run: runStackedActionSpy, + })), + useSourceControlActionRunning: vi.fn(() => false), + useSourceControlPublishRepositoryAction: vi.fn(() => ({ + error: null, + isPending: false, + resetError: vi.fn(), + run: vi.fn(), + })), + useVcsInitAction: vi.fn(() => ({ + error: null, + isPending: false, + resetError: vi.fn(), + run: vi.fn(), + })), + useVcsPullAction: vi.fn(() => ({ + error: null, + isPending: false, + resetError: vi.fn(), + run: vi.fn(), + })), })); -vi.mock("~/lib/gitStatusState", () => ({ - refreshGitStatus: refreshGitStatusSpy, - resetGitStatusStateForTests: () => undefined, - useGitStatus: vi.fn(() => ({ +vi.mock("~/lib/vcsStatusState", () => ({ + refreshVcsStatus: refreshVcsStatusSpy, + resetVcsStatusStateForTests: () => undefined, + useVcsStatus: vi.fn(() => ({ data: { isRepo: true, sourceControlProvider: { @@ -379,14 +362,14 @@ describe("GitActionsControl thread-scoped progress toast", () => { visibilityState = "visible"; document.dispatchEvent(new Event("visibilitychange")); - expect(refreshGitStatusSpy).not.toHaveBeenCalled(); + expect(refreshVcsStatusSpy).not.toHaveBeenCalled(); await vi.advanceTimersByTimeAsync(249); - expect(refreshGitStatusSpy).not.toHaveBeenCalled(); + expect(refreshVcsStatusSpy).not.toHaveBeenCalled(); await vi.advanceTimersByTimeAsync(1); - expect(refreshGitStatusSpy).toHaveBeenCalledTimes(1); - expect(refreshGitStatusSpy).toHaveBeenCalledWith({ + expect(refreshVcsStatusSpy).toHaveBeenCalledTimes(1); + expect(refreshVcsStatusSpy).toHaveBeenCalledWith({ environmentId: ENVIRONMENT_A, cwd: GIT_CWD, }); diff --git a/apps/web/src/components/GitActionsControl.logic.ts b/apps/web/src/components/GitActionsControl.logic.ts index 3f6bae61cdd..96663172f49 100644 --- a/apps/web/src/components/GitActionsControl.logic.ts +++ b/apps/web/src/components/GitActionsControl.logic.ts @@ -1,407 +1,15 @@ -import type { - GitRunStackedActionResult, - GitStackedAction, - VcsStatusResult, -} from "@t3tools/contracts"; -import { isTemporaryWorktreeBranch } from "@t3tools/shared/git"; -import { - DEFAULT_CHANGE_REQUEST_TERMINOLOGY, - getChangeRequestTerminology, - type ChangeRequestTerminology, -} from "../sourceControlPresentation"; - -export type GitActionIconName = "commit" | "push" | "pr"; - -export type GitDialogAction = "commit" | "push" | "create_pr"; - -export interface GitActionMenuItem { - id: "commit" | "push" | "pr"; - label: string; - disabled: boolean; - icon: GitActionIconName; - kind: "open_dialog" | "open_pr"; - dialogAction?: GitDialogAction; -} - -export interface GitQuickAction { - label: string; - disabled: boolean; - kind: "run_action" | "run_pull" | "open_pr" | "open_publish" | "show_hint"; - action?: GitStackedAction; - hint?: string; -} - -export interface DefaultBranchActionDialogCopy { - title: string; - description: string; - continueLabel: string; -} - -export type DefaultBranchConfirmableAction = - | "push" - | "create_pr" - | "commit_push" - | "commit_push_pr"; - -function resolveChangeRequestTerminology( - gitStatus: VcsStatusResult | null, -): ChangeRequestTerminology { - return gitStatus?.sourceControlProvider - ? getChangeRequestTerminology(gitStatus.sourceControlProvider) - : DEFAULT_CHANGE_REQUEST_TERMINOLOGY; -} - -export function buildGitActionProgressStages(input: { - action: GitStackedAction; - hasCustomCommitMessage: boolean; - hasWorkingTreeChanges: boolean; - pushTarget?: string; - featureBranch?: boolean; - shouldPushBeforePr?: boolean; - terminology?: ChangeRequestTerminology; -}): string[] { - const terminology = input.terminology ?? DEFAULT_CHANGE_REQUEST_TERMINOLOGY; - const branchStages = input.featureBranch ? ["Preparing feature ref..."] : []; - const pushStage = input.pushTarget ? `Pushing to ${input.pushTarget}...` : "Pushing..."; - const prStages = [ - `Preparing ${terminology.shortLabel}...`, - `Generating ${terminology.shortLabel} content...`, - `Creating ${terminology.singular}...`, - ]; - - if (input.action === "push") { - return [pushStage]; - } - if (input.action === "create_pr") { - return input.shouldPushBeforePr ? [pushStage, ...prStages] : prStages; - } - - const shouldIncludeCommitStages = input.action === "commit" || input.hasWorkingTreeChanges; - const commitStages = !shouldIncludeCommitStages - ? [] - : input.hasCustomCommitMessage - ? ["Committing..."] - : ["Generating commit message...", "Committing..."]; - if (input.action === "commit") { - return [...branchStages, ...commitStages]; - } - if (input.action === "commit_push") { - return [...branchStages, ...commitStages, pushStage]; - } - return [...branchStages, ...commitStages, pushStage, ...prStages]; -} - -export function buildMenuItems( - gitStatus: VcsStatusResult | null, - isBusy: boolean, - hasPrimaryRemote = true, -): GitActionMenuItem[] { - if (!gitStatus) return []; - const terminology = resolveChangeRequestTerminology(gitStatus); - - const hasBranch = gitStatus.refName !== null; - const hasChanges = gitStatus.hasWorkingTreeChanges; - const hasOpenPr = gitStatus.pr?.state === "open"; - const isBehind = gitStatus.behindCount > 0; - const hasDefaultBranchDelta = (gitStatus.aheadOfDefaultCount ?? gitStatus.aheadCount) > 0; - const canPushWithoutUpstream = hasPrimaryRemote && !gitStatus.hasUpstream; - const canCommit = !isBusy && hasChanges; - const canPush = - !isBusy && - hasBranch && - !isBehind && - gitStatus.aheadCount > 0 && - (gitStatus.hasUpstream || canPushWithoutUpstream); - const canCreatePr = - !isBusy && - hasBranch && - !hasChanges && - !hasOpenPr && - hasDefaultBranchDelta && - !isBehind && - (gitStatus.hasUpstream || canPushWithoutUpstream); - const canOpenPr = !isBusy && hasOpenPr; - - const commitItem: GitActionMenuItem = { - id: "commit", - label: "Commit", - disabled: !canCommit, - icon: "commit", - kind: "open_dialog", - dialogAction: "commit", - }; - - if (!hasPrimaryRemote) { - return [commitItem]; - } - - return [ - commitItem, - { - id: "push", - label: "Push", - disabled: !canPush, - icon: "push", - kind: "open_dialog", - dialogAction: "push", - }, - hasOpenPr - ? { - id: "pr", - label: `View ${terminology.shortLabel}`, - disabled: !canOpenPr, - icon: "pr", - kind: "open_pr", - } - : { - id: "pr", - label: `Create ${terminology.shortLabel}`, - disabled: !canCreatePr, - icon: "pr", - kind: "open_dialog", - dialogAction: "create_pr", - }, - ]; -} - -export function resolveQuickAction( - gitStatus: VcsStatusResult | null, - isBusy: boolean, - isDefaultRef = false, - hasPrimaryRemote = true, -): GitQuickAction { - if (isBusy) { - return { label: "Commit", disabled: true, kind: "show_hint", hint: "Git action in progress." }; - } - - if (!gitStatus) { - return { - label: "Commit", - disabled: true, - kind: "show_hint", - hint: "Git status is unavailable.", - }; - } - - const hasBranch = gitStatus.refName !== null; - const hasChanges = gitStatus.hasWorkingTreeChanges; - const hasOpenPr = gitStatus.pr?.state === "open"; - const isAhead = gitStatus.aheadCount > 0; - const hasDefaultBranchDelta = (gitStatus.aheadOfDefaultCount ?? gitStatus.aheadCount) > 0; - const isBehind = gitStatus.behindCount > 0; - const isDiverged = isAhead && isBehind; - const terminology = resolveChangeRequestTerminology(gitStatus); - - if (!hasBranch) { - return { - label: "Commit", - disabled: true, - kind: "show_hint", - hint: `Create and checkout a ref before pushing or opening a ${terminology.singular}.`, - }; - } - - if (hasChanges) { - if (!gitStatus.hasUpstream && !hasPrimaryRemote) { - return { label: "Commit", disabled: false, kind: "run_action", action: "commit" }; - } - if (hasOpenPr || isDefaultRef) { - return { label: "Commit & push", disabled: false, kind: "run_action", action: "commit_push" }; - } - return { - label: `Commit, push & ${terminology.shortLabel}`, - disabled: false, - kind: "run_action", - action: "commit_push_pr", - }; - } - - if (!gitStatus.hasUpstream) { - if (!hasPrimaryRemote) { - if (hasOpenPr && !isAhead) { - return { label: `View ${terminology.shortLabel}`, disabled: false, kind: "open_pr" }; - } - return { - label: "Publish repository", - disabled: false, - kind: "open_publish", - }; - } - if (!isAhead) { - if (hasOpenPr) { - return { label: `View ${terminology.shortLabel}`, disabled: false, kind: "open_pr" }; - } - return { - label: "Push", - disabled: true, - kind: "show_hint", - hint: "No local commits to push.", - }; - } - if (hasOpenPr || isDefaultRef) { - return { - label: "Push", - disabled: false, - kind: "run_action", - action: isDefaultRef ? "commit_push" : "push", - }; - } - return { - label: `Push & create ${terminology.shortLabel}`, - disabled: false, - kind: "run_action", - action: "create_pr", - }; - } - - if (isDiverged) { - return { - label: "Sync ref", - disabled: true, - kind: "show_hint", - hint: "Branch has diverged from upstream. Rebase/merge first.", - }; - } - - if (isBehind) { - return { - label: "Pull", - disabled: false, - kind: "run_pull", - }; - } - - if (isAhead) { - if (hasOpenPr || isDefaultRef) { - return { - label: "Push", - disabled: false, - kind: "run_action", - action: isDefaultRef ? "commit_push" : "push", - }; - } - return { - label: `Push & create ${terminology.shortLabel}`, - disabled: false, - kind: "run_action", - action: "create_pr", - }; - } - - if (hasOpenPr && gitStatus.hasUpstream) { - return { label: `View ${terminology.shortLabel}`, disabled: false, kind: "open_pr" }; - } - - if (hasDefaultBranchDelta && !isDefaultRef) { - return { - label: `Create ${terminology.shortLabel}`, - disabled: false, - kind: "run_action", - action: "create_pr", - }; - } - - return { - label: "Commit", - disabled: true, - kind: "show_hint", - hint: "Branch is up to date. No action needed.", - }; -} - -export function requiresDefaultBranchConfirmation( - action: GitStackedAction, - isDefaultRef: boolean, -): boolean { - if (!isDefaultRef) return false; - return ( - action === "push" || - action === "create_pr" || - action === "commit_push" || - action === "commit_push_pr" - ); -} - -export function resolveDefaultBranchActionDialogCopy(input: { - action: DefaultBranchConfirmableAction; - branchName: string; - includesCommit: boolean; - terminology?: ChangeRequestTerminology; -}): DefaultBranchActionDialogCopy { - const branchLabel = input.branchName; - const suffix = ` on "${branchLabel}". You can continue on this ref or create a feature ref and run the same action there.`; - const terminology = input.terminology ?? DEFAULT_CHANGE_REQUEST_TERMINOLOGY; - - if (input.action === "push" || input.action === "commit_push") { - if (input.includesCommit) { - return { - title: "Commit & push to default ref?", - description: `This action will commit and push changes${suffix}`, - continueLabel: `Commit & push to ${branchLabel}`, - }; - } - return { - title: "Push to default ref?", - description: `This action will push local commits${suffix}`, - continueLabel: `Push to ${branchLabel}`, - }; - } - - if (input.includesCommit) { - return { - title: `Commit, push & create ${terminology.shortLabel} from default ref?`, - description: `This action will commit, push, and create a ${terminology.singular}${suffix}`, - continueLabel: `Commit, push & create ${terminology.shortLabel}`, - }; - } - return { - title: `Push & create ${terminology.shortLabel} from default ref?`, - description: `This action will push local commits and create a ${terminology.singular}${suffix}`, - continueLabel: `Push & create ${terminology.shortLabel}`, - }; -} - -export function resolveThreadBranchUpdate( - result: GitRunStackedActionResult, -): { branch: string } | null { - if (result.branch.status !== "created" || !result.branch.name) { - return null; - } - - return { - branch: result.branch.name, - }; -} - -export function resolveLiveThreadBranchUpdate(input: { - threadBranch: string | null; - gitStatus: VcsStatusResult | null; -}): { branch: string | null } | null { - if (!input.gitStatus) { - return null; - } - - if (input.gitStatus.refName === null && input.threadBranch !== null) { - return null; - } - - if (input.threadBranch === input.gitStatus.refName) { - return null; - } - - if ( - input.threadBranch !== null && - input.gitStatus.refName !== null && - !isTemporaryWorktreeBranch(input.threadBranch) && - isTemporaryWorktreeBranch(input.gitStatus.refName) - ) { - return null; - } - - return { - branch: input.gitStatus.refName, - }; -} - -// Re-export from shared for backwards compatibility in this module's exports +export { + buildGitActionProgressStages, + buildMenuItems, + getGitActionDisabledReason, + type GitActionIconName, + type GitActionMenuItem, + type GitQuickAction, + type DefaultBranchConfirmableAction, + requiresDefaultBranchConfirmation, + resolveDefaultBranchActionDialogCopy, + resolveLiveThreadBranchUpdate, + resolveQuickAction, + resolveThreadBranchUpdate, +} from "@t3tools/client-runtime"; export { resolveAutoFeatureBranchName } from "@t3tools/shared/git"; diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index d8c5cdd3fa1..1c1c49b2cf9 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -10,7 +10,6 @@ import type { SourceControlRepositoryVisibility, VcsStatusResult, } from "@t3tools/contracts"; -import { useIsMutating, useMutation, useQueryClient } from "@tanstack/react-query"; import { useNavigate } from "@tanstack/react-router"; import * as Option from "effect/Option"; import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; @@ -33,6 +32,7 @@ import { cn } from "~/lib/utils"; import { buildGitActionProgressStages, buildMenuItems, + getGitActionDisabledReason, type GitActionIconName, type GitActionMenuItem, type GitQuickAction, @@ -65,13 +65,13 @@ import { stackedThreadToast, toastManager, type ThreadToastData } from "~/compon import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; import { openInPreferredEditor } from "~/editorPreferences"; import { - gitInitMutationOptions, - gitMutationKeys, - gitPullMutationOptions, - gitRunStackedActionMutationOptions, - sourceControlPublishRepositoryMutationOptions, -} from "~/lib/gitReactQuery"; -import { refreshGitStatus, useGitStatus } from "~/lib/gitStatusState"; + useGitStackedAction, + useSourceControlActionRunning, + useSourceControlPublishRepositoryAction, + useVcsInitAction, + useVcsPullAction, +} from "~/lib/sourceControlActions"; +import { refreshVcsStatus, useVcsStatus } from "~/lib/vcsStatusState"; import { useSourceControlDiscovery } from "~/lib/sourceControlDiscoveryState"; import { newCommandId, randomUUID } from "~/lib/utils"; import { resolvePathLinkTarget } from "~/terminal-links"; @@ -128,6 +128,7 @@ interface RunGitActionWithToastInput { } const GIT_STATUS_WINDOW_REFRESH_DEBOUNCE_MS = 250; +const RUNNING_SOURCE_CONTROL_ACTIONS = ["runStackedAction", "pull", "publishRepository"] as const; const PUBLISH_PROVIDER_OPTIONS = [ { @@ -231,75 +232,6 @@ function resolveProgressDescription(progress: ActiveGitActionProgress): string | return formatElapsedDescription(progress.hookStartedAtMs ?? progress.phaseStartedAtMs); } -function getMenuActionDisabledReason({ - item, - gitStatus, - isBusy, - hasPrimaryRemote, -}: { - item: GitActionMenuItem; - gitStatus: VcsStatusResult | null; - isBusy: boolean; - hasPrimaryRemote: boolean; -}): string | null { - if (!item.disabled) return null; - if (isBusy) return "Git action in progress."; - if (!gitStatus) return "Git status is unavailable."; - - const hasBranch = gitStatus.refName !== null; - const hasChanges = gitStatus.hasWorkingTreeChanges; - const hasOpenPr = gitStatus.pr?.state === "open"; - const isAhead = gitStatus.aheadCount > 0; - const isBehind = gitStatus.behindCount > 0; - const terminology = getSourceControlPresentation(gitStatus.sourceControlProvider).terminology; - - if (item.id === "commit") { - if (!hasChanges) { - return "Worktree is clean. Make changes before committing."; - } - return "Commit is currently unavailable."; - } - - if (item.id === "push") { - if (!hasBranch) { - return "Detached HEAD: checkout a refName before pushing."; - } - if (hasChanges) { - return "Commit or stash local changes before pushing."; - } - if (isBehind) { - return "Branch is behind upstream. Pull/rebase before pushing."; - } - if (!gitStatus.hasUpstream && !hasPrimaryRemote) { - return 'Add an "origin" remote before pushing.'; - } - if (!isAhead) { - return "No local commits to push."; - } - return "Push is currently unavailable."; - } - - if (hasOpenPr) { - return `View ${terminology.singular} is currently unavailable.`; - } - if (!hasBranch) { - return `Detached HEAD: checkout a refName before creating a ${terminology.singular}.`; - } - if (hasChanges) { - return `Commit local changes before creating a ${terminology.singular}.`; - } - if (!gitStatus.hasUpstream && !hasPrimaryRemote) { - return `Add an "origin" remote before creating a ${terminology.singular}.`; - } - if (!isAhead) { - return `No local commits to include in a ${terminology.singular}.`; - } - if (isBehind) { - return `Branch is behind upstream. Pull/rebase before creating a ${terminology.singular}.`; - } - return `Create ${terminology.singular} is currently unavailable.`; -} - const COMMIT_DIALOG_TITLE = "Commit changes"; const COMMIT_DIALOG_DESCRIPTION = "Review and confirm your commit. Leave the message blank to auto-generate one."; @@ -346,7 +278,6 @@ interface PublishRepositoryDialogProps { } function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { - const queryClient = useQueryClient(); const navigate = useNavigate(); const sourceControlDiscovery = useSourceControlDiscovery(); const [publishProvider, setPublishProvider] = useState("github"); @@ -362,13 +293,14 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { null, ); const [hasUserEditedPublishRepository, setHasUserEditedPublishRepository] = useState(false); - const publishRepositoryMutation = useMutation( - sourceControlPublishRepositoryMutationOptions({ + const sourceControlScope = useMemo( + () => ({ environmentId: props.environmentId, cwd: props.gitCwd, - queryClient, }), + [props.environmentId, props.gitCwd], ); + const publishRepositoryAction = useSourceControlPublishRepositoryAction(sourceControlScope); const publishAccountByProvider = useMemo(() => { const accounts: Record = { github: null, @@ -435,13 +367,13 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { const canSubmitPublishRepository = useMemo(() => { if (!selectedPublishProviderReadiness.ready) return false; - if (publishRepositoryMutation.isPending) return false; + if (publishRepositoryAction.isPending) return false; const repositoryParts = publishRepository.trim().split("/"); const owner = repositoryParts[0]?.trim() ?? ""; const rest = repositoryParts.slice(1); const name = rest.join("/").trim(); return owner.length > 0 && name.length > 0; - }, [publishRepository, publishRepositoryMutation.isPending, selectedPublishProviderReadiness]); + }, [publishRepository, publishRepositoryAction.isPending, selectedPublishProviderReadiness]); useEffect(() => { if (!props.open) { @@ -465,8 +397,8 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { setPublishError(null); - void publishRepositoryMutation - .mutateAsync({ + void publishRepositoryAction + .run({ provider: publishProvider, repository: publishRepository.trim(), visibility: publishVisibility, @@ -478,7 +410,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { setPublishResult(result); setPublishWizardStep(2); }); - void refreshGitStatus({ environmentId: props.environmentId, cwd: props.gitCwd }).catch( + void refreshVcsStatus({ environmentId: props.environmentId, cwd: props.gitCwd }).catch( () => undefined, ); }) @@ -493,7 +425,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { publishProvider, publishRemoteName, publishRepository, - publishRepositoryMutation, + publishRepositoryAction, publishVisibility, ]); @@ -689,7 +621,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { } }} placeholder={publishPathPlaceholder} - disabled={publishRepositoryMutation.isPending} + disabled={publishRepositoryAction.isPending} className="w-full bg-transparent px-3 py-2 font-mono text-sm placeholder:text-muted-foreground/60 focus:outline-none" /> @@ -708,7 +640,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { setPublishVisibility(value as SourceControlRepositoryVisibility) } aria-labelledby="publish-visibility-cards-label" - disabled={publishRepositoryMutation.isPending} + disabled={publishRepositoryAction.isPending} className="grid grid-cols-2 gap-2.5" > {[ @@ -780,7 +712,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { value={publishRemoteName} onChange={(event) => setPublishRemoteName(event.target.value)} placeholder="origin" - disabled={publishRepositoryMutation.isPending} + disabled={publishRepositoryAction.isPending} />
@@ -796,7 +728,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { setPublishProtocol(value as SourceControlCloneProtocol) } aria-labelledby="publish-protocol-label" - disabled={publishRepositoryMutation.isPending} + disabled={publishRepositoryAction.isPending} className="grid grid-cols-2 gap-2" > {(["ssh", "https"] as const).map((value) => { @@ -823,7 +755,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { ) : null}
- {publishRepositoryMutation.isPending ? ( + {publishRepositoryAction.isPending ? (
) : null} - {publishError && !publishRepositoryMutation.isPending ? ( + {publishError && !publishRepositoryAction.isPending ? (
{ if (publishWizardStep === 0) { handleOpenChange(false); @@ -926,7 +858,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { disabled={!canSubmitPublishRepository} onClick={submitPublishRepository} > - {publishRepositoryMutation.isPending ? ( + {publishRepositoryAction.isPending ? ( <> Publishing... @@ -969,7 +901,6 @@ export default function GitActionsControl({ ); const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); const setThreadBranch = useStore((store) => store.setThreadBranch); - const queryClient = useQueryClient(); const [isCommitDialogOpen, setIsCommitDialogOpen] = useState(false); const [dialogCommitMessage, setDialogCommitMessage] = useState(""); const [excludedFiles, setExcludedFiles] = useState>(new Set()); @@ -978,6 +909,10 @@ export default function GitActionsControl({ const [pendingDefaultBranchAction, setPendingDefaultBranchAction] = useState(null); const activeGitActionProgressRef = useRef(null); + const sourceControlScope = useMemo( + () => ({ environmentId: activeEnvironmentId, cwd: gitCwd }), + [activeEnvironmentId, gitCwd], + ); let runGitActionWithToast: (input: RunGitActionWithToastInput) => Promise; const updateActiveProgressToast = useCallback(() => { @@ -1054,7 +989,7 @@ export default function GitActionsControl({ [persistThreadBranchSync], ); - const { data: gitStatus = null, error: gitStatusError } = useGitStatus({ + const { data: gitStatus = null, error: gitStatusError } = useVcsStatus({ environmentId: activeEnvironmentId, cwd: gitCwd, }); @@ -1074,32 +1009,13 @@ export default function GitActionsControl({ const allSelected = excludedFiles.size === 0; const noneSelected = selectedFiles.length === 0; - const initMutation = useMutation( - gitInitMutationOptions({ environmentId: activeEnvironmentId, cwd: gitCwd, queryClient }), - ); - - const runImmediateGitActionMutation = useMutation( - gitRunStackedActionMutationOptions({ - environmentId: activeEnvironmentId, - cwd: gitCwd, - queryClient, - }), + const initAction = useVcsInitAction(sourceControlScope); + const runImmediateGitAction = useGitStackedAction(sourceControlScope); + const pullAction = useVcsPullAction(sourceControlScope); + const isGitActionRunning = useSourceControlActionRunning( + sourceControlScope, + RUNNING_SOURCE_CONTROL_ACTIONS, ); - const pullMutation = useMutation( - gitPullMutationOptions({ environmentId: activeEnvironmentId, cwd: gitCwd, queryClient }), - ); - - const isRunStackedActionRunning = - useIsMutating({ - mutationKey: gitMutationKeys.runStackedAction(activeEnvironmentId, gitCwd), - }) > 0; - const isPullRunning = - useIsMutating({ mutationKey: gitMutationKeys.pull(activeEnvironmentId, gitCwd) }) > 0; - const isPublishRunning = - useIsMutating({ - mutationKey: gitMutationKeys.publishRepository(activeEnvironmentId, gitCwd), - }) > 0; - const isGitActionRunning = isRunStackedActionRunning || isPullRunning || isPublishRunning; const isSelectingWorktreeBase = !activeServerThread && activeDraftThread?.envMode === "worktree" && @@ -1178,7 +1094,7 @@ export default function GitActionsControl({ } refreshTimeout = window.setTimeout(() => { refreshTimeout = null; - void refreshGitStatus({ environmentId: activeEnvironmentId, cwd: gitCwd }).catch( + void refreshVcsStatus({ environmentId: activeEnvironmentId, cwd: gitCwd }).catch( () => undefined, ); }, GIT_STATUS_WINDOW_REFRESH_DEBOUNCE_MS); @@ -1376,7 +1292,7 @@ export default function GitActionsControl({ updateActiveProgressToast(); }; - const promise = runImmediateGitActionMutation.mutateAsync({ + const promise = runImmediateGitAction.run({ actionId, action, ...(commitMessage ? { commitMessage } : {}), @@ -1517,26 +1433,26 @@ export default function GitActionsControl({ return; } if (quickAction.kind === "run_pull") { - const promise = pullMutation.mutateAsync(); - void toastManager.promise< - Awaited>, - ThreadToastData - >(promise, { - loading: { title: "Pulling...", data: threadToastData }, - success: (result) => ({ - title: result.status === "pulled" ? "Pulled" : "Already up to date", - description: - result.status === "pulled" - ? `Updated ${result.refName} from ${result.upstreamRef ?? "upstream"}` - : `${result.refName} is already synchronized.`, - data: threadToastData, - }), - error: (err) => ({ - title: "Pull failed", - description: err instanceof Error ? err.message : "An error occurred.", - data: threadToastData, - }), - }); + const promise = pullAction.run(); + void toastManager.promise>, ThreadToastData>( + promise, + { + loading: { title: "Pulling...", data: threadToastData }, + success: (result) => ({ + title: result.status === "pulled" ? "Pulled" : "Already up to date", + description: + result.status === "pulled" + ? `Updated ${result.refName} from ${result.upstreamRef ?? "upstream"}` + : `${result.refName} is already synchronized.`, + data: threadToastData, + }), + error: (err) => ({ + title: "Pull failed", + description: err instanceof Error ? err.message : "An error occurred.", + data: threadToastData, + }), + }, + ); void promise.catch(() => undefined); return; } @@ -1623,10 +1539,12 @@ export default function GitActionsControl({ ) : ( @@ -1672,7 +1590,7 @@ export default function GitActionsControl({ { if (open) { - void refreshGitStatus({ + void refreshVcsStatus({ environmentId: activeEnvironmentId, cwd: gitCwd, }).catch(() => undefined); @@ -1687,7 +1605,7 @@ export default function GitActionsControl({ {gitActionMenuItems.map((item) => { - const disabledReason = getMenuActionDisabledReason({ + const disabledReason = getGitActionDisabledReason({ item, gitStatus: gitStatusForActions, isBusy: isGitActionRunning, diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index 611eaf572d0..4320f6ecf4a 100644 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ b/apps/web/src/components/KeybindingsToast.browser.tsx @@ -33,12 +33,38 @@ import { useStore } from "../store"; import { createAuthenticatedSessionHandlers } from "../../test/authHttpHandlers"; import { BrowserWsRpcHarness } from "../../test/wsRpcHarness"; -vi.mock("../lib/gitStatusState", () => ({ - useGitStatus: () => ({ data: null, error: null, cause: null, isPending: false }), - useGitStatuses: () => new Map(), - refreshGitStatus: () => Promise.resolve(null), - resetGitStatusStateForTests: () => undefined, -})); +vi.mock("../lib/vcsStatusState", () => { + const status = { + data: { + isRepo: true, + sourceControlProvider: { + kind: "github", + name: "GitHub", + baseUrl: "https://github.com", + }, + hasPrimaryRemote: true, + isDefaultRef: true, + refName: "main", + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + pr: null, + }, + error: null, + cause: null, + isPending: false, + }; + + return { + getVcsStatusSnapshot: () => status, + useVcsStatus: () => status, + useVcsStatuses: () => new Map(), + refreshVcsStatus: () => Promise.resolve(null), + resetVcsStatusStateForTests: () => undefined, + }; +}); const THREAD_ID = "thread-kb-toast-test" as ThreadId; const PROJECT_ID = "project-1" as ProjectId; diff --git a/apps/web/src/components/PullRequestThreadDialog.tsx b/apps/web/src/components/PullRequestThreadDialog.tsx index 7c241afdedb..7e2ac54a586 100644 --- a/apps/web/src/components/PullRequestThreadDialog.tsx +++ b/apps/web/src/components/PullRequestThreadDialog.tsx @@ -1,13 +1,13 @@ -import type { EnvironmentId, GitResolvePullRequestResult, ThreadId } from "@t3tools/contracts"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { useDebouncedValue } from "@tanstack/react-pacer"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { - gitPreparePullRequestThreadMutationOptions, - gitResolvePullRequestQueryOptions, -} from "~/lib/gitReactQuery"; -import { useGitStatus } from "~/lib/gitStatusState"; + readCachedPullRequestResolution, + usePreparePullRequestThreadAction, + usePullRequestResolution, +} from "~/lib/sourceControlActions"; +import { useVcsStatus } from "~/lib/vcsStatusState"; import { cn } from "~/lib/utils"; import { parsePullRequestReference } from "~/pullRequestReference"; import { getSourceControlPresentation } from "~/sourceControlPresentation"; @@ -43,7 +43,6 @@ export function PullRequestThreadDialog({ onOpenChange, onPrepared, }: PullRequestThreadDialogProps) { - const queryClient = useQueryClient(); const referenceInputRef = useRef(null); const [reference, setReference] = useState(initialReference ?? ""); const [referenceDirty, setReferenceDirty] = useState(false); @@ -53,7 +52,7 @@ export function PullRequestThreadDialog({ { wait: 450 }, (debouncerState) => ({ isPending: debouncerState.isPending }), ); - const { data: gitStatus = null } = useGitStatus({ environmentId, cwd }); + const { data: gitStatus = null } = useVcsStatus({ environmentId, cwd }); const sourceControlPresentation = useMemo( () => getSourceControlPresentation(gitStatus?.sourceControlProvider), [gitStatus?.sourceControlProvider], @@ -81,33 +80,30 @@ export function PullRequestThreadDialog({ const parsedReference = parsePullRequestReference(reference); const parsedDebouncedReference = parsePullRequestReference(debouncedReference); - const resolvePullRequestQuery = useQuery( - gitResolvePullRequestQueryOptions({ + const sourceControlScope = useMemo( + () => ({ environmentId, cwd, - reference: open ? parsedDebouncedReference : null, }), + [cwd, environmentId], ); + const pullRequestResolution = usePullRequestResolution({ + ...sourceControlScope, + reference: open ? parsedDebouncedReference : null, + }); const cachedPullRequest = useMemo(() => { - if (!cwd || !parsedReference) { - return null; - } - const cached = queryClient.getQueryData([ - "git", - "pull-request", - environmentId, - cwd, - parsedReference, - ]); - return cached?.pullRequest ?? null; - }, [cwd, environmentId, parsedReference, queryClient]); - const preparePullRequestThreadMutation = useMutation( - gitPreparePullRequestThreadMutationOptions({ environmentId, cwd, queryClient }), - ); + return ( + readCachedPullRequestResolution({ + ...sourceControlScope, + reference: parsedReference, + })?.pullRequest ?? null + ); + }, [parsedReference, sourceControlScope]); + const preparePullRequestThreadAction = usePreparePullRequestThreadAction(sourceControlScope); const liveResolvedPullRequest = parsedReference !== null && parsedReference === parsedDebouncedReference - ? (resolvePullRequestQuery.data?.pullRequest ?? null) + ? (pullRequestResolution.data?.pullRequest ?? null) : null; const resolvedPullRequest = liveResolvedPullRequest ?? cachedPullRequest; const isResolving = @@ -116,8 +112,8 @@ export function PullRequestThreadDialog({ resolvedPullRequest === null && (referenceDebouncer.state.isPending || parsedReference !== parsedDebouncedReference || - resolvePullRequestQuery.isPending || - resolvePullRequestQuery.isFetching); + pullRequestResolution.isPending || + pullRequestResolution.isFetching); const statusTone = useMemo(() => { switch (resolvedPullRequest?.state) { case "merged": @@ -142,7 +138,7 @@ export function PullRequestThreadDialog({ } setPreparingMode(mode); try { - const result = await preparePullRequestThreadMutation.mutateAsync({ + const result = await preparePullRequestThreadAction.run({ reference: parsedReference, mode, ...(mode === "worktree" ? { threadId } : {}), @@ -161,7 +157,7 @@ export function PullRequestThreadDialog({ onOpenChange, onPrepared, parsedReference, - preparePullRequestThreadMutation, + preparePullRequestThreadAction, resolvedPullRequest, threadId, ], @@ -176,13 +172,13 @@ export function PullRequestThreadDialog({ : null; const errorMessage = validationMessage ?? - (resolvedPullRequest === null && resolvePullRequestQuery.isError - ? resolvePullRequestQuery.error instanceof Error - ? resolvePullRequestQuery.error.message + (resolvedPullRequest === null && pullRequestResolution.error + ? pullRequestResolution.error instanceof Error + ? pullRequestResolution.error.message : `Failed to resolve ${terminology.singular}.` - : preparePullRequestThreadMutation.error instanceof Error - ? preparePullRequestThreadMutation.error.message - : preparePullRequestThreadMutation.error + : preparePullRequestThreadAction.error instanceof Error + ? preparePullRequestThreadAction.error.message + : preparePullRequestThreadAction.error ? `Failed to prepare ${terminology.singular} thread.` : null); @@ -190,7 +186,7 @@ export function PullRequestThreadDialog({ { - if (!preparePullRequestThreadMutation.isPending) { + if (!preparePullRequestThreadAction.isPending) { onOpenChange(nextOpen); } }} @@ -224,7 +220,7 @@ export function PullRequestThreadDialog({ return; } event.preventDefault(); - if (!isResolving && !preparePullRequestThreadMutation.isPending) { + if (!isResolving && !preparePullRequestThreadAction.isPending) { void handleConfirm("local"); } }} @@ -263,7 +259,7 @@ export function PullRequestThreadDialog({ variant="outline" size="sm" onClick={() => onOpenChange(false)} - disabled={preparePullRequestThreadMutation.isPending} + disabled={preparePullRequestThreadAction.isPending} > Cancel @@ -278,7 +274,7 @@ export function PullRequestThreadDialog({ !cwd || !resolvedPullRequest || isResolving || - preparePullRequestThreadMutation.isPending + preparePullRequestThreadAction.isPending } > {preparingMode === "local" ? "Preparing local..." : "Local"} @@ -293,7 +289,7 @@ export function PullRequestThreadDialog({ !cwd || !resolvedPullRequest || isResolving || - preparePullRequestThreadMutation.isPending + preparePullRequestThreadAction.isPending } > {preparingMode === "worktree" ? "Preparing worktree..." : "Worktree"} diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 39b759ac313..b9dd27dfb03 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -300,7 +300,7 @@ export function resolveThreadRowClassName(input: { isSelected: boolean; }): string { const baseClassName = - "h-7 w-full translate-x-0 cursor-pointer justify-start px-2 text-left select-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring"; + "h-6 w-full translate-x-0 cursor-pointer justify-start px-2 text-left select-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring sm:h-7"; if (input.isSelected && input.isActive) { return cn( diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 3d0ccd3ab7c..a3d033846b0 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -73,7 +73,8 @@ import { selectThreadByRef, useStore, } from "../store"; -import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; +import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; +import { useThreadRunningTerminalIds } from "../terminalSessionState"; import { useUiStateStore } from "../uiStateStore"; import { resolveShortcutCommand, @@ -85,7 +86,7 @@ import { } from "../keybindings"; import { useModelPickerOpen } from "../modelPickerOpenState"; import { useShortcutModifierState } from "../shortcutModifierState"; -import { useGitStatus } from "../lib/gitStatusState"; +import { useVcsStatus } from "../lib/vcsStatusState"; import { readLocalApi } from "../localApi"; import { useComposerDraftStore } from "../composerDraftStore"; import { useNewThreadHandler } from "../hooks/useHandleNewThread"; @@ -341,10 +342,10 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP const threadKey = scopedThreadKey(threadRef); const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]); const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey)); - const runningTerminalIds = useTerminalStateStore( - (state) => - selectThreadTerminalState(state.terminalStateByThreadKey, threadRef).runningTerminalIds, - ); + const runningTerminalIds = useThreadRunningTerminalIds({ + environmentId: thread.environmentId, + threadId: thread.id, + }); const primaryEnvironmentId = usePrimaryEnvironmentId(); const isRemoteThread = primaryEnvironmentId !== null && thread.environmentId !== primaryEnvironmentId; @@ -369,7 +370,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP ), ); const gitCwd = thread.worktreePath ?? threadProjectCwd ?? props.projectCwd; - const gitStatus = useGitStatus({ + const gitStatus = useVcsStatus({ environmentId: thread.environmentId, cwd: thread.branch != null ? gitCwd : null, }); @@ -808,7 +809,7 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( return ( {shouldShowThreadPanel && showEmptyThreadState ? ( @@ -2931,8 +2932,8 @@ export default function Sidebar() { () => ({ terminalFocus: isTerminalFocused(), terminalOpen: routeThreadRef - ? selectThreadTerminalState( - useTerminalStateStore.getState().terminalStateByThreadKey, + ? selectThreadTerminalUiState( + useTerminalUiStateStore.getState().terminalUiStateByThreadKey, routeThreadRef, ).terminalOpen : false, @@ -3139,8 +3140,8 @@ export default function Sidebar() { () => ({ terminalFocus: false, terminalOpen: routeThreadRef - ? selectThreadTerminalState( - useTerminalStateStore.getState().terminalStateByThreadKey, + ? selectThreadTerminalUiState( + useTerminalUiStateStore.getState().terminalUiStateByThreadKey, routeThreadRef, ).terminalOpen : false, diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index 5ed0c0e4c40..2423799ac37 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -7,9 +7,9 @@ import { useSavedEnvironmentRegistryStore, useSavedEnvironmentRuntimeStore, } from "../environments/runtime"; -import { useGitStatus } from "../lib/gitStatusState"; +import { useVcsStatus } from "../lib/vcsStatusState"; import { type AppState, selectProjectByRef, useStore } from "../store"; -import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; +import { useThreadRunningTerminalIds } from "../terminalSessionState"; import { useUiStateStore } from "../uiStateStore"; import { resolveChangeRequestPresentation } from "../sourceControlPresentation"; import { resolveThreadStatusPill, type ThreadStatusPill } from "./Sidebar.logic"; @@ -81,7 +81,7 @@ export function resolveThreadPr( } export function terminalStatusFromRunningIds( - runningTerminalIds: string[], + runningTerminalIds: ReadonlyArray, ): TerminalStatusIndicator | null { if (runningTerminalIds.length === 0) { return null; @@ -150,7 +150,7 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar ), ); const gitCwd = thread.worktreePath ?? threadProjectCwd; - const gitStatus = useGitStatus({ + const gitStatus = useVcsStatus({ environmentId: thread.environmentId, cwd: thread.branch != null ? gitCwd : null, }); @@ -195,11 +195,10 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar * environment indicator, matching the sidebar's trailing indicators. */ export function ThreadRowTrailingStatus({ thread }: { thread: SidebarThreadSummary }) { - const threadRef = scopeThreadRef(thread.environmentId, thread.id); - const runningTerminalIds = useTerminalStateStore( - (state) => - selectThreadTerminalState(state.terminalStateByThreadKey, threadRef).runningTerminalIds, - ); + const runningTerminalIds = useThreadRunningTerminalIds({ + environmentId: thread.environmentId, + threadId: thread.id, + }); const primaryEnvironmentId = usePrimaryEnvironmentId(); const isRemoteThread = primaryEnvironmentId !== null && thread.environmentId !== primaryEnvironmentId; diff --git a/apps/web/src/components/ThreadTerminalDrawer.browser.tsx b/apps/web/src/components/ThreadTerminalDrawer.browser.tsx index 2df2e04f5c4..bddd5ef6290 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.browser.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.browser.tsx @@ -1,7 +1,7 @@ import "../index.css"; import { scopeThreadRef } from "@t3tools/client-runtime"; -import { ThreadId } from "@t3tools/contracts"; +import { ThreadId, type TerminalAttachStreamEvent } from "@t3tools/contracts"; import { afterEach, describe, expect, it, vi } from "vitest"; import { render } from "vitest-browser-react"; @@ -18,7 +18,17 @@ const { terminalDisposeSpy: vi.fn(), fitAddonFitSpy: vi.fn(), fitAddonLoadSpy: vi.fn(), - environmentApiById: new Map } }>(), + environmentApiById: new Map< + string, + { + terminal: { + open: ReturnType; + attach: ReturnType; + write: ReturnType; + resize: ReturnType; + }; + } + >(), readEnvironmentApiMock: vi.fn((environmentId: string) => environmentApiById.get(environmentId)), readLocalApiMock: vi.fn< () => @@ -124,20 +134,33 @@ import { TerminalViewport } from "./ThreadTerminalDrawer"; const THREAD_ID = ThreadId.make("thread-terminal-browser"); function createEnvironmentApi() { + const snapshot = { + threadId: THREAD_ID, + terminalId: "term-1", + cwd: "/repo/project", + worktreePath: null, + status: "running" as const, + pid: 123, + history: "", + exitCode: null, + exitSignal: null, + label: "Terminal 1", + updatedAt: "2026-04-07T00:00:00.000Z", + }; + return { terminal: { - open: vi.fn(async () => ({ - threadId: THREAD_ID, - terminalId: "default", - cwd: "/repo/project", - worktreePath: null, - status: "running" as const, - pid: 123, - history: "", - exitCode: null, - exitSignal: null, - updatedAt: "2026-04-07T00:00:00.000Z", - })), + open: vi.fn(async () => snapshot), + attach: vi.fn( + ( + _input: unknown, + listener: (event: TerminalAttachStreamEvent) => void, + _options?: unknown, + ) => { + listener({ type: "snapshot", snapshot }); + return vi.fn(); + }, + ), write: vi.fn(async () => undefined), resize: vi.fn(async () => undefined), }, @@ -148,6 +171,7 @@ async function mountTerminalViewport(props: { threadRef: ReturnType; drawerBackgroundColor?: string; drawerTextColor?: string; + runtimeEnv?: Record; }) { const drawer = document.createElement("div"); drawer.className = "thread-terminal-drawer"; @@ -168,9 +192,10 @@ async function mountTerminalViewport(props: { undefined} onAddTerminalContext={() => undefined} focusRequestId={0} @@ -183,14 +208,18 @@ async function mountTerminalViewport(props: { ); return { - rerender: async (nextProps: { threadRef: ReturnType }) => { + rerender: async (nextProps: { + threadRef: ReturnType; + runtimeEnv?: Record; + }) => { await screen.rerender( undefined} onAddTerminalContext={() => undefined} focusRequestId={0} @@ -236,7 +265,48 @@ describe("TerminalViewport", () => { } }); - it("reopens the terminal when the scoped thread reference changes", async () => { + it("renders and attaches the terminal without the desktop local API", async () => { + const environment = createEnvironmentApi(); + environmentApiById.set("environment-a", environment); + readLocalApiMock.mockReturnValueOnce(undefined); + + const mounted = await mountTerminalViewport({ + threadRef: scopeThreadRef("environment-a" as never, THREAD_ID), + }); + + try { + await vi.waitFor(() => { + expect(environment.terminal.attach).toHaveBeenCalledTimes(1); + }); + expect(terminalConstructorSpy).toHaveBeenCalledTimes(1); + } finally { + await mounted.cleanup(); + } + }); + + it("keeps the terminal mounted when xterm fit runs before dimensions are ready", async () => { + const environment = createEnvironmentApi(); + environmentApiById.set("environment-a", environment); + fitAddonFitSpy.mockImplementationOnce(() => { + throw new TypeError("Cannot read properties of undefined (reading 'dimensions')"); + }); + + const mounted = await mountTerminalViewport({ + threadRef: scopeThreadRef("environment-a" as never, THREAD_ID), + }); + + try { + await vi.waitFor(() => { + expect(environment.terminal.attach).toHaveBeenCalledTimes(1); + }); + expect(terminalConstructorSpy).toHaveBeenCalledTimes(1); + expect(fitAddonFitSpy).toHaveBeenCalled(); + } finally { + await mounted.cleanup(); + } + }); + + it("reattaches the terminal when the scoped thread reference changes", async () => { const environmentA = createEnvironmentApi(); const environmentB = createEnvironmentApi(); environmentApiById.set("environment-a", environmentA); @@ -248,7 +318,7 @@ describe("TerminalViewport", () => { try { await vi.waitFor(() => { - expect(environmentA.terminal.open).toHaveBeenCalledTimes(1); + expect(environmentA.terminal.attach).toHaveBeenCalledTimes(1); }); await mounted.rerender({ @@ -256,7 +326,7 @@ describe("TerminalViewport", () => { }); await vi.waitFor(() => { - expect(environmentB.terminal.open).toHaveBeenCalledTimes(1); + expect(environmentB.terminal.attach).toHaveBeenCalledTimes(1); }); expect(terminalDisposeSpy).toHaveBeenCalledTimes(1); } finally { @@ -264,25 +334,53 @@ describe("TerminalViewport", () => { } }); - it("does not reopen the terminal when the scoped thread reference values stay the same", async () => { + it("does not reattach the terminal when the scoped thread reference values stay the same", async () => { + const environment = createEnvironmentApi(); + environmentApiById.set("environment-a", environment); + + const mounted = await mountTerminalViewport({ + threadRef: scopeThreadRef("environment-a" as never, THREAD_ID), + }); + + try { + await vi.waitFor(() => { + expect(environment.terminal.attach).toHaveBeenCalledTimes(1); + }); + + await mounted.rerender({ + threadRef: scopeThreadRef("environment-a" as never, THREAD_ID), + }); + + await vi.waitFor(() => { + expect(environment.terminal.attach).toHaveBeenCalledTimes(1); + }); + expect(terminalDisposeSpy).not.toHaveBeenCalled(); + } finally { + await mounted.cleanup(); + } + }); + + it("does not reattach when runtime env contents are unchanged but object identity changes", async () => { const environment = createEnvironmentApi(); environmentApiById.set("environment-a", environment); const mounted = await mountTerminalViewport({ threadRef: scopeThreadRef("environment-a" as never, THREAD_ID), + runtimeEnv: { PATH: "/usr/bin", T3: "1" }, }); try { await vi.waitFor(() => { - expect(environment.terminal.open).toHaveBeenCalledTimes(1); + expect(environment.terminal.attach).toHaveBeenCalledTimes(1); }); await mounted.rerender({ threadRef: scopeThreadRef("environment-a" as never, THREAD_ID), + runtimeEnv: { T3: "1", PATH: "/usr/bin" }, }); await vi.waitFor(() => { - expect(environment.terminal.open).toHaveBeenCalledTimes(1); + expect(environment.terminal.attach).toHaveBeenCalledTimes(1); }); expect(terminalDisposeSpy).not.toHaveBeenCalled(); } finally { diff --git a/apps/web/src/components/ThreadTerminalDrawer.test.ts b/apps/web/src/components/ThreadTerminalDrawer.test.ts index d9740cc305f..4d59583777b 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.test.ts +++ b/apps/web/src/components/ThreadTerminalDrawer.test.ts @@ -2,8 +2,6 @@ import { describe, expect, it } from "vitest"; import { resolveTerminalSelectionActionPosition, - selectPendingTerminalEventEntries, - selectTerminalEventEntriesAfterSnapshot, shouldHandleTerminalSelectionMouseUp, terminalSelectionActionDelayForClickCount, } from "./ThreadTerminalDrawer"; @@ -74,64 +72,4 @@ describe("resolveTerminalSelectionActionPosition", () => { expect(shouldHandleTerminalSelectionMouseUp(false, 0)).toBe(false); expect(shouldHandleTerminalSelectionMouseUp(true, 1)).toBe(false); }); - - it("replays only terminal events newer than the open snapshot", () => { - expect( - selectTerminalEventEntriesAfterSnapshot( - [ - { - id: 1, - event: { - threadId: "thread-1", - terminalId: "default", - createdAt: "2026-04-02T20:00:00.000Z", - type: "output", - data: "before", - }, - }, - { - id: 2, - event: { - threadId: "thread-1", - terminalId: "default", - createdAt: "2026-04-02T20:00:01.000Z", - type: "output", - data: "after", - }, - }, - ], - "2026-04-02T20:00:00.500Z", - ).map((entry) => entry.id), - ).toEqual([2]); - }); - - it("applies only terminal events that have not already been consumed", () => { - expect( - selectPendingTerminalEventEntries( - [ - { - id: 1, - event: { - threadId: "thread-1", - terminalId: "default", - createdAt: "2026-04-02T20:00:00.000Z", - type: "output", - data: "one", - }, - }, - { - id: 2, - event: { - threadId: "thread-1", - terminalId: "default", - createdAt: "2026-04-02T20:00:01.000Z", - type: "output", - data: "two", - }, - }, - ], - 1, - ).map((entry) => entry.id), - ).toEqual([2]); - }); }); diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 6c71e5eb334..48fb9f0281a 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -3,10 +3,11 @@ import { Plus, SquareSplitHorizontal, TerminalSquare, Trash2, XIcon } from "luci import { type ResolvedKeybindingsConfig, type ScopedThreadRef, - type TerminalEvent, + type TerminalAttachStreamEvent, type TerminalSessionSnapshot, type ThreadId, } from "@t3tools/contracts"; +import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; import { Terminal, type ITheme } from "@xterm/xterm"; import { type PointerEvent as ReactPointerEvent, @@ -41,13 +42,12 @@ import { } from "../keybindings"; import { DEFAULT_THREAD_TERMINAL_HEIGHT, - DEFAULT_THREAD_TERMINAL_ID, MAX_TERMINALS_PER_GROUP, type ThreadTerminalGroup, } from "../types"; import { readEnvironmentApi } from "~/environmentApi"; import { readLocalApi } from "~/localApi"; -import { selectTerminalEventEntries, useTerminalStateStore } from "../terminalStateStore"; +import { attachTerminalSession } from "../terminalSessionState"; const MIN_DRAWER_HEIGHT = 180; const MAX_DRAWER_HEIGHT_RATIO = 0.75; @@ -75,18 +75,22 @@ function writeTerminalSnapshot(terminal: Terminal, snapshot: TerminalSessionSnap } } -export function selectTerminalEventEntriesAfterSnapshot( - entries: ReadonlyArray<{ id: number; event: TerminalEvent }>, - snapshotUpdatedAt: string, -): ReadonlyArray<{ id: number; event: TerminalEvent }> { - return entries.filter((entry) => entry.event.createdAt > snapshotUpdatedAt); +function fitTerminalSafely(fitAddon: FitAddon): boolean { + try { + fitAddon.fit(); + return true; + } catch { + return false; + } } -export function selectPendingTerminalEventEntries( - entries: ReadonlyArray<{ id: number; event: TerminalEvent }>, - lastAppliedTerminalEventId: number, -): ReadonlyArray<{ id: number; event: TerminalEvent }> { - return entries.filter((entry) => entry.id > lastAppliedTerminalEventId); +function runtimeEnvSignature(runtimeEnv: Record | undefined): string { + if (!runtimeEnv) return ""; + return JSON.stringify( + Object.entries(runtimeEnv) + .filter(([key, value]) => key.length > 0 && typeof value === "string") + .sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey)), + ); } function normalizeComputedColor(value: string | null | undefined, fallback: string): string { @@ -264,6 +268,12 @@ interface TerminalViewportProps { keybindings: ResolvedKeybindingsConfig; } +interface TerminalLaunchLocation { + readonly cwd: string; + readonly worktreePath?: string | null; + readonly runtimeEnv?: Record; +} + export function TerminalViewport({ threadRef, threadId, @@ -291,8 +301,7 @@ export function TerminalViewport({ const selectionActionOpenRef = useRef(false); const selectionActionTimerRef = useRef(null); const keybindingsRef = useRef(keybindings); - const lastAppliedTerminalEventIdRef = useRef(0); - const terminalHydratedRef = useRef(false); + const runtimeEnvKey = useMemo(() => runtimeEnvSignature(runtimeEnv), [runtimeEnv]); const handleSessionExited = useEffectEvent(() => { onSessionExited(); }); @@ -312,7 +321,7 @@ export function TerminalViewport({ let disposed = false; const api = readEnvironmentApi(environmentId); const localApi = readLocalApi(); - if (!api || !localApi) return; + if (!api) return; const fitAddon = new FitAddon(); const terminal = new Terminal({ @@ -325,7 +334,7 @@ export function TerminalViewport({ }); terminal.loadAddon(fitAddon); terminal.open(mount); - fitAddon.fit(); + fitTerminalSafely(fitAddon); terminalRef.current = terminal; fitAddonRef.current = fitAddon; @@ -379,6 +388,10 @@ export function TerminalViewport({ }; const showSelectionAction = async () => { + if (!localApi) { + clearSelectionAction(); + return; + } if (selectionActionOpenRef.current) { return; } @@ -489,6 +502,10 @@ export function TerminalViewport({ const latestTerminal = terminalRef.current; if (!latestTerminal) return; + if (!localApi) { + writeSystemMessage(latestTerminal, "Opening links is unavailable in this browser."); + return; + } if (match.kind === "url") { void localApi.shell.openExternal(match.text).catch((error: unknown) => { @@ -567,7 +584,7 @@ export function TerminalViewport({ attributeFilter: ["class", "style"], }); - const applyTerminalEvent = (event: TerminalEvent) => { + const applyAttachEvent = (event: TerminalAttachStreamEvent) => { const activeTerminal = terminalRef.current; if (!activeTerminal) { return; @@ -577,13 +594,20 @@ export function TerminalViewport({ return; } + if (event.type === "snapshot") { + hasHandledExitRef.current = false; + clearSelectionAction(); + writeTerminalSnapshot(activeTerminal, event.snapshot); + return; + } + if (event.type === "output") { activeTerminal.write(event.data); clearSelectionAction(); return; } - if (event.type === "started" || event.type === "restarted") { + if (event.type === "restarted") { hasHandledExitRef.current = false; clearSelectionAction(); writeTerminalSnapshot(activeTerminal, event.snapshot); @@ -602,16 +626,21 @@ export function TerminalViewport({ return; } - const details = [ - typeof event.exitCode === "number" ? `code ${event.exitCode}` : null, - typeof event.exitSignal === "number" ? `signal ${event.exitSignal}` : null, - ] - .filter((value): value is string => value !== null) - .join(", "); - writeSystemMessage( - activeTerminal, - details.length > 0 ? `Process exited (${details})` : "Process exited", - ); + if (event.type === "closed") { + writeSystemMessage(activeTerminal, "Terminal closed"); + } else { + const details = [ + typeof event.exitCode === "number" ? `code ${event.exitCode}` : null, + typeof event.exitSignal === "number" ? `signal ${event.exitSignal}` : null, + ] + .filter((value): value is string => value !== null) + .join(", "); + writeSystemMessage( + activeTerminal, + details.length > 0 ? `Process exited (${details})` : "Process exited", + ); + } + if (hasHandledExitRef.current) { return; } @@ -623,54 +652,16 @@ export function TerminalViewport({ handleSessionExited(); }, 0); }; - const applyPendingTerminalEvents = ( - terminalEventEntries: ReadonlyArray<{ id: number; event: TerminalEvent }>, - ) => { - const pendingEntries = selectPendingTerminalEventEntries( - terminalEventEntries, - lastAppliedTerminalEventIdRef.current, - ); - if (pendingEntries.length === 0) { - return; - } - for (const entry of pendingEntries) { - applyTerminalEvent(entry.event); - } - lastAppliedTerminalEventIdRef.current = - pendingEntries.at(-1)?.id ?? lastAppliedTerminalEventIdRef.current; - }; - - const unsubscribeTerminalEvents = useTerminalStateStore.subscribe((state, previousState) => { - if (!terminalHydratedRef.current) { - return; - } - - const previousLastEntryId = - selectTerminalEventEntries( - previousState.terminalEventEntriesByKey, - threadRef, - terminalId, - ).at(-1)?.id ?? 0; - const nextEntries = selectTerminalEventEntries( - state.terminalEventEntriesByKey, - threadRef, - terminalId, - ); - const nextLastEntryId = nextEntries.at(-1)?.id ?? 0; - if (nextLastEntryId === previousLastEntryId) { - return; - } - - applyPendingTerminalEvents(nextEntries); - }); - - const openTerminal = async () => { - try { - const activeTerminal = terminalRef.current; - const activeFitAddon = fitAddonRef.current; - if (!activeTerminal || !activeFitAddon) return; - activeFitAddon.fit(); - const snapshot = await api.terminal.open({ + let unsubscribeAttach: (() => void) | null = null; + const attachTerminal = () => { + const activeTerminal = terminalRef.current; + const activeFitAddon = fitAddonRef.current; + if (!activeTerminal || !activeFitAddon) return; + fitTerminalSafely(activeFitAddon); + unsubscribeAttach = attachTerminalSession({ + environmentId, + client: api, + terminal: { threadId, terminalId, cwd, @@ -678,35 +669,20 @@ export function TerminalViewport({ cols: activeTerminal.cols, rows: activeTerminal.rows, ...(runtimeEnv ? { env: runtimeEnv } : {}), - }); - if (disposed) return; - writeTerminalSnapshot(activeTerminal, snapshot); - const bufferedEntries = selectTerminalEventEntries( - useTerminalStateStore.getState().terminalEventEntriesByKey, - threadRef, - terminalId, - ); - const replayEntries = selectTerminalEventEntriesAfterSnapshot( - bufferedEntries, - snapshot.updatedAt, - ); - for (const entry of replayEntries) { - applyTerminalEvent(entry.event); - } - lastAppliedTerminalEventIdRef.current = bufferedEntries.at(-1)?.id ?? 0; - terminalHydratedRef.current = true; - if (autoFocus) { - window.requestAnimationFrame(() => { - activeTerminal.focus(); - }); - } - } catch (err) { - if (disposed) return; - writeSystemMessage( - terminal, - err instanceof Error ? err.message : "Failed to open terminal", - ); - } + }, + onEvent: (event) => { + if (disposed) return; + applyAttachEvent(event); + }, + onSnapshot: () => { + if (disposed) return; + if (autoFocus) { + window.requestAnimationFrame(() => { + activeTerminal.focus(); + }); + } + }, + }); }; const fitTimer = window.setTimeout(() => { @@ -715,7 +691,7 @@ export function TerminalViewport({ if (!activeTerminal || !activeFitAddon) return; const wasAtBottom = activeTerminal.buffer.active.viewportY >= activeTerminal.buffer.active.baseY; - activeFitAddon.fit(); + fitTerminalSafely(activeFitAddon); if (wasAtBottom) { activeTerminal.scrollToBottom(); } @@ -728,13 +704,12 @@ export function TerminalViewport({ }) .catch(() => undefined); }, 30); - void openTerminal(); + attachTerminal(); return () => { disposed = true; - terminalHydratedRef.current = false; - lastAppliedTerminalEventIdRef.current = 0; - unsubscribeTerminalEvents(); + unsubscribeAttach?.(); + unsubscribeAttach = null; window.clearTimeout(fitTimer); inputDisposable.dispose(); selectionDisposable.dispose(); @@ -752,7 +727,7 @@ export function TerminalViewport({ // autoFocus is intentionally omitted; // it is only read at mount time and must not trigger terminal teardown/recreation. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [cwd, environmentId, runtimeEnv, terminalId, threadId]); + }, [cwd, environmentId, runtimeEnvKey, terminalId, threadId, worktreePath]); useEffect(() => { if (!autoFocus) return; @@ -773,7 +748,7 @@ export function TerminalViewport({ if (!api || !terminal || !fitAddon) return; const wasAtBottom = terminal.buffer.active.viewportY >= terminal.buffer.active.baseY; const frame = window.requestAnimationFrame(() => { - fitAddon.fit(); + fitTerminalSafely(fitAddon); if (wasAtBottom) { terminal.scrollToBottom(); } @@ -821,6 +796,10 @@ interface ThreadTerminalDrawerProps { onHeightChange: (height: number) => void; onAddTerminalContext: (selection: TerminalContextSelection) => void; keybindings: ResolvedKeybindingsConfig; + /** Prefer server-provided tab titles when present (e.g. active subprocess name). */ + terminalLabelsById?: ReadonlyMap; + /** Prefer per-session launch locations when the server already knows a terminal. */ + terminalLaunchLocationsById?: ReadonlyMap; } interface TerminalActionButtonProps { @@ -875,6 +854,8 @@ export default function ThreadTerminalDrawer({ onHeightChange, onAddTerminalContext, keybindings, + terminalLabelsById, + terminalLaunchLocationsById, }: ThreadTerminalDrawerProps) { const [drawerHeight, setDrawerHeight] = useState(() => clampDrawerHeight(height)); const [resizeEpoch, setResizeEpoch] = useState(0); @@ -889,15 +870,20 @@ export default function ThreadTerminalDrawer({ const didResizeDuringDragRef = useRef(false); const normalizedTerminalIds = useMemo(() => { - const cleaned = [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))]; - return cleaned.length > 0 ? cleaned : [DEFAULT_THREAD_TERMINAL_ID]; + return [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))]; }, [terminalIds]); - const resolvedActiveTerminalId = normalizedTerminalIds.includes(activeTerminalId) - ? activeTerminalId - : (normalizedTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID); + const resolvedActiveTerminalId = + normalizedTerminalIds.length === 0 + ? "" + : normalizedTerminalIds.includes(activeTerminalId) + ? activeTerminalId + : (normalizedTerminalIds[0] ?? ""); const resolvedTerminalGroups = useMemo(() => { + if (normalizedTerminalIds.length === 0) { + return []; + } const validTerminalIdSet = new Set(normalizedTerminalIds); const assignedTerminalIds = new Set(); const usedGroupIds = new Set(); @@ -934,7 +920,7 @@ export default function ThreadTerminalDrawer({ const baseGroupId = terminalGroup.id.trim().length > 0 ? terminalGroup.id.trim() - : `group-${nextTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID}`; + : `group-${nextTerminalIds[0] ?? normalizedTerminalIds[0] ?? ""}`; nextGroups.push({ id: assignUniqueGroupId(baseGroupId), terminalIds: nextTerminalIds, @@ -949,17 +935,17 @@ export default function ThreadTerminalDrawer({ }); } - if (nextGroups.length > 0) { - return nextGroups; - } + const terminalOrderIndex = new Map( + normalizedTerminalIds.map((id, index) => [id, index] as const), + ); + nextGroups.sort((left, right) => { + const rank = (ids: readonly string[]) => + Math.min(...ids.map((id) => terminalOrderIndex.get(id) ?? Number.POSITIVE_INFINITY)); + return rank(left.terminalIds) - rank(right.terminalIds); + }); - return [ - { - id: `group-${resolvedActiveTerminalId}`, - terminalIds: [resolvedActiveTerminalId], - }, - ]; - }, [normalizedTerminalIds, resolvedActiveTerminalId, terminalGroups]); + return nextGroups; + }, [normalizedTerminalIds, terminalGroups]); const resolvedActiveGroupIndex = useMemo(() => { const indexById = resolvedTerminalGroups.findIndex( @@ -972,21 +958,33 @@ export default function ThreadTerminalDrawer({ return indexByTerminal >= 0 ? indexByTerminal : 0; }, [activeTerminalGroupId, resolvedActiveTerminalId, resolvedTerminalGroups]); - const visibleTerminalIds = resolvedTerminalGroups[resolvedActiveGroupIndex]?.terminalIds ?? [ - resolvedActiveTerminalId, - ]; + const visibleTerminalIds = + resolvedTerminalGroups[resolvedActiveGroupIndex]?.terminalIds ?? + (normalizedTerminalIds.length > 0 ? [resolvedActiveTerminalId] : []); const hasTerminalSidebar = normalizedTerminalIds.length > 1; const isSplitView = visibleTerminalIds.length > 1; const showGroupHeaders = resolvedTerminalGroups.length > 1 || resolvedTerminalGroups.some((terminalGroup) => terminalGroup.terminalIds.length > 1); const hasReachedSplitLimit = visibleTerminalIds.length >= MAX_TERMINALS_PER_GROUP; - const terminalLabelById = useMemo( - () => - new Map( - normalizedTerminalIds.map((terminalId, index) => [terminalId, `Terminal ${index + 1}`]), - ), - [normalizedTerminalIds], + const terminalLabelById = useMemo(() => { + const next = new Map(); + for (const terminalId of normalizedTerminalIds) { + next.set(terminalId, terminalLabelsById?.get(terminalId) ?? getTerminalLabel(terminalId)); + } + return next; + }, [normalizedTerminalIds, terminalLabelsById]); + const resolveTerminalLaunchLocation = useCallback( + (terminalId: string): TerminalLaunchLocation => { + return ( + terminalLaunchLocationsById?.get(terminalId) ?? { + cwd, + ...(worktreePath !== undefined ? { worktreePath } : {}), + ...(runtimeEnv ? { runtimeEnv } : {}), + } + ); + }, + [cwd, runtimeEnv, terminalLaunchLocationsById, worktreePath], ); const splitTerminalActionLabel = hasReachedSplitLimit ? `Split Terminal (max ${MAX_TERMINALS_PER_GROUP} per group)` @@ -1109,6 +1107,35 @@ export default function ThreadTerminalDrawer({ }; }, [syncHeight]); + if (normalizedTerminalIds.length === 0) { + return ( + + ); + } + + const activeTerminalLaunchLocation = resolveTerminalLaunchLocation(resolvedActiveTerminalId); + return (