diff --git a/.macroscope/check-run-agents/effect-service-conventions.md b/.macroscope/check-run-agents/effect-service-conventions.md index afbdc55ba60..09f4db07965 100644 --- a/.macroscope/check-run-agents/effect-service-conventions.md +++ b/.macroscope/check-run-agents/effect-service-conventions.md @@ -43,6 +43,15 @@ Review changed TypeScript and directly affected call sites for the conventions b - Keep implementation-specific names when an abstract port module contains one of several possible implementations, for example `makeCloudflaredRelayClient` and `layerCloudflared` in `RelayClient.ts`. - `infra/relay/src/db.ts` is an intentional exception: an inline `Layer.succeed(RelayDb, db)` is acceptable without generic `make`/`layer` exports. +## Dependency acquisition and runtime boundaries + +- Production service construction must acquire Effect service dependencies from the environment with `yield* Foo.Foo`, and its `make`/`layer` types must expose those requirements. Flag factories or constructors that accept `Foo["Service"]` (or a plain object whose methods return `Effect`) when that value is an implementation dependency owned by the service. Passing service instances explicitly is acceptable in tests and integration harnesses; passing pure configuration, immutable domain values, or deliberate callback strategies is not service injection. +- Do not hide dependencies in module globals, closures over singleton services, or `Layer.succeed` implementations that call runtime-backed or imperative APIs. Trace helpers used by a supposedly synchronous layer far enough to verify that asynchronous services are represented in the Effect environment. +- `ManagedRuntime.make`, `runPromise`, and `runPromiseExit` belong at explicit application/framework boundaries such as React, native callback, CLI, or HTTP adapters. Flag their use in domain services, repositories, persistence implementations, and service constructors. A clearly named imperative adapter may bridge an Effect service into a Promise API, but it must not become a dependency of another Effect service. +- Do not create per-feature managed runtimes or Atom runtimes to smuggle the same owned resource into multiple consumers. Compose the resource once in an application-owned layer/runtime and provide its context to integration runtimes. +- When acquisition can fail but a caller must retain fallback behavior, keep the failure typed in Effect rather than bypassing the layer through an imperative runtime. Model unavailability in service operations or with an explicit optional-service layer so downstream recovery remains visible and testable. +- During review, search touched code and affected call sites for service-instance parameters, `Layer.succeed`, `ManagedRuntime.make`, and `.runPromise`/`.runPromiseExit`. Verify that each occurrence is a legitimate test seam, pure value injection, or application boundary—not fake dependency injection or a hidden runtime. + ## Errors and predicates - Define service failures with `Schema.TaggedErrorClass` and structured attributes. Derive `message` from those attributes rather than storing an unstructured message as the only data. diff --git a/app.json b/app.json new file mode 100644 index 00000000000..306ca48315c --- /dev/null +++ b/app.json @@ -0,0 +1,3 @@ +{ + "expo": {} +} diff --git a/apps/desktop/package.json b/apps/desktop/package.json index c8418e31bac..ceac5792bbb 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -31,7 +31,7 @@ "@effect/vitest": "catalog:", "@types/node": "catalog:", "cross-env": "^10.1.0", - "electron-builder": "26.8.1", + "electron-builder": "26.15.6", "tailwindcss": "^4.0.0", "vite-plus": "catalog:" }, diff --git a/apps/marketing/src/pages/index.astro b/apps/marketing/src/pages/index.astro index dbd9f7c4f23..c20c3728021 100644 --- a/apps/marketing/src/pages/index.astro +++ b/apps/marketing/src/pages/index.astro @@ -254,7 +254,7 @@ const mobileEndorsementRows = [ @@ -403,12 +403,14 @@ const mobileEndorsementRows = [ const label = document.getElementById("download-label"); const ctaBtn = document.getElementById("cta-download-btn") as HTMLAnchorElement | null; const ctaLabel = document.getElementById("cta-download-label"); + const kbd = document.getElementById("pr-button-kbd"); const platform = detectPlatform(); if (!platform) return; document.documentElement.dataset.platform = platform.os; if (label) label.textContent = platform.label; if (ctaLabel) ctaLabel.textContent = platform.label; + if (kbd && platform.os !== "mac") kbd.textContent = "Ctrl ⏎"; try { const release = await fetchLatestRelease(); diff --git a/apps/mobile/README.md b/apps/mobile/README.md index ad0242c2714..552216bf353 100644 --- a/apps/mobile/README.md +++ b/apps/mobile/README.md @@ -34,6 +34,30 @@ Build and run the local iOS dev client: vp run ios:dev ``` +If your Xcode account only has a Personal Team, use a bundle identifier you control and opt into the +reduced-capability local build. Personal Team builds omit the widget extension, push entitlement, and +native Sign in with Apple entitlement; builds without this opt-in are unchanged. + +```bash +T3CODE_IOS_PERSONAL_TEAM=1 \ +T3CODE_IOS_PERSONAL_TEAM_BUNDLE_ID=com.example.t3code.dev \ +vp run ios:dev +``` + +Build and install a self-contained Release app that does not need Metro: + +```bash +vp run ios:release +``` + +The Personal Team equivalent also needs a unique bundle identifier: + +```bash +T3CODE_IOS_PERSONAL_TEAM=1 \ +T3CODE_IOS_PERSONAL_TEAM_BUNDLE_ID=com.example.t3code \ +vp run ios:release +``` + Build and run the local iOS preview app: ```bash diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 098b830aca3..f9bf88f45ce 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -8,6 +8,20 @@ const repoEnv = loadRepoEnv(); Object.assign(process.env, repoEnv); const APP_VARIANT = resolveAppVariant(repoEnv.APP_VARIANT); +const isIosPersonalTeamBuild = repoEnv.T3CODE_IOS_PERSONAL_TEAM === "1"; + +const personalTeamBundleIdentifier = repoEnv.T3CODE_IOS_PERSONAL_TEAM_BUNDLE_ID?.trim(); +const IOS_BUNDLE_IDENTIFIER_PATTERN = /^[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+$/; + +if ( + isIosPersonalTeamBuild && + (!personalTeamBundleIdentifier || + !IOS_BUNDLE_IDENTIFIER_PATTERN.test(personalTeamBundleIdentifier)) +) { + throw new Error( + "T3CODE_IOS_PERSONAL_TEAM_BUNDLE_ID must be a reverse-DNS identifier such as com.example.t3code when T3CODE_IOS_PERSONAL_TEAM=1.", + ); +} const VARIANT_CONFIG: Record< AppVariant, @@ -63,6 +77,36 @@ function resolveAppVariant(value: string | undefined): AppVariant { const variant = VARIANT_CONFIG[APP_VARIANT]; +const dmSansFonts = { + regular: "@expo-google-fonts/dm-sans/400Regular/DMSans_400Regular.ttf", + medium: "@expo-google-fonts/dm-sans/500Medium/DMSans_500Medium.ttf", + bold: "@expo-google-fonts/dm-sans/700Bold/DMSans_700Bold.ttf", +} as const; + +const widgetsPlugin: NonNullable[number] = [ + "expo-widgets", + { + bundleIdentifier: `${variant.iosBundleIdentifier}.widgets`, + groupIdentifier: `group.${variant.iosBundleIdentifier}`, + enablePushNotifications: true, + // Agent activity can update many times an hour; without the + // frequent-updates entitlement iOS throttles the update budget sooner. + frequentUpdates: true, + widgets: [ + { + name: "AgentActivity", + displayName: "Agent Activity", + description: "Shows the current state of active T3 Code agents.", + supportedFamilies: ["systemSmall", "systemMedium", "accessoryRectangular"], + }, + ], + }, +]; + +// These aliases match the fonts' PostScript names on iOS. Register the same +// names on Android so React Native and the native composer use one set of +// family names without waiting for runtime font loading. + const config: ExpoConfig = { name: variant.appName, slug: "t3-code", @@ -115,16 +159,59 @@ const config: ExpoConfig = { backgroundImage: "./assets/android-icon-background.png", monochromeImage: "./assets/android-icon-monochrome.png", }, - predictiveBackGestureEnabled: false, + // Opts into OnBackInvokedCallback-based back dispatch (Android 13+). + // JS back handling survives it via react-native's Android 16 shim plus + // withAndroidPredictiveBackCompat on Android 13-15. + predictiveBackGestureEnabled: true, }, web: { favicon: "./assets/favicon.png", }, plugins: [ - "expo-font", + "expo-asset", + [ + "expo-font", + { + ios: { + fonts: [dmSansFonts.regular, dmSansFonts.medium, dmSansFonts.bold], + }, + android: { + fonts: [ + { + fontFamily: "DMSans-Regular", + fontDefinitions: [{ path: dmSansFonts.regular, weight: 400 }], + }, + { + fontFamily: "DMSans-Medium", + fontDefinitions: [{ path: dmSansFonts.medium, weight: 500 }], + }, + { + fontFamily: "DMSans-Bold", + fontDefinitions: [{ path: dmSansFonts.bold, weight: 700 }], + }, + ], + }, + }, + ], "expo-secure-store", - ["@clerk/expo", { theme: "./clerk-theme.json" }], + "expo-sqlite", + // appleSignIn must be gated here: withoutIosPersonalTeamCapabilities.cjs runs before + // plugins earlier in this array, so it cannot strip the entitlement Clerk would add. + ["@clerk/expo", { theme: "./clerk-theme.json", appleSignIn: !isIosPersonalTeamBuild }], "expo-web-browser", + [ + "expo-quick-actions", + { + // Adaptive launcher-shortcut icon; referenced by resource name from + // the shortcut items set in src/features/shortcuts. + androidIcons: { + shortcut_icon: { + foregroundImage: "./assets/android-icon-foreground.png", + backgroundColor: "#E6F4FE", + }, + }, + }, + ], [ "expo-camera", { @@ -164,31 +251,19 @@ const config: ExpoConfig = { // expo-widgets' — its dangerous mod wipes ios/ExpoWidgetsTarget/ (which // would delete the asset catalog) and its xcodeproj mod creates the widget // target (which must exist before the compile phase can be attached). - "./plugins/withWidgetLogoAsset.cjs", - [ - "expo-widgets", - { - bundleIdentifier: `${variant.iosBundleIdentifier}.widgets`, - groupIdentifier: `group.${variant.iosBundleIdentifier}`, - enablePushNotifications: true, - // Agent activity can update many times an hour; without the - // frequent-updates entitlement iOS throttles the update budget sooner. - frequentUpdates: true, - widgets: [ - { - name: "AgentActivity", - displayName: "Agent Activity", - description: "Shows the current state of active T3 Code agents.", - supportedFamilies: ["systemSmall", "systemMedium", "accessoryRectangular"], - }, - ], - }, - ], + ...(!isIosPersonalTeamBuild ? ["./plugins/withWidgetLogoAsset.cjs", widgetsPlugin] : []), "./plugins/withIosSceneLifecycle.cjs", + "./plugins/withIosCrashLog.cjs", "./plugins/withAndroidCleartextTraffic.cjs", + "./plugins/withAndroidGradleHeap.cjs", + "./plugins/withAndroidModernPopupMenu.cjs", + "./plugins/withAndroidModernAlertDialog.cjs", + "./plugins/withAndroidPredictiveBackCompat.cjs", + ...(isIosPersonalTeamBuild ? ["./plugins/withoutIosPersonalTeamCapabilities.cjs"] : []), ], extra: { appVariant: APP_VARIANT, + iosPersonalTeamBuild: isIosPersonalTeamBuild, relay: { url: repoEnv.T3CODE_RELAY_URL ?? null, }, diff --git a/apps/mobile/eas.json b/apps/mobile/eas.json index 82c707eac12..a1d315b7e14 100644 --- a/apps/mobile/eas.json +++ b/apps/mobile/eas.json @@ -8,7 +8,8 @@ "development": { "corepack": true, "env": { - "APP_VARIANT": "development" + "APP_VARIANT": "development", + "NODE_OPTIONS": "--max-old-space-size=4096" }, "channel": "development", "developmentClient": true, @@ -17,7 +18,8 @@ "preview": { "corepack": true, "env": { - "APP_VARIANT": "preview" + "APP_VARIANT": "preview", + "NODE_OPTIONS": "--max-old-space-size=4096" }, "channel": "preview", "environment": "preview", @@ -27,7 +29,8 @@ "corepack": true, "env": { "APP_VARIANT": "preview", - "MOBILE_VERSION_POLICY": "fingerprint" + "MOBILE_VERSION_POLICY": "fingerprint", + "NODE_OPTIONS": "--max-old-space-size=4096" }, "channel": "preview", "environment": "preview", @@ -40,7 +43,8 @@ "production": { "corepack": true, "env": { - "APP_VARIANT": "production" + "APP_VARIANT": "production", + "NODE_OPTIONS": "--max-old-space-size=4096" }, "channel": "production", "environment": "production", diff --git a/apps/mobile/global.css b/apps/mobile/global.css index f76300ee524..6d36bf77caf 100644 --- a/apps/mobile/global.css +++ b/apps/mobile/global.css @@ -12,7 +12,7 @@ /* Card / surface */ --color-card: #ffffff; --color-card-alt: #f5f5f5; - --color-card-translucent: rgba(255, 255, 255, 0.94); + --color-card-translucent: rgba(255, 255, 255, 0.8); /* Text */ --color-foreground: #262626; @@ -51,6 +51,7 @@ /* Inputs */ --color-input: #ffffff; --color-input-border: rgba(0, 0, 0, 0.1); + --color-sidebar-search: rgba(118, 118, 128, 0.12); --color-placeholder: #a3a3a3; /* Icons */ @@ -105,7 +106,7 @@ /* Card / surface */ --color-card: #171717; --color-card-alt: #1c1c1c; - --color-card-translucent: rgba(17, 17, 17, 0.94); + --color-card-translucent: rgba(17, 17, 17, 0.8); /* Text */ --color-foreground: #f5f5f5; @@ -144,6 +145,7 @@ /* Inputs */ --color-input: #141414; --color-input-border: rgba(255, 255, 255, 0.08); + --color-sidebar-search: rgba(118, 118, 128, 0.24); --color-placeholder: #8e8e93; /* Icons */ @@ -194,9 +196,10 @@ /* ─── Typography ────────────────────────────────────────────────────── */ @theme { - --font-sans: "DMSans_400Regular"; - --font-medium: "DMSans_500Medium"; - --font-bold: "DMSans_700Bold"; + /* Keep these native family names aligned with app.config.ts. */ + --font-sans: "DMSans-Regular"; + --font-medium: "DMSans-Medium"; + --font-bold: "DMSans-Bold"; /* Keep this scale aligned with src/lib/typography.ts for native style props. */ --text-3xs: 11px; @@ -221,14 +224,14 @@ /* ─── Custom utilities ──────────────────────────────────────────────── */ @utility font-t3-medium { - font-family: "DMSans_500Medium"; + font-family: var(--font-medium); } @utility font-t3-bold { - font-family: "DMSans_700Bold"; + font-family: var(--font-bold); } @utility font-t3-extrabold { - font-family: "DMSans_700Bold"; + font-family: var(--font-bold); font-weight: 800; } diff --git a/apps/mobile/index.ts b/apps/mobile/index.ts index 979dc75d5f1..8add3abd386 100644 --- a/apps/mobile/index.ts +++ b/apps/mobile/index.ts @@ -1,3 +1,7 @@ +// Installed via a side-effect import listed first so the fatal-error handler +// is in place before the rest of the app module graph evaluates. +import "./src/lib/installCrashLog"; + import { registerRootComponent } from "expo"; import "react-native-gesture-handler"; import { featureFlags } from "react-native-screens"; diff --git a/apps/mobile/metro.config.js b/apps/mobile/metro.config.js index d314f6206c6..fe886077697 100644 --- a/apps/mobile/metro.config.js +++ b/apps/mobile/metro.config.js @@ -6,6 +6,7 @@ const { withUniwindConfig } = require("uniwind/metro"); /** @type {import("expo/metro-config").MetroConfig} */ const config = getDefaultConfig(__dirname); const workspaceRoot = path.resolve(__dirname, "../.."); +const escapedWorkspaceRoot = workspaceRoot.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const mobileShikiRoot = path.dirname(require.resolve("shiki/package.json", { paths: [__dirname] })); const resolveShikiDependencyRoot = (packageName) => { const entryPath = require.resolve(packageName, { paths: [mobileShikiRoot] }); @@ -25,6 +26,14 @@ const resolveShikiDependencyRoot = (packageName) => { config.watchFolders = [...new Set([...(config.watchFolders ?? []), workspaceRoot])]; config.resolver = { ...config.resolver, + blockList: [ + ...(Array.isArray(config.resolver?.blockList) + ? config.resolver.blockList + : config.resolver?.blockList + ? [config.resolver.blockList] + : []), + new RegExp(`${escapedWorkspaceRoot}[/\\\\]\\.t3[/\\\\].*`), + ], extraNodeModules: { // oxlint-disable-next-line unicorn/no-useless-fallback-in-spread ...(config.resolver?.extraNodeModules ?? {}), diff --git a/apps/mobile/modules/t3-composer-editor/android/build.gradle b/apps/mobile/modules/t3-composer-editor/android/build.gradle new file mode 100644 index 00000000000..dfdb4d16a14 --- /dev/null +++ b/apps/mobile/modules/t3-composer-editor/android/build.gradle @@ -0,0 +1,19 @@ +apply plugin: 'com.android.library' +apply plugin: 'org.jetbrains.kotlin.android' + +group = 'com.t3tools.composereditor' +version = '0.0.0' + +android { + namespace 'expo.modules.t3composereditor' + 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-composer-editor/android/src/main/AndroidManifest.xml b/apps/mobile/modules/t3-composer-editor/android/src/main/AndroidManifest.xml new file mode 100644 index 00000000000..94cbbcfc396 --- /dev/null +++ b/apps/mobile/modules/t3-composer-editor/android/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorModule.kt b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorModule.kt new file mode 100644 index 00000000000..e11181e81a9 --- /dev/null +++ b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorModule.kt @@ -0,0 +1,72 @@ +package expo.modules.t3composereditor + +import expo.modules.kotlin.modules.Module +import expo.modules.kotlin.modules.ModuleDefinition + +class T3ComposerEditorModule : Module() { + override fun definition() = ModuleDefinition { + Name("T3ComposerEditor") + + View(T3ComposerEditorView::class) { + Prop("controlledDocumentJson") { view: T3ComposerEditorView, documentJson: String -> + view.setControlledDocumentJson(documentJson) + } + Prop("themeJson") { view: T3ComposerEditorView, themeJson: String -> + view.setThemeJson(themeJson) + } + Prop("placeholder") { view: T3ComposerEditorView, placeholder: String -> + view.setPlaceholder(placeholder) + } + Prop("fontFamily") { view: T3ComposerEditorView, fontFamily: String -> + view.setFontFamily(fontFamily) + } + Prop("fontSize") { view: T3ComposerEditorView, fontSize: Double -> + view.setFontSize(fontSize.toFloat()) + } + Prop("lineHeight") { view: T3ComposerEditorView, lineHeight: Double -> + view.setLineHeight(lineHeight.toFloat()) + } + Prop("contentInsetVertical") { view: T3ComposerEditorView, contentInsetVertical: Double -> + view.setContentInsetVertical(contentInsetVertical.toInt()) + } + + Prop("singleLineCentered") { view: T3ComposerEditorView, singleLineCentered: Boolean -> + view.setSingleLineCentered(singleLineCentered) + } + Prop("editable") { view: T3ComposerEditorView, editable: Boolean -> + view.setEditable(editable) + } + Prop("scrollEnabled") { view: T3ComposerEditorView, scrollEnabled: Boolean -> + view.setScrollEnabled(scrollEnabled) + } + Prop("autoFocus") { view: T3ComposerEditorView, autoFocus: Boolean -> + view.setAutoFocus(autoFocus) + } + Prop("autoCorrect") { view: T3ComposerEditorView, autoCorrect: Boolean -> + view.setAutoCorrect(autoCorrect) + } + Prop("spellCheck") { view: T3ComposerEditorView, spellCheck: Boolean -> + view.setSpellCheck(spellCheck) + } + + Events( + "onComposerChange", + "onComposerSelectionChange", + "onComposerFocus", + "onComposerBlur", + "onComposerPasteImages", + "onComposerContentSizeChange", + ) + + AsyncFunction("focus") { view: T3ComposerEditorView -> + view.focusEditor() + } + AsyncFunction("blur") { view: T3ComposerEditorView -> + view.blurEditor() + } + AsyncFunction("setSelection") { view: T3ComposerEditorView, start: Int, end: Int -> + view.setSelection(start, end) + } + } + } +} diff --git a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt new file mode 100644 index 00000000000..e13c0a52189 --- /dev/null +++ b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt @@ -0,0 +1,480 @@ +package expo.modules.t3composereditor + +import android.content.Context +import android.content.ClipboardManager +import android.graphics.Color +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.RectF +import android.graphics.Typeface +import android.text.Editable +import android.text.InputType +import android.text.Spanned +import android.text.TextWatcher +import android.text.style.ReplacementSpan +import android.view.Gravity +import android.view.ViewGroup +import android.view.inputmethod.InputMethodManager +import android.widget.EditText +import expo.modules.kotlin.AppContext +import expo.modules.kotlin.viewevent.EventDispatcher +import expo.modules.kotlin.views.ExpoView +import org.json.JSONObject +import kotlin.math.max + +class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( + context, + appContext +) { + private val editor = SelectionAwareEditText(context) + private val onComposerChange by EventDispatcher() + private val onComposerSelectionChange by EventDispatcher() + private val onComposerFocus by EventDispatcher() + private val onComposerBlur by EventDispatcher() + private val onComposerPasteImages by EventDispatcher() + private val onComposerContentSizeChange by EventDispatcher() + private var applyingNativeValue = false + private var desiredLineHeightPx = 0 + private var lastContentHeight = 0 + private var contentInsetVertical = 0 + private var tokensJson = "[]" + private var tokens: List = emptyList() + private var chipTheme = ComposerChipTheme.default() + private var autoCorrect = true + private var spellCheck = true + private var nativeEventCount = 0 + + init { + editor.setBackgroundColor(Color.TRANSPARENT) + editor.gravity = Gravity.TOP or Gravity.START + editor.includeFontPadding = false + editor.isSingleLine = false + editor.minLines = 1 + editor.inputType = + InputType.TYPE_CLASS_TEXT or + InputType.TYPE_TEXT_FLAG_MULTI_LINE or + InputType.TYPE_TEXT_FLAG_CAP_SENTENCES + editor.setTextColor(Color.BLACK) + editor.setHintTextColor(Color.GRAY) + editor.setPadding(0, 0, 0, 0) + editor.selectionListener = { start, end -> + if (!applyingNativeValue) { + emitSelectionChange(start, end) + } + } + editor.pasteImagesListener = { uris -> + onComposerPasteImages(mapOf("uris" to uris)) + } + editor.setOnFocusChangeListener { _, hasFocus -> + if (hasFocus) { + onComposerFocus(emptyMap()) + } else { + onComposerBlur(emptyMap()) + } + } + editor.addTextChangedListener( + object : TextWatcher { + override fun beforeTextChanged( + text: CharSequence?, + start: Int, + count: Int, + after: Int + ) = Unit + override fun onTextChanged(text: CharSequence?, start: Int, before: Int, count: Int) = Unit + + override fun afterTextChanged(editable: Editable?) { + if (applyingNativeValue) return + val nextValue = editable.toString() + val selection = currentSelectionPayload() + nativeEventCount += 1 + onComposerChange( + mapOf( + "value" to nextValue, + "selection" to selection, + "eventCount" to nativeEventCount, + ), + ) + emitContentSizeIfNeeded() + } + }, + ) + editor.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> emitContentSizeIfNeeded() } + addView( + editor, + LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT), + ) + } + + @Suppress("ReturnCount") + fun setControlledDocumentJson(documentJson: String) { + val document = try { + JSONObject(documentJson) + } catch (_: Exception) { + return + } + val mostRecentEventCount = document.optInt("mostRecentEventCount", -1) + if (mostRecentEventCount < nativeEventCount) return + + val value = document.optString("value") + if (document.optBoolean("isNativeEcho") && editor.text.toString() != value) return + + val nextTokensJson = document.optString("tokensJson", "[]") + val nextTokens = if (nextTokensJson == tokensJson) tokens else parseTokens(nextTokensJson) + val requestedSelection = document.optJSONObject("selection") + val previousSelectionStart = editor.selectionStart.coerceAtLeast(0) + val previousSelectionEnd = editor.selectionEnd.coerceAtLeast(0) + val valueChanged = editor.text.toString() != value + + applyingNativeValue = true + try { + if (valueChanged) { + editor.setText(value) + } + tokensJson = nextTokensJson + tokens = nextTokens + applyTokenSpans() + if (requestedSelection != null) { + applySelection( + requestedSelection.optInt("start", previousSelectionStart), + requestedSelection.optInt("end", previousSelectionEnd), + ) + } else if (valueChanged) { + applySelection(previousSelectionStart, previousSelectionEnd) + } + } finally { + applyingNativeValue = false + } + emitContentSizeIfNeeded() + } + + fun setThemeJson(themeJson: String) { + try { + val theme = JSONObject(themeJson) + editor.setTextColor(parseColor(theme.optString("text"), Color.BLACK)) + editor.setHintTextColor(parseColor(theme.optString("placeholder"), Color.GRAY)) + chipTheme = ComposerChipTheme( + chipBackground = parseColor(theme.optString("chipBackground"), chipTheme.chipBackground), + chipBorder = parseColor(theme.optString("chipBorder"), chipTheme.chipBorder), + chipText = parseColor(theme.optString("chipText"), chipTheme.chipText), + skillBackground = parseColor( + theme.optString("skillBackground"), + chipTheme.skillBackground, + ), + skillBorder = parseColor(theme.optString("skillBorder"), chipTheme.skillBorder), + skillText = parseColor(theme.optString("skillText"), chipTheme.skillText), + ) + applyTokenSpans() + } catch (_: Exception) { + } + } + + fun setPlaceholder(placeholder: String) { + editor.hint = placeholder + } + + fun setFontFamily(fontFamily: String) { + editor.typeface = if (fontFamily.contains("Mono", ignoreCase = true)) { + Typeface.MONOSPACE + } else { + Typeface.DEFAULT + } + } + + fun setFontSize(fontSize: Float) { + editor.textSize = fontSize + applyLineHeight() + } + + fun setLineHeight(lineHeight: Float) { + desiredLineHeightPx = (lineHeight * resources.displayMetrics.density).toInt() + applyLineHeight() + } + + fun setSingleLineCentered(centered: Boolean) { + editor.gravity = if (centered) { + Gravity.CENTER_VERTICAL or Gravity.START + } else { + Gravity.TOP or Gravity.START + } + } + + fun setContentInsetVertical(contentInsetVertical: Int) { + this.contentInsetVertical = + max(0, (contentInsetVertical * resources.displayMetrics.density).toInt()) + editor.setPadding(0, this.contentInsetVertical, 0, this.contentInsetVertical) + emitContentSizeIfNeeded() + } + + fun setEditable(editable: Boolean) { + editor.isEnabled = editable + editor.isFocusable = editable + editor.isFocusableInTouchMode = editable + editor.isCursorVisible = editable + } + + fun setScrollEnabled(scrollEnabled: Boolean) { + editor.isVerticalScrollBarEnabled = scrollEnabled + } + + fun setAutoFocus(autoFocus: Boolean) { + if (autoFocus) { + post { focusEditor() } + } + } + + fun setAutoCorrect(autoCorrect: Boolean) { + this.autoCorrect = autoCorrect + updateInputFlags() + } + + fun setSpellCheck(spellCheck: Boolean) { + this.spellCheck = spellCheck + updateInputFlags() + } + + fun focusEditor() { + editor.requestFocus() + val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager + imm?.showSoftInput(editor, InputMethodManager.SHOW_IMPLICIT) + } + + fun blurEditor() { + editor.clearFocus() + val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager + imm?.hideSoftInputFromWindow(editor.windowToken, 0) + } + + fun setSelection(start: Int, end: Int) { + applySelection(start, end) + } + + private fun applySelection(start: Int, end: Int) { + val textLength = editor.text?.length ?: 0 + val safeStart = start.coerceIn(0, textLength) + val safeEnd = end.coerceIn(0, textLength) + editor.setSelection(safeStart, safeEnd) + } + + private fun updateInputFlags() { + var flags = + InputType.TYPE_CLASS_TEXT or + InputType.TYPE_TEXT_FLAG_MULTI_LINE or + InputType.TYPE_TEXT_FLAG_CAP_SENTENCES + flags = if (autoCorrect && spellCheck) { + flags or InputType.TYPE_TEXT_FLAG_AUTO_CORRECT + } else { + flags or InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS + } + editor.inputType = flags + } + + private fun applyLineHeight() { + if (desiredLineHeightPx <= 0) return + val fontHeight = editor.paint.fontMetricsInt.descent - editor.paint.fontMetricsInt.ascent + editor.setLineSpacing(max(0, desiredLineHeightPx - fontHeight).toFloat(), 1f) + } + + private fun currentSelectionPayload(): Map = + mapOf( + "start" to editor.selectionStart.coerceAtLeast(0), + "end" to editor.selectionEnd.coerceAtLeast(0), + ) + + private fun emitSelectionChange(start: Int, end: Int) { + onComposerSelectionChange( + mapOf( + "value" to editor.text.toString(), + "selection" to mapOf("start" to start, "end" to end), + "eventCount" to nativeEventCount, + ), + ) + } + + private fun emitContentSizeIfNeeded() { + val height = editor.layout?.height ?: editor.measuredHeight + val contentHeight = height + contentInsetVertical * 2 + if (contentHeight == lastContentHeight) return + lastContentHeight = contentHeight + onComposerContentSizeChange( + mapOf("height" to contentHeight / resources.displayMetrics.density), + ) + } + + private fun applyTokenSpans() { + val editable = editor.text ?: return + editable.getSpans( + 0, + editable.length, + ComposerChipSpan::class.java + ).forEach(editable::removeSpan) + tokens.forEach { token -> + if (token.start < 0 || token.end <= token.start || token.end > editable.length) return@forEach + val expectedSource = editable.substring(token.start, token.end) + if (expectedSource != token.source) return@forEach + editable.setSpan( + ComposerChipSpan( + token.label, + token.type == "skill", + chipTheme, + resources.displayMetrics.density + ), + token.start, + token.end, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE, + ) + } + editor.invalidate() + } + + private fun parseColor(value: String, fallback: Int): Int = + try { + Color.parseColor(value) + } catch (_: Exception) { + fallback + } +} + +private data class ComposerToken( + val type: String, + val source: String, + val label: String, + val start: Int, + val end: Int +) + +private data class ComposerChipTheme( + val chipBackground: Int, + val chipBorder: Int, + val chipText: Int, + val skillBackground: Int, + val skillBorder: Int, + val skillText: Int +) { + companion object { + fun default() = ComposerChipTheme( + chipBackground = Color.rgb(238, 240, 243), + chipBorder = Color.rgb(210, 214, 220), + chipText = Color.rgb(35, 39, 45), + skillBackground = Color.rgb(233, 239, 255), + skillBorder = Color.rgb(185, 200, 245), + skillText = Color.rgb(45, 72, 155), + ) + } +} + +private class ComposerChipSpan( + private val label: String, + private val skill: Boolean, + private val theme: ComposerChipTheme, + density: Float +) : ReplacementSpan() { + private val horizontalPadding = 7f * density + private val verticalPadding = 2f * density + private val cornerRadius = 6f * density + private val borderWidth = density + + override fun getSize( + paint: Paint, + text: CharSequence, + start: Int, + end: Int, + fontMetrics: Paint.FontMetricsInt? + ): Int { + fontMetrics?.let { + val extra = verticalPadding.toInt() + val base = paint.fontMetricsInt + it.top = base.top - extra + it.ascent = base.ascent - extra + it.descent = base.descent + extra + it.bottom = base.bottom + extra + } + return (paint.measureText(label) + horizontalPadding * 2).toInt() + } + + override fun draw( + canvas: Canvas, + text: CharSequence, + start: Int, + end: Int, + x: Float, + top: Int, + y: Int, + bottom: Int, + paint: Paint + ) { + val width = paint.measureText(label) + horizontalPadding * 2 + val metrics = paint.fontMetrics + val rect = RectF( + x, + y + metrics.ascent - verticalPadding, + x + width, + y + metrics.descent + verticalPadding, + ) + val originalColor = paint.color + val originalStyle = paint.style + val originalStrokeWidth = paint.strokeWidth + + paint.color = if (skill) theme.skillBackground else theme.chipBackground + paint.style = Paint.Style.FILL + canvas.drawRoundRect(rect, cornerRadius, cornerRadius, paint) + paint.color = if (skill) theme.skillBorder else theme.chipBorder + paint.style = Paint.Style.STROKE + paint.strokeWidth = borderWidth + canvas.drawRoundRect(rect, cornerRadius, cornerRadius, paint) + paint.color = if (skill) theme.skillText else theme.chipText + paint.style = Paint.Style.FILL + canvas.drawText(label, x + horizontalPadding, y.toFloat(), paint) + + paint.color = originalColor + paint.style = originalStyle + paint.strokeWidth = originalStrokeWidth + } +} + +private fun parseTokens(value: String): List = try { + val array = org.json.JSONArray(value) + List(array.length()) { index -> + val token = array.getJSONObject(index) + ComposerToken( + type = token.optString("type"), + source = token.optString("source"), + label = token.optString("label"), + start = token.optInt("start"), + end = token.optInt("end"), + ) + } +} catch (_: Exception) { + emptyList() +} + +private class SelectionAwareEditText(context: Context) : EditText(context) { + var selectionListener: ((Int, Int) -> Unit)? = null + var pasteImagesListener: ((List) -> Unit)? = null + + override fun onSelectionChanged(selStart: Int, selEnd: Int) { + super.onSelectionChanged(selStart, selEnd) + selectionListener?.invoke(selStart, selEnd) + } + + override fun onTextContextMenuItem(id: Int): Boolean { + if (id == android.R.id.paste || id == android.R.id.pasteAsPlainText) { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager + val clip = clipboard?.primaryClip + val imageUris = buildList { + if (clip != null) { + for (index in 0 until clip.itemCount) { + clip.getItemAt(index).uri?.let { uri -> + val mimeType = context.contentResolver.getType(uri) + if (mimeType?.startsWith("image/") == true) add(uri.toString()) + } + } + } + } + if (imageUris.isNotEmpty()) { + pasteImagesListener?.invoke(imageUris) + return true + } + } + return super.onTextContextMenuItem(id) + } +} diff --git a/apps/mobile/modules/t3-composer-editor/expo-module.config.json b/apps/mobile/modules/t3-composer-editor/expo-module.config.json index 0d6384cd91a..56fd899920c 100644 --- a/apps/mobile/modules/t3-composer-editor/expo-module.config.json +++ b/apps/mobile/modules/t3-composer-editor/expo-module.config.json @@ -1,6 +1,9 @@ { - "platforms": ["apple"], + "platforms": ["apple", "android"], "apple": { "modules": ["T3ComposerEditorModule"] + }, + "android": { + "modules": ["expo.modules.t3composereditor.T3ComposerEditorModule"] } } diff --git a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift index 8515abfbae8..180da203008 100644 --- a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift +++ b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift @@ -300,7 +300,7 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate { skillText: "#a21caf", fileTint: "#737373" ) - private var fontFamily = "DMSans_400Regular" + private var fontFamily = "DMSans-Regular" private var fontSize: CGFloat = 14 private var lineHeight: CGFloat = 20 private var contentInsetVertical: CGFloat = 0 @@ -608,7 +608,7 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate { iconImage: UIImage?, style: ComposerChipStyle ) -> UIImage { - let font = UIFont(name: "DMSans_500Medium", size: max(12, fontSize - 2)) + let font = UIFont(name: "DMSans-Medium", size: max(12, fontSize - 2)) ?? UIFont.systemFont(ofSize: max(12, fontSize - 2), weight: .medium) let fallbackIcon = UIImage( systemName: iconName, diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.h b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.h index 99417490a63..737330db1d4 100644 --- a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.h +++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.h @@ -20,12 +20,16 @@ struct T3MarkdownTextParagraphStyleRange { Float firstLineHeadIndent; Float headIndent; Float paragraphSpacing; + + bool operator==(const T3MarkdownTextParagraphStyleRange&) const = default; }; struct T3MarkdownTextAttachmentRange { size_t location; size_t length; std::string imageUri; + + bool operator==(const T3MarkdownTextAttachmentRange&) const = default; }; inline Float T3MarkdownTextAttachmentSize(const T3MarkdownTextAttachmentRange &) { @@ -52,11 +56,6 @@ T3MarkdownTextStateReal> { public: using ConcreteViewShadowNode::ConcreteViewShadowNode; - T3MarkdownTextShadowNode( - const ShadowNode& sourceShadowNode, - const ShadowNodeFragment& fragment - ); - static ShadowNodeTraits BaseTraits() { auto traits = ConcreteViewShadowNode::BaseTraits(); traits.set(ShadowNodeTraits::Trait::LeafYogaNode); @@ -71,8 +70,18 @@ T3MarkdownTextStateReal> { const LayoutConstraints& layoutConstraints) const override; private: - mutable AttributedString _attributedString; - mutable std::vector _paragraphStyleRanges; - mutable std::vector _attachmentRanges; + // Content must be derived from the current children whenever it is needed. + // Yoga can invoke layout() on a fresh clone without ever calling + // measureContent() on it (for example when both dimensions are already + // exact), so caching measure-time content in mutable members and publishing + // it from layout() lets state fall behind the children and drop text. + struct Content { + AttributedString attributedString; + std::vector paragraphStyleRanges; + std::vector attachmentRanges; + }; + + Content buildContent(const LayoutContext& layoutContext) const; + void updateStateIfNeeded(Content&& content); }; } // namespace facebook::React diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.mm b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.mm index b9abe452fb9..7a02094a4d3 100644 --- a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.mm +++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.mm @@ -72,15 +72,8 @@ static void applyAttachments( } } -T3MarkdownTextShadowNode::T3MarkdownTextShadowNode( - const ShadowNode& sourceShadowNode, - const ShadowNodeFragment& fragment -) : ConcreteViewShadowNode(sourceShadowNode, fragment) { -}; - -Size T3MarkdownTextShadowNode::measureContent( - const LayoutContext& layoutContext, - const LayoutConstraints& layoutConstraints) const { +T3MarkdownTextShadowNode::Content T3MarkdownTextShadowNode::buildContent( + const LayoutContext& layoutContext) const { const auto &baseProps = getConcreteProps(); auto baseTextAttributes = TextAttributes::defaultTextAttributes(); @@ -207,14 +200,23 @@ static void applyAttachments( } } - _attributedString = baseAttributedString; - _paragraphStyleRanges = paragraphStyleRanges; - _attachmentRanges = attachmentRanges; + return Content{ + std::move(baseAttributedString), + std::move(paragraphStyleRanges), + std::move(attachmentRanges), + }; +} + +Size T3MarkdownTextShadowNode::measureContent( + const LayoutContext& layoutContext, + const LayoutConstraints& layoutConstraints) const { + const auto &baseProps = getConcreteProps(); + const auto content = buildContent(layoutContext); NSMutableAttributedString *convertedAttributedString = - [RCTNSAttributedStringFromAttributedString(baseAttributedString) mutableCopy]; - applyParagraphStyles(convertedAttributedString, paragraphStyleRanges); - applyAttachments(convertedAttributedString, attachmentRanges); + [RCTNSAttributedStringFromAttributedString(content.attributedString) mutableCopy]; + applyParagraphStyles(convertedAttributedString, content.paragraphStyleRanges); + applyAttachments(convertedAttributedString, content.attachmentRanges); const CGFloat maximumWidth = std::isfinite(layoutConstraints.maximumSize.width) ? layoutConstraints.maximumSize.width @@ -255,10 +257,21 @@ static void applyAttachments( void T3MarkdownTextShadowNode::layout(LayoutContext layoutContext) { ensureUnsealed(); + updateStateIfNeeded(buildContent(layoutContext)); +} + +void T3MarkdownTextShadowNode::updateStateIfNeeded(Content&& content) { + const auto &stateData = getStateData(); + if (stateData.attributedString == content.attributedString && + stateData.paragraphStyleRanges == content.paragraphStyleRanges && + stateData.attachmentRanges == content.attachmentRanges) { + return; + } + setStateData(T3MarkdownTextStateReal{ - _attributedString, - _paragraphStyleRanges, - _attachmentRanges, + std::move(content.attributedString), + std::move(content.paragraphStyleRanges), + std::move(content.attachmentRanges), }); } } diff --git a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx index 56321ba01ad..5c8d5bc97c2 100644 --- a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx +++ b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx @@ -34,6 +34,7 @@ export function SelectableMarkdownText({ skills = EMPTY_SKILLS, textStyle, highlightCode, + fillWidth = false, preserveSoftBreaks = false, onLinkPress, marginTop = 0, @@ -63,7 +64,15 @@ export function SelectableMarkdownText({ // shrink-to-fit containers such as user-message bubbles. Yoga then gives // the native text node an unbounded second pass and the parent only clips // the resulting single-line width instead of reflowing it. - + {chunks.map((chunk, index) => { const content = chunk.kind === "rich" ? ( diff --git a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts index 42cc3cd6fb6..c1287a3565f 100644 --- a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts +++ b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts @@ -40,6 +40,7 @@ export interface SelectableMarkdownTextProps { readonly markdown: string; readonly textStyle: NativeMarkdownTextStyle; readonly highlightCode: MarkdownCodeHighlighter; + readonly fillWidth?: boolean; readonly skills?: ReadonlyArray; readonly preserveSoftBreaks?: boolean; readonly onLinkPress?: (href: string) => void; diff --git a/apps/mobile/modules/t3-native-controls/android/build.gradle b/apps/mobile/modules/t3-native-controls/android/build.gradle new file mode 100644 index 00000000000..ba0622b7f0e --- /dev/null +++ b/apps/mobile/modules/t3-native-controls/android/build.gradle @@ -0,0 +1,19 @@ +apply plugin: 'com.android.library' +apply plugin: 'org.jetbrains.kotlin.android' + +group = 'com.t3tools.nativecontrols' +version = '0.0.0' + +android { + namespace 'expo.modules.t3nativecontrols' + 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-native-controls/android/src/main/AndroidManifest.xml b/apps/mobile/modules/t3-native-controls/android/src/main/AndroidManifest.xml new file mode 100644 index 00000000000..94cbbcfc396 --- /dev/null +++ b/apps/mobile/modules/t3-native-controls/android/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3HeaderButtonView.kt b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3HeaderButtonView.kt new file mode 100644 index 00000000000..47db92d92a4 --- /dev/null +++ b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3HeaderButtonView.kt @@ -0,0 +1,97 @@ +package expo.modules.t3nativecontrols + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.view.View +import expo.modules.kotlin.AppContext +import expo.modules.kotlin.viewevent.EventDispatcher +import expo.modules.kotlin.views.ExpoView + +class T3HeaderButtonView(context: Context, appContext: AppContext) : ExpoView(context, appContext) { + private val iconView = HeaderIconView(context) + private val onTriggered by EventDispatcher() + + init { + isClickable = true + isFocusable = true + setOnClickListener { + onTriggered(emptyMap()) + } + addView(iconView, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)) + } + + fun setLabel(label: String) { + contentDescription = label + } + + fun setSystemImage(systemImage: String) { + iconView.systemImage = systemImage + } +} + +private class HeaderIconView(context: Context) : View(context) { + private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.parseColor("#6B7280") + strokeCap = Paint.Cap.ROUND + strokeJoin = Paint.Join.ROUND + strokeWidth = 3f * resources.displayMetrics.density + style = Paint.Style.STROKE + } + + var systemImage: String = "gearshape" + set(value) { + field = value + invalidate() + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + val cx = width / 2f + val cy = height / 2f + val size = minOf(width, height).toFloat() + if (systemImage == "square.and.pencil") { + drawNewTask(canvas, cx, cy, size) + } else { + drawSettings(canvas, cx, cy, size) + } + } + + private fun drawSettings(canvas: Canvas, cx: Float, cy: Float, size: Float) { + val radius = size * 0.12f + canvas.drawCircle(cx, cy, radius, paint) + for (index in 0 until 8) { + val angle = Math.PI * index / 4.0 + val inner = size * 0.19f + val outer = size * 0.27f + val sx = cx + kotlin.math.cos(angle).toFloat() * inner + val sy = cy + kotlin.math.sin(angle).toFloat() * inner + val ex = cx + kotlin.math.cos(angle).toFloat() * outer + val ey = cy + kotlin.math.sin(angle).toFloat() * outer + canvas.drawLine(sx, sy, ex, ey, paint) + } + } + + private fun drawNewTask(canvas: Canvas, cx: Float, cy: Float, size: Float) { + val left = cx - size * 0.2f + val top = cy - size * 0.16f + val right = cx + size * 0.14f + val bottom = cy + size * 0.2f + canvas.drawRoundRect(left, top, right, bottom, size * 0.04f, size * 0.04f, paint) + canvas.drawLine( + cx - size * 0.02f, + cy + size * 0.13f, + cx + size * 0.24f, + cy - size * 0.13f, + paint + ) + canvas.drawLine( + cx + size * 0.17f, + cy - size * 0.2f, + cx + size * 0.24f, + cy - size * 0.13f, + paint + ) + } +} diff --git a/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3NativeControlsModule.kt b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3NativeControlsModule.kt new file mode 100644 index 00000000000..4d5f02aaa9c --- /dev/null +++ b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3NativeControlsModule.kt @@ -0,0 +1,21 @@ +package expo.modules.t3nativecontrols + +import expo.modules.kotlin.modules.Module +import expo.modules.kotlin.modules.ModuleDefinition + +class T3NativeControlsModule : Module() { + override fun definition() = ModuleDefinition { + Name("T3NativeControls") + + View(T3HeaderButtonView::class) { + Prop("label") { view: T3HeaderButtonView, label: String -> + view.setLabel(label) + } + Prop("systemImage") { view: T3HeaderButtonView, systemImage: String -> + view.setSystemImage(systemImage) + } + + Events("onTriggered") + } + } +} diff --git a/apps/mobile/modules/t3-native-controls/expo-module.config.json b/apps/mobile/modules/t3-native-controls/expo-module.config.json index b53b3c50a03..d9a77f14e25 100644 --- a/apps/mobile/modules/t3-native-controls/expo-module.config.json +++ b/apps/mobile/modules/t3-native-controls/expo-module.config.json @@ -1,6 +1,9 @@ { - "platforms": ["apple"], + "platforms": ["apple", "android"], "apple": { "modules": ["T3NativeControlsModule", "T3KeyboardCommandsModule"] + }, + "android": { + "modules": ["expo.modules.t3nativecontrols.T3NativeControlsModule"] } } diff --git a/apps/mobile/modules/t3-native-controls/ios/T3CrashLog.swift b/apps/mobile/modules/t3-native-controls/ios/T3CrashLog.swift new file mode 100644 index 00000000000..aa6eca69707 --- /dev/null +++ b/apps/mobile/modules/t3-native-controls/ios/T3CrashLog.swift @@ -0,0 +1,163 @@ +import Foundation + +/// Durable crash persistence for Documents/crash-logs. +/// +/// The Expo module uses this for JS `writeSyncText`. AppDelegate installs +/// `RCTSetFatalHandler` and calls `persistNativeFatal` so reportFatal still +/// leaves a last-crash.json when the JS ErrorUtils path never flushes. +public enum T3CrashLog { + public static let directoryName = "crash-logs" + public static let lastCrashFileName = "last-crash.json" + + private static let isoFormatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }() + + /// Shared fsync write used by the Expo module and native fatal hooks. + @discardableResult + public static func writeSyncText(relativePath: String, contents: String) -> Bool { + do { + let docs = try FileManager.default.url( + for: .documentDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) + let url = docs.appendingPathComponent(relativePath) + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + let data = Data(contents.utf8) + // Non-atomic: under abort we prefer a partial file over losing the rename. + try data.write(to: url, options: []) + let handle = try FileHandle(forWritingTo: url) + defer { try? handle.close() } + if #available(iOS 13.0, *) { + try handle.synchronize() + } + return true + } catch { + return false + } + } + + /// Persist a native RCTFatal / RCTFatalException payload. + public static func persistNativeFatal( + message: String, + name: String, + stack: String?, + extra: String?, + source: String + ) { + let truncatedMessage = truncate(message, max: 8_000) + let truncatedStack = stack.map { truncate($0, max: 48_000) } + let capturedAt = isoFormatter.string(from: Date()) + let millis = Int(Date().timeIntervalSince1970 * 1000) + + var record: [String: Any] = [ + "breadcrumbs": [] as [Any], + "capturedAt": capturedAt, + "handlerInvocation": 0, + "isFatal": true, + "message": truncatedMessage, + "name": name, + "source": source, + ] + if let truncatedStack, !truncatedStack.isEmpty { + record["stack"] = truncatedStack + } + if let extra, !extra.isEmpty { + record["extraData"] = truncate(extra, max: 8_000) + } + + guard let data = try? JSONSerialization.data(withJSONObject: record, options: []), + let contents = String(data: data, encoding: .utf8) + else { + let escaped = truncatedMessage + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + .replacingOccurrences(of: "\n", with: "\\n") + let fallback = + "{\"capturedAt\":\"\(capturedAt)\",\"isFatal\":true,\"message\":\"\(escaped)\",\"name\":\"\(name)\",\"source\":\"\(source)\",\"handlerInvocation\":0,\"breadcrumbs\":[]}" + _ = writeSyncText(relativePath: "\(directoryName)/\(lastCrashFileName)", contents: fallback) + _ = writeSyncText( + relativePath: "\(directoryName)/crash-native-\(millis)-0.json", + contents: fallback + ) + return + } + + _ = writeSyncText(relativePath: "\(directoryName)/\(lastCrashFileName)", contents: contents) + _ = writeSyncText( + relativePath: "\(directoryName)/crash-native-\(millis)-0.json", + contents: contents + ) + } + + public static func formatJSStack(_ value: Any?) -> String? { + guard let value else { + return nil + } + if let text = value as? String { + return text + } + if let frames = value as? [[String: Any]] { + let lines = frames.prefix(80).map { frame -> String in + let method = frame["methodName"] as? String ?? "?" + let file = frame["file"] as? String ?? "?" + let line = stringifyFrameNumber(frame["lineNumber"]) + let column = stringifyFrameNumber(frame["column"]) + return " at \(method) (\(file):\(line):\(column))" + } + return lines.joined(separator: "\n") + } + return String(describing: value) + } + + public static func formatExceptionStack(_ exception: NSException) -> String { + let addresses = exception.callStackSymbols + if addresses.isEmpty { + return exception.callStackReturnAddresses.map { String(describing: $0) }.joined(separator: "\n") + } + return addresses.prefix(80).joined(separator: "\n") + } + + public static func stringValue(_ value: Any?) -> String? { + guard let value, !(value is NSNull) else { + return nil + } + if let text = value as? String { + return text + } + if JSONSerialization.isValidJSONObject(value), + let data = try? JSONSerialization.data(withJSONObject: value, options: []), + let text = String(data: data, encoding: .utf8) { + return text + } + return String(describing: value) + } + + private static func truncate(_ text: String, max: Int) -> String { + guard text.count > max else { + return text + } + let end = text.index(text.startIndex, offsetBy: max) + return String(text[.. String { + if let number = value as? NSNumber { + return number.stringValue + } + if let intValue = value as? Int { + return String(intValue) + } + if let text = value as? String { + return text + } + return "?" + } +} diff --git a/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift index 33e1dc9086c..f39a3b82fc5 100644 --- a/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift +++ b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift @@ -1,9 +1,21 @@ import ExpoModulesCore +import Foundation public final class T3NativeControlsModule: Module { public func definition() -> ModuleDefinition { Name("T3NativeControls") + // Durable last-chance write for fatal JS records. Expo FileSystem write can + // lose the race with RCTFatal abort; this fsyncs to Documents. + Function("writeSyncText") { (relativePath: String, contents: String) -> Bool in + T3CrashLog.writeSyncText(relativePath: relativePath, contents: contents) + } + + // AppDelegate installs the native RCTFatal hooks; this is a no-op ack for JS. + Function("installFatalHandler") { () -> Bool in + true + } + View(T3HeaderButtonView.self) { Prop("label") { (view: T3HeaderButtonView, label: String) in view.setLabel(label) diff --git a/apps/mobile/modules/t3-review-diff/android/build.gradle b/apps/mobile/modules/t3-review-diff/android/build.gradle new file mode 100644 index 00000000000..22bb070b3b8 --- /dev/null +++ b/apps/mobile/modules/t3-review-diff/android/build.gradle @@ -0,0 +1,19 @@ +apply plugin: 'com.android.library' +apply plugin: 'org.jetbrains.kotlin.android' + +group = 'com.t3tools.reviewdiff' +version = '0.0.0' + +android { + namespace 'expo.modules.t3reviewdiff' + 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-review-diff/android/src/main/AndroidManifest.xml b/apps/mobile/modules/t3-review-diff/android/src/main/AndroidManifest.xml new file mode 100644 index 00000000000..94cbbcfc396 --- /dev/null +++ b/apps/mobile/modules/t3-review-diff/android/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffModule.kt b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffModule.kt new file mode 100644 index 00000000000..ee40275ede8 --- /dev/null +++ b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffModule.kt @@ -0,0 +1,78 @@ +package expo.modules.t3reviewdiff + +import expo.modules.kotlin.modules.Module +import expo.modules.kotlin.modules.ModuleDefinition + +class T3ReviewDiffModule : Module() { + override fun definition() = ModuleDefinition { + Name("T3ReviewDiffSurface") + + View(T3ReviewDiffView::class) { + Prop("tokensResetKey") { view: T3ReviewDiffView, tokensResetKey: String -> + view.setTokensResetKey(tokensResetKey) + } + Prop("contentResetKey") { view: T3ReviewDiffView, contentResetKey: String -> + view.setContentResetKey(contentResetKey) + } + Prop("collapsedFileIdsJson") { view: T3ReviewDiffView, collapsedFileIdsJson: String -> + view.setCollapsedFileIdsJson(collapsedFileIdsJson) + } + Prop("viewedFileIdsJson") { view: T3ReviewDiffView, viewedFileIdsJson: String -> + view.setViewedFileIdsJson(viewedFileIdsJson) + } + Prop("selectedRowIdsJson") { view: T3ReviewDiffView, selectedRowIdsJson: String -> + view.setSelectedRowIdsJson(selectedRowIdsJson) + } + Prop("collapsedCommentIdsJson") { view: T3ReviewDiffView, collapsedCommentIdsJson: String -> + view.setCollapsedCommentIdsJson(collapsedCommentIdsJson) + } + Prop("appearanceScheme") { view: T3ReviewDiffView, appearanceScheme: String -> + view.setAppearanceScheme(appearanceScheme) + } + Prop("themeJson") { view: T3ReviewDiffView, themeJson: String -> + view.setThemeJson(themeJson) + } + Prop("styleJson") { view: T3ReviewDiffView, styleJson: String -> + view.setStyleJson(styleJson) + } + Prop("rowHeight") { view: T3ReviewDiffView, rowHeight: Double -> + view.setRowHeight(rowHeight.toFloat()) + } + Prop("contentWidth") { view: T3ReviewDiffView, contentWidth: Double -> + view.setContentWidth(contentWidth.toFloat()) + } + Prop("initialRowIndex") { view: T3ReviewDiffView, initialRowIndex: Double -> + view.setInitialRowIndex(initialRowIndex) + } + + Events( + "onDebug", + "onVisibleFileChange", + "onToggleFile", + "onToggleViewedFile", + "onPressLine", + "onToggleComment", + ) + + AsyncFunction("scrollToFile") { view: T3ReviewDiffView, fileId: String, animated: Boolean -> + view.scrollToFile(fileId, animated) + } + AsyncFunction("scrollToTop") { view: T3ReviewDiffView, animated: Boolean -> + view.scrollToTop(animated) + } + AsyncFunction("setRowsJson") { view: T3ReviewDiffView, rowsJson: String -> + view.setRowsJson(rowsJson) + } + AsyncFunction("setTokensJson") { view: T3ReviewDiffView, tokensJson: String -> + view.setTokensJson(tokensJson) + } + AsyncFunction("setTokensPatchJson") { view: T3ReviewDiffView, tokensPatchJson: String -> + view.setTokensPatchJson(tokensPatchJson) + } + + OnViewDestroys { view: T3ReviewDiffView -> + view.cleanup() + } + } + } +} diff --git a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt new file mode 100644 index 00000000000..6f960f818a0 --- /dev/null +++ b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt @@ -0,0 +1,1056 @@ +package expo.modules.t3reviewdiff + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.Typeface +import android.view.GestureDetector +import android.view.MotionEvent +import android.view.VelocityTracker +import android.view.View +import android.view.ViewGroup +import android.view.ViewConfiguration +import android.widget.OverScroller +import expo.modules.kotlin.AppContext +import expo.modules.kotlin.viewevent.EventDispatcher +import expo.modules.kotlin.views.ExpoView +import org.json.JSONArray +import org.json.JSONObject +import java.util.concurrent.Executors +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.min + +class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(context, appContext) { + private val canvasView = DiffCanvasView(context) + private val onDebug by EventDispatcher() + private val onVisibleFileChange by EventDispatcher() + private val onToggleFile by EventDispatcher() + private val onToggleViewedFile by EventDispatcher() + private val onPressLine by EventDispatcher() + private val onToggleComment by EventDispatcher() + private var rows: List = emptyList() + private var visibleRows: List = emptyList() + private var collapsedFileIds: Set = emptySet() + private var viewedFileIds: Set = emptySet() + private var selectedRowIds: Set = emptySet() + private var collapsedCommentIds: Set = emptySet() + private var initialRowIndex = 0 + private var pendingInitialScroll = false + private var lastVisibleFileId: String? = null + private var tokensResetKey = "" + private var contentResetKey = "" + private var rowsDecodeGeneration = 0 + private var tokensDecodeGeneration = 0 + private val payloadDecodeExecutor = Executors.newSingleThreadExecutor() + private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop + private val minimumFlingVelocity = ViewConfiguration.get(context).scaledMinimumFlingVelocity + private val verticalScroller = OverScroller(context) + private val horizontalScroller = OverScroller(context) + private var dragAxis: DragAxis? = null + private var lastTouchX = 0f + private var lastTouchY = 0f + private var velocityTracker: VelocityTracker? = null + + init { + canvasView.onRowTap = { row, gesture -> handleRowTap(row, gesture) } + canvasView.onVisibleRowsChanged = { first, last -> + onDebug( + mapOf( + "message" to "visible-range", + "firstRowIndex" to first, + "lastRowIndex" to last, + ), + ) + emitVisibleFile(first) + } + + addView( + canvasView, + LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT), + ) + } + + fun setTokensResetKey(value: String) { + if (tokensResetKey == value) return + tokensResetKey = value + canvasView.tokensByRowId = emptyMap() + } + + fun setContentResetKey(value: String) { + if (contentResetKey == value) return + contentResetKey = value + tokensDecodeGeneration += 1 + canvasView.tokensByRowId = emptyMap() + lastVisibleFileId = null + pendingInitialScroll = true + canvasView.setVerticalOffset(0) + canvasView.setHorizontalOffset(0) + applyPendingInitialScroll() + } + + fun setCollapsedFileIdsJson(value: String) { + collapsedFileIds = parseStringSet(value) + rebuildVisibleRows() + } + + fun setViewedFileIdsJson(value: String) { + viewedFileIds = parseStringSet(value) + canvasView.viewedFileIds = viewedFileIds + } + + fun setSelectedRowIdsJson(value: String) { + selectedRowIds = parseStringSet(value) + canvasView.selectedRowIds = selectedRowIds + } + + fun setCollapsedCommentIdsJson(value: String) { + collapsedCommentIds = parseStringSet(value) + rebuildVisibleRows() + } + + fun setAppearanceScheme(value: String) { + canvasView.theme = DiffTheme.fallback(value) + } + + fun setThemeJson(value: String) { + canvasView.theme = DiffTheme.fromJson(value, canvasView.theme) + } + + fun setStyleJson(value: String) { + canvasView.style = DiffStyle.fromJson(value, canvasView.style, resources.displayMetrics.density) + } + + fun setRowHeight(value: Float) { + canvasView.style = canvasView.style.copy(rowHeightPx = dp(value)) + } + + fun setContentWidth(value: Float) { + canvasView.contentWidthPx = max(width, dp(value).toInt()) + } + + fun setInitialRowIndex(value: Double) { + initialRowIndex = value.toInt().coerceAtLeast(0) + pendingInitialScroll = true + applyPendingInitialScroll() + } + + fun setRowsJson(value: String) { + rowsDecodeGeneration += 1 + val generation = rowsDecodeGeneration + payloadDecodeExecutor.execute { + val decodedRows = parseRows(value) + post { + if (generation != rowsDecodeGeneration) return@post + rows = decodedRows + lastVisibleFileId = null + rebuildVisibleRows() + } + } + } + + fun setTokensJson(value: String) { + tokensDecodeGeneration += 1 + val generation = tokensDecodeGeneration + payloadDecodeExecutor.execute { + val decodedTokens = parseTokensObject(value) + post { + if (generation != tokensDecodeGeneration) return@post + canvasView.tokensByRowId = decodedTokens + } + } + } + + fun setTokensPatchJson(value: String) { + payloadDecodeExecutor.execute { + try { + val payload = JSONObject(value) + val resetKey = payload.optString("resetKey") + val decodedTokens = parseTokensObject( + payload.optJSONObject("tokensByRowId") ?: JSONObject(), + ) + post { + if (resetKey.isNotEmpty() && resetKey != tokensResetKey) return@post + if (decodedTokens.isNotEmpty()) { + canvasView.tokensByRowId = canvasView.tokensByRowId + decodedTokens + } + } + } catch (_: Exception) { + } + } + } + + fun cleanup() { + payloadDecodeExecutor.shutdownNow() + } + + fun scrollToFile(fileId: String, animated: Boolean) { + val index = visibleRows.indexOfFirst { it.kind == "file" && it.resolvedFileId == fileId } + if (index < 0) return + scrollToY(canvasView.rowTop(index), animated) + } + + fun scrollToTop(animated: Boolean) { + scrollToY(0, animated) + } + + @Suppress("NestedBlockDepth", "ReturnCount") + override fun onInterceptTouchEvent(event: MotionEvent): Boolean { + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + verticalScroller.forceFinished(true) + horizontalScroller.forceFinished(true) + dragAxis = null + lastTouchX = event.x + lastTouchY = event.y + parent?.requestDisallowInterceptTouchEvent(true) + return false + } + MotionEvent.ACTION_MOVE -> { + if (dragAxis == null) { + val deltaX = event.x - lastTouchX + val deltaY = event.y - lastTouchY + if (max(abs(deltaX), abs(deltaY)) > touchSlop) { + dragAxis = if (abs(deltaY) >= abs(deltaX)) DragAxis.VERTICAL else DragAxis.HORIZONTAL + } + } + return dragAxis != null + } + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> return dragAxis != null + } + return false + } + + override fun dispatchTouchEvent(event: MotionEvent): Boolean { + if (event.actionMasked == MotionEvent.ACTION_DOWN) { + velocityTracker?.recycle() + velocityTracker = VelocityTracker.obtain() + } + velocityTracker?.addMovement(event) + val handled = super.dispatchTouchEvent(event) + if ( + ( + event.actionMasked == MotionEvent.ACTION_UP || + event.actionMasked == MotionEvent.ACTION_CANCEL + ) && + dragAxis == null + ) { + velocityTracker?.recycle() + velocityTracker = null + parent?.requestDisallowInterceptTouchEvent(false) + } + return handled + } + + @Suppress("NestedBlockDepth") + override fun onTouchEvent(event: MotionEvent): Boolean { + val axis = dragAxis ?: return false + when (event.actionMasked) { + MotionEvent.ACTION_MOVE -> { + val deltaX = (lastTouchX - event.x).toInt() + val deltaY = (lastTouchY - event.y).toInt() + if (axis == DragAxis.VERTICAL) { + canvasView.scrollByVertical(deltaY) + } else { + canvasView.scrollByHorizontal(deltaX) + } + lastTouchX = event.x + lastTouchY = event.y + } + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + if (event.actionMasked == MotionEvent.ACTION_UP) { + velocityTracker?.computeCurrentVelocity(1000) + if (axis == DragAxis.VERTICAL) { + val velocity = -(velocityTracker?.yVelocity ?: 0f).toInt() + if (abs(velocity) >= minimumFlingVelocity) { + verticalScroller.fling( + 0, + canvasView.verticalOffset(), + 0, + velocity, + 0, + 0, + 0, + canvasView.maxVerticalOffset(), + ) + postInvalidateOnAnimation() + } + } else { + val velocity = -(velocityTracker?.xVelocity ?: 0f).toInt() + if (abs(velocity) >= minimumFlingVelocity) { + horizontalScroller.fling( + canvasView.horizontalOffset(), + 0, + velocity, + 0, + 0, + canvasView.maxHorizontalOffset(), + 0, + 0, + ) + postInvalidateOnAnimation() + } + } + } + dragAxis = null + velocityTracker?.recycle() + velocityTracker = null + parent?.requestDisallowInterceptTouchEvent(false) + } + } + return true + } + + override fun computeScroll() { + var animating = false + if (verticalScroller.computeScrollOffset()) { + canvasView.setVerticalOffset(verticalScroller.currY) + animating = true + } + if (horizontalScroller.computeScrollOffset()) { + canvasView.setHorizontalOffset(horizontalScroller.currX) + animating = true + } + if (animating) { + postInvalidateOnAnimation() + } + } + + private fun rebuildVisibleRows() { + val filtered = ArrayList(rows.size) + var currentFileCollapsed = false + rows.forEach { row -> + if (row.kind == "file") { + currentFileCollapsed = collapsedFileIds.contains(row.resolvedFileId) + filtered.add(row) + } else if (!currentFileCollapsed) { + if (row.kind != "comment" || !collapsedCommentIds.contains(row.id)) { + filtered.add(row) + } else { + filtered.add(row.copy(commentText = "Comment collapsed")) + } + } + } + visibleRows = filtered + canvasView.rows = filtered + canvasView.viewedFileIds = viewedFileIds + canvasView.selectedRowIds = selectedRowIds + applyPendingInitialScroll() + } + + private fun handleRowTap(row: DiffRow, gesture: String) { + when (row.kind) { + "file" -> { + if (gesture == "longPress") { + onToggleViewedFile(mapOf("fileId" to row.resolvedFileId)) + } else { + onToggleFile(mapOf("fileId" to row.resolvedFileId)) + } + } + "comment" -> onToggleComment(mapOf("commentId" to row.id)) + "line" -> { + val payload = mutableMapOf( + "rowId" to row.id, + "fileId" to row.resolvedFileId, + "gesture" to gesture, + "change" to row.change, + ) + row.oldLineNumber?.let { payload["oldLineNumber"] = it } + row.newLineNumber?.let { payload["newLineNumber"] = it } + onPressLine(payload) + } + } + } + + @Suppress("ReturnCount") + private fun emitVisibleFile(firstVisibleIndex: Int) { + if (visibleRows.isEmpty()) return + val start = firstVisibleIndex.coerceIn(0, visibleRows.lastIndex) + val fileId = (start downTo 0) + .asSequence() + .map { visibleRows[it].resolvedFileId } + .firstOrNull { it.isNotEmpty() } + ?: return + if (fileId == lastVisibleFileId) return + lastVisibleFileId = fileId + onVisibleFileChange(mapOf("fileId" to fileId)) + } + + private fun applyPendingInitialScroll() { + if (!pendingInitialScroll || visibleRows.isEmpty()) return + pendingInitialScroll = false + val index = initialRowIndex.coerceIn(0, visibleRows.lastIndex) + post { canvasView.setVerticalOffset(canvasView.rowTop(index)) } + } + + private fun scrollToY(y: Int, animated: Boolean) { + val target = y.coerceIn(0, canvasView.maxVerticalOffset()) + if (animated) { + verticalScroller.startScroll( + 0, + canvasView.verticalOffset(), + 0, + target - canvasView.verticalOffset(), + 250, + ) + postInvalidateOnAnimation() + } else { + canvasView.setVerticalOffset(target) + } + } + + private fun dp(value: Float): Float = value * resources.displayMetrics.density + + private enum class DragAxis { + VERTICAL, + HORIZONTAL + } +} + +private data class DiffRow( + val kind: String, + val id: String, + val fileId: String, + val filePath: String, + val previousPath: String?, + val changeType: String, + val additions: Int, + val deletions: Int, + val text: String, + val content: String, + val change: String, + val oldLineNumber: Int?, + val newLineNumber: Int?, + val commentText: String, + val commentRangeLabel: String, + val commentSectionTitle: String +) { + val resolvedFileId: String get() = fileId.ifEmpty { id } +} + +private data class DiffToken( + val content: String, + val color: Int?, + val fontStyle: Int +) + +private data class DiffTheme( + val background: Int, + val text: Int, + val mutedText: Int, + val headerBackground: Int, + val border: Int, + val hunkBackground: Int, + val hunkText: Int, + val addBackground: Int, + val deleteBackground: Int, + val addBar: Int, + val deleteBar: Int, + val addText: Int, + val deleteText: Int +) { + companion object { + fun fallback(scheme: String): DiffTheme = if (scheme == "dark") { + DiffTheme( + background = Color.rgb(20, 22, 25), + text = Color.rgb(236, 238, 240), + mutedText = Color.rgb(153, 160, 170), + headerBackground = Color.rgb(26, 29, 33), + border = Color.rgb(52, 57, 64), + hunkBackground = Color.rgb(7, 31, 40), + hunkText = Color.rgb(0, 159, 255), + addBackground = Color.rgb(13, 47, 40), + deleteBackground = Color.rgb(57, 20, 21), + addBar = Color.rgb(0, 202, 177), + deleteBar = Color.rgb(255, 46, 63), + addText = Color.rgb(94, 204, 113), + deleteText = Color.rgb(255, 103, 98), + ) + } else { + DiffTheme( + background = Color.WHITE, + text = Color.rgb(7, 7, 7), + mutedText = Color.rgb(102, 106, 115), + headerBackground = Color.WHITE, + border = Color.rgb(222, 224, 228), + hunkBackground = Color.rgb(224, 242, 255), + hunkText = Color.rgb(0, 130, 220), + addBackground = Color.rgb(229, 248, 245), + deleteBackground = Color.rgb(255, 230, 231), + addBar = Color.rgb(0, 172, 151), + deleteBar = Color.rgb(213, 44, 54), + addText = Color.rgb(25, 130, 67), + deleteText = Color.rgb(190, 38, 48), + ) + } + + fun fromJson(value: String, fallback: DiffTheme): DiffTheme = try { + val json = JSONObject(value) + DiffTheme( + background = color(json, "background", fallback.background), + text = color(json, "text", fallback.text), + mutedText = color(json, "mutedText", fallback.mutedText), + headerBackground = color(json, "headerBackground", fallback.headerBackground), + border = color(json, "border", fallback.border), + hunkBackground = color(json, "hunkBackground", fallback.hunkBackground), + hunkText = color(json, "hunkText", fallback.hunkText), + addBackground = color(json, "addBackground", fallback.addBackground), + deleteBackground = color(json, "deleteBackground", fallback.deleteBackground), + addBar = color(json, "addBar", fallback.addBar), + deleteBar = color(json, "deleteBar", fallback.deleteBar), + addText = color(json, "addText", fallback.addText), + deleteText = color(json, "deleteText", fallback.deleteText), + ) + } catch (_: Exception) { + fallback + } + + private fun color(json: JSONObject, key: String, fallback: Int): Int = + parseColor(json.optString(key), fallback) + } +} + +private data class DiffStyle( + val rowHeightPx: Float, + val gutterWidthPx: Float, + val codePaddingPx: Float, + val changeBarWidthPx: Float, + val fileHeaderHeightPx: Float, + val codeFontSizePx: Float, + val lineNumberFontSizePx: Float +) { + companion object { + fun defaults(density: Float): DiffStyle = DiffStyle( + rowHeightPx = 20f * density, + gutterWidthPx = 72f * density, + codePaddingPx = 10f * density, + changeBarWidthPx = 3f * density, + fileHeaderHeightPx = 44f * density, + codeFontSizePx = 12f * density, + lineNumberFontSizePx = 10f * density, + ) + + fun fromJson(value: String, fallback: DiffStyle, density: Float): DiffStyle = try { + val json = JSONObject(value) + DiffStyle( + rowHeightPx = json.floatDp("rowHeight", fallback.rowHeightPx, density), + gutterWidthPx = json.floatDp("gutterWidth", fallback.gutterWidthPx, density), + codePaddingPx = json.floatDp("codePadding", fallback.codePaddingPx, density), + changeBarWidthPx = json.floatDp("changeBarWidth", fallback.changeBarWidthPx, density), + fileHeaderHeightPx = json.floatDp("fileHeaderHeight", fallback.fileHeaderHeightPx, density), + codeFontSizePx = json.floatSp("codeFontSize", fallback.codeFontSizePx, density), + lineNumberFontSizePx = json.floatSp( + "lineNumberFontSize", + fallback.lineNumberFontSizePx, + density + ), + ) + } catch (_: Exception) { + fallback + } + } +} + +private class DiffCanvasView(context: Context) : View(context) { + private val density = resources.displayMetrics.density + private val backgroundPaint = Paint() + private val borderPaint = Paint(Paint.ANTI_ALIAS_FLAG) + private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { typeface = Typeface.MONOSPACE } + private val boldTextPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD) + } + private val gestureDetector = GestureDetector( + context, + object : GestureDetector.SimpleOnGestureListener() { + override fun onDown(event: MotionEvent): Boolean = true + + override fun onSingleTapUp(event: MotionEvent): Boolean { + rowAt(event.y)?.let { onRowTap?.invoke(it, "tap") } + return true + } + + override fun onLongPress(event: MotionEvent) { + rowAt(event.y)?.let { onRowTap?.invoke(it, "longPress") } + } + }, + ) + private var rowOffsets = intArrayOf(0) + private var verticalOffset = 0 + private var horizontalOffset = 0 + private var lastVisibleRange: Pair? = null + + var rows: List = emptyList() + set(value) { + field = value + rebuildOffsets() + } + var tokensByRowId: Map> = emptyMap() + set(value) { + field = value + invalidate() + } + var viewedFileIds: Set = emptySet() + set(value) { + field = value + invalidate() + } + var selectedRowIds: Set = emptySet() + set(value) { + field = value + invalidate() + } + var theme: DiffTheme = DiffTheme.fallback("light") + set(value) { + field = value + invalidate() + } + var style: DiffStyle = DiffStyle.defaults(density) + set(value) { + field = value + rebuildOffsets() + } + var contentWidthPx: Int = (1200 * density).toInt() + set(value) { + field = max(value, suggestedMinimumWidth) + setHorizontalOffset(horizontalOffset) + invalidate() + } + var onRowTap: ((DiffRow, String) -> Unit)? = null + var onVisibleRowsChanged: ((Int, Int) -> Unit)? = null + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + setMeasuredDimension( + MeasureSpec.getSize(widthMeasureSpec), + MeasureSpec.getSize(heightMeasureSpec), + ) + } + + override fun onSizeChanged(width: Int, height: Int, oldWidth: Int, oldHeight: Int) { + super.onSizeChanged(width, height, oldWidth, oldHeight) + setVerticalOffset(verticalOffset) + setHorizontalOffset(horizontalOffset) + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + canvas.drawColor(theme.background) + if (rows.isEmpty()) return + val first = rowIndexAt(max(0, verticalOffset + canvas.clipBounds.top)) + val last = rowIndexAt( + min(max(0, rowOffsets.last() - 1), verticalOffset + canvas.clipBounds.bottom), + ).coerceAtLeast(first) + for (index in first..last.coerceAtMost(rows.lastIndex)) { + drawRow( + canvas, + rows[index], + rowOffsets[index] - verticalOffset, + rowOffsets[index + 1] - verticalOffset, + ) + } + drawStickyFileHeader(canvas, first) + drawHorizontalScrollIndicator(canvas) + emitVisibleRange() + } + + override fun onTouchEvent(event: MotionEvent): Boolean = gestureDetector.onTouchEvent(event) + + fun rowTop(index: Int): Int = rowOffsets[index.coerceIn(0, max(0, rowOffsets.size - 2))] + + fun setVerticalOffset(value: Int) { + val maxOffset = max(0, (rowOffsets.lastOrNull() ?: 0) - height) + val nextOffset = value.coerceIn(0, maxOffset) + if (verticalOffset == nextOffset) return + verticalOffset = nextOffset + invalidate() + emitVisibleRange() + } + + fun scrollByVertical(delta: Int) { + setVerticalOffset(verticalOffset + delta) + } + + fun verticalOffset(): Int = verticalOffset + + fun maxVerticalOffset(): Int = max(0, (rowOffsets.lastOrNull() ?: 0) - height) + + fun setHorizontalOffset(value: Int) { + val nextOffset = value.coerceIn(0, maxHorizontalOffset()) + if (horizontalOffset == nextOffset) return + horizontalOffset = nextOffset + invalidate() + } + + fun scrollByHorizontal(delta: Int) { + setHorizontalOffset(horizontalOffset + delta) + } + + fun horizontalOffset(): Int = horizontalOffset + + fun maxHorizontalOffset(): Int = max(0, contentWidthPx - width) + + private fun rebuildOffsets() { + rowOffsets = IntArray(rows.size + 1) + rows.forEachIndexed { index, row -> + rowOffsets[index + 1] = rowOffsets[index] + rowHeight(row) + } + setVerticalOffset(verticalOffset) + requestLayout() + invalidate() + } + + private fun rowHeight(row: DiffRow): Int = when (row.kind) { + "file" -> style.fileHeaderHeightPx.toInt() + "comment" -> max((style.rowHeightPx * 3.2f).toInt(), (56 * density).toInt()) + else -> style.rowHeightPx.toInt() + }.coerceAtLeast(1) + + @Suppress("ReturnCount") + private fun rowIndexAt(y: Int): Int { + if (rows.isEmpty()) return 0 + var low = 0 + var high = rows.lastIndex + while (low <= high) { + val middle = (low + high) ushr 1 + when { + y < rowOffsets[middle] -> high = middle - 1 + y >= rowOffsets[middle + 1] -> low = middle + 1 + else -> return middle + } + } + return low.coerceIn(0, rows.lastIndex) + } + + private fun rowAt(y: Float): DiffRow? { + stickyFileHeader(firstVisibleRow())?.let { sticky -> + if (y >= max(0, sticky.top).toFloat() && y < sticky.bottom.toFloat()) { + return rows.getOrNull(sticky.index) + } + } + return rows.getOrNull(rowIndexAt(verticalOffset + y.toInt())) + } + + private fun firstVisibleRow(): Int = rowIndexAt(verticalOffset) + + private fun emitVisibleRange() { + if (rows.isEmpty()) return + val first = rowIndexAt(verticalOffset) + val last = rowIndexAt(verticalOffset + max(1, height)) + val range = first to last + if (range == lastVisibleRange) return + lastVisibleRange = range + onVisibleRowsChanged?.invoke(first, last) + } + + private fun drawRow(canvas: Canvas, row: DiffRow, top: Int, bottom: Int) { + when (row.kind) { + "file" -> drawFileRow(canvas, row, top, bottom) + "hunk" -> drawHunkRow(canvas, row, top, bottom) + "notice" -> drawNoticeRow(canvas, row, top, bottom) + "comment" -> drawCommentRow(canvas, row, top, bottom) + else -> drawLineRow(canvas, row, top, bottom) + } + } + + private fun drawFileRow(canvas: Canvas, row: DiffRow, top: Int, bottom: Int) { + fill(canvas, theme.headerBackground, 0f, top.toFloat(), width.toFloat(), bottom.toFloat()) + val baseline = centeredBaseline(top, bottom, boldTextPaint.apply { textSize = 13f * density }) + boldTextPaint.color = theme.text + val marker = if (viewedFileIds.contains(row.resolvedFileId)) "[x] " else "[ ] " + textPaint.color = theme.mutedText + textPaint.textSize = 11f * density + val stats = "+${row.additions} -${row.deletions}" + val statsX = max(12f * density, width - textPaint.measureText(stats) - 16f * density) + val title = ellipsize(marker + row.filePath, boldTextPaint, statsX - 28f * density) + canvas.drawText(title, 12f * density, baseline, boldTextPaint) + canvas.drawText(stats, statsX, baseline, textPaint) + drawBottomBorder(canvas, bottom) + } + + private fun drawHunkRow(canvas: Canvas, row: DiffRow, top: Int, bottom: Int) { + fill(canvas, theme.hunkBackground, 0f, top.toFloat(), width.toFloat(), bottom.toFloat()) + textPaint.color = theme.hunkText + textPaint.textSize = style.codeFontSizePx + drawScrollableCode(canvas, top, bottom) { codeX -> + canvas.drawText( + row.text.ifEmpty { row.content }, + codeX, + centeredBaseline(top, bottom, textPaint), + textPaint, + ) + } + } + + private fun drawNoticeRow(canvas: Canvas, row: DiffRow, top: Int, bottom: Int) { + textPaint.color = theme.mutedText + textPaint.textSize = style.codeFontSizePx + drawScrollableCode(canvas, top, bottom) { codeX -> + canvas.drawText(row.text, codeX, centeredBaseline(top, bottom, textPaint), textPaint) + } + } + + private fun drawCommentRow(canvas: Canvas, row: DiffRow, top: Int, bottom: Int) { + fill( + canvas, + theme.headerBackground, + style.gutterWidthPx, + top.toFloat(), + width.toFloat(), + bottom.toFloat() + ) + boldTextPaint.color = theme.text + boldTextPaint.textSize = 12f * density + drawScrollableCode(canvas, top, bottom) { codeX -> + canvas.drawText( + row.commentSectionTitle.ifEmpty { row.commentRangeLabel.ifEmpty { "Comment" } }, + codeX, + top + 20f * density, + boldTextPaint, + ) + textPaint.color = theme.mutedText + textPaint.textSize = 12f * density + canvas.drawText(row.commentText, codeX, top + 42f * density, textPaint) + } + drawBottomBorder(canvas, bottom) + } + + @Suppress("CyclomaticComplexMethod") + private fun drawLineRow(canvas: Canvas, row: DiffRow, top: Int, bottom: Int) { + val background = when (row.change) { + "add" -> theme.addBackground + "delete" -> theme.deleteBackground + else -> theme.background + } + fill(canvas, background, 0f, top.toFloat(), width.toFloat(), bottom.toFloat()) + val selected = selectedRowIds.contains(row.id) + if (selected) { + fill( + canvas, + withAlpha(theme.hunkText, if (theme.background == Color.WHITE) 54 else 76), + 0f, + top.toFloat(), + width.toFloat(), + bottom.toFloat(), + ) + } + val barColor = when (row.change) { + "add" -> theme.addBar + "delete" -> theme.deleteBar + else -> Color.TRANSPARENT + } + if (barColor != Color.TRANSPARENT && style.changeBarWidthPx > 0) { + fill(canvas, barColor, 0f, top.toFloat(), style.changeBarWidthPx, bottom.toFloat()) + } + + val tokens = tokensByRowId[row.id] + drawScrollableCode(canvas, top, bottom) { codeX -> + if (tokens.isNullOrEmpty()) { + textPaint.textSize = style.codeFontSizePx + textPaint.color = when (row.change) { + "add" -> theme.addText + "delete" -> theme.deleteText + else -> theme.text + } + canvas.drawText(row.content, codeX, centeredBaseline(top, bottom, textPaint), textPaint) + } else { + var x = codeX + tokens.forEach { token -> + textPaint.textSize = style.codeFontSizePx + textPaint.color = token.color ?: theme.text + textPaint.typeface = when { + token.fontStyle and 1 != 0 -> Typeface.create(Typeface.MONOSPACE, Typeface.ITALIC) + token.fontStyle and 2 != 0 -> Typeface.create(Typeface.MONOSPACE, Typeface.BOLD) + else -> Typeface.MONOSPACE + } + canvas.drawText(token.content, x, centeredBaseline(top, bottom, textPaint), textPaint) + x += textPaint.measureText(token.content) + } + textPaint.typeface = Typeface.MONOSPACE + } + } + + textPaint.textSize = style.lineNumberFontSizePx + textPaint.color = if (selected) theme.text else theme.mutedText + val oldNumber = row.oldLineNumber?.toString().orEmpty() + val newNumber = row.newLineNumber?.toString().orEmpty() + val baseline = centeredBaseline(top, bottom, textPaint) + canvas.drawText(oldNumber, style.changeBarWidthPx + 6f * density, baseline, textPaint) + canvas.drawText( + newNumber, + style.changeBarWidthPx + style.gutterWidthPx / 2f, + baseline, + textPaint + ) + } + + private fun drawScrollableCode( + canvas: Canvas, + top: Int, + bottom: Int, + draw: (Float) -> Unit + ) { + val gutterEnd = style.changeBarWidthPx + style.gutterWidthPx + canvas.save() + canvas.clipRect(gutterEnd, top.toFloat(), width.toFloat(), bottom.toFloat()) + draw(gutterEnd + style.codePaddingPx - horizontalOffset) + canvas.restore() + } + + private fun drawStickyFileHeader(canvas: Canvas, firstVisibleIndex: Int) { + val sticky = stickyFileHeader(firstVisibleIndex) ?: return + val naturalTop = rowOffsets[sticky.index] - verticalOffset + if (naturalTop == sticky.top) return + drawFileRow(canvas, rows[sticky.index], sticky.top, sticky.bottom) + } + + @Suppress("ReturnCount") + private fun stickyFileHeader(firstVisibleIndex: Int): StickyFileHeader? { + if (rows.isEmpty()) return null + val fileIndex = (firstVisibleIndex.coerceIn(0, rows.lastIndex) downTo 0) + .firstOrNull { rows[it].kind == "file" } + ?: return null + val headerHeight = rowHeight(rows[fileIndex]) + val nextFileIndex = ((fileIndex + 1)..rows.lastIndex).firstOrNull { rows[it].kind == "file" } + val top = nextFileIndex + ?.let { min(0, rowOffsets[it] - verticalOffset - headerHeight) } + ?: 0 + return StickyFileHeader(fileIndex, top, top + headerHeight) + } + + private fun drawHorizontalScrollIndicator(canvas: Canvas) { + val maxOffset = maxHorizontalOffset() + if (maxOffset <= 0 || width <= 0) return + val trackWidth = width.toFloat() + val thumbWidth = max(24f * density, trackWidth * trackWidth / contentWidthPx) + val thumbTravel = trackWidth - thumbWidth + val left = thumbTravel * horizontalOffset / maxOffset + fill( + canvas, + withAlpha(theme.mutedText, 110), + left, + height - 2f * density, + left + thumbWidth, + height.toFloat(), + ) + } + + @Suppress("LongParameterList") + private fun fill( + canvas: Canvas, + color: Int, + left: Float, + top: Float, + right: Float, + bottom: Float + ) { + backgroundPaint.color = color + canvas.drawRect(left, top, right, bottom, backgroundPaint) + } + + private fun drawBottomBorder(canvas: Canvas, bottom: Int) { + borderPaint.color = theme.border + borderPaint.strokeWidth = density + canvas.drawLine(0f, bottom - density / 2f, width.toFloat(), bottom - density / 2f, borderPaint) + } + + private fun centeredBaseline(top: Int, bottom: Int, paint: Paint): Float { + val metrics = paint.fontMetrics + return (top + bottom) / 2f - (metrics.ascent + metrics.descent) / 2f + } + + private fun ellipsize(value: String, paint: Paint, width: Float): String { + if (paint.measureText(value) <= width) return value + val suffix = "..." + val available = max(0f, width - paint.measureText(suffix)) + var end = value.length + while (end > 0 && paint.measureText(value, 0, end) > available) end -= 1 + return value.substring(0, end) + suffix + } + + private data class StickyFileHeader( + val index: Int, + val top: Int, + val bottom: Int + ) +} + +private fun withAlpha(color: Int, alpha: Int): Int = + Color.argb(alpha, Color.red(color), Color.green(color), Color.blue(color)) + +private fun parseRows(value: String): List = try { + val array = JSONArray(value) + List(array.length()) { index -> + val row = array.getJSONObject(index) + DiffRow( + kind = row.optString("kind"), + id = row.optString("id"), + fileId = row.optString("fileId"), + filePath = row.optString("filePath"), + previousPath = row.optNullableString("previousPath"), + changeType = row.optString("changeType"), + additions = row.optInt("additions"), + deletions = row.optInt("deletions"), + text = row.optString("text"), + content = row.optString("content"), + change = row.optString("change", "context"), + oldLineNumber = row.optNullableInt("oldLineNumber"), + newLineNumber = row.optNullableInt("newLineNumber"), + commentText = row.optString("commentText"), + commentRangeLabel = row.optString("commentRangeLabel"), + commentSectionTitle = row.optString("commentSectionTitle"), + ) + } +} catch (_: Exception) { + emptyList() +} + +private fun parseTokensObject(value: String): Map> = try { + parseTokensObject(JSONObject(value)) +} catch (_: Exception) { + emptyMap() +} + +private fun parseTokensObject(value: JSONObject): Map> { + val result = LinkedHashMap>() + val keys = value.keys() + while (keys.hasNext()) { + val rowId = keys.next() + val array = value.optJSONArray(rowId) ?: continue + result[rowId] = List(array.length()) { index -> + val token = array.getJSONObject(index) + DiffToken( + content = token.optString("content"), + color = token.optNullableString("color")?.let { parseColor(it, Color.TRANSPARENT) }, + fontStyle = token.optInt("fontStyle"), + ) + } + } + return result +} + +private fun parseStringSet(value: String): Set = try { + val array = JSONArray(value) + buildSet { + for (index in 0 until array.length()) add(array.getString(index)) + } +} catch (_: Exception) { + emptySet() +} + +private fun parseColor(value: String, fallback: Int): Int = try { + Color.parseColor(value) +} catch (_: Exception) { + fallback +} + +private fun JSONObject.optNullableString(key: String): String? = + if (isNull(key)) null else optString(key).takeIf { it.isNotEmpty() } + +private fun JSONObject.optNullableInt(key: String): Int? = + if (isNull(key) || !has(key)) null else optInt(key) + +private fun JSONObject.floatDp(key: String, fallbackPx: Float, density: Float): Float = + if (has(key)) optDouble(key).toFloat() * density else fallbackPx + +private fun JSONObject.floatSp(key: String, fallbackPx: Float, density: Float): Float = + if (has(key)) optDouble(key).toFloat() * density else fallbackPx diff --git a/apps/mobile/modules/t3-review-diff/expo-module.config.json b/apps/mobile/modules/t3-review-diff/expo-module.config.json index fe6b11b649c..bf5baf4fc5c 100644 --- a/apps/mobile/modules/t3-review-diff/expo-module.config.json +++ b/apps/mobile/modules/t3-review-diff/expo-module.config.json @@ -1,7 +1,10 @@ { - "platforms": ["apple"], + "platforms": ["apple", "android"], "apple": { "modules": ["T3ReviewDiffModule"], "podspecPath": "T3ReviewDiffNative.podspec" + }, + "android": { + "modules": ["expo.modules.t3reviewdiff.T3ReviewDiffModule"] } } diff --git a/apps/mobile/modules/t3-review-diff/package.json b/apps/mobile/modules/t3-review-diff/package.json index 75ac49e5a99..4b3f91dbd86 100644 --- a/apps/mobile/modules/t3-review-diff/package.json +++ b/apps/mobile/modules/t3-review-diff/package.json @@ -7,6 +7,11 @@ "modules": [ "T3ReviewDiffModule" ] + }, + "android": { + "modules": [ + "expo.modules.t3reviewdiff.T3ReviewDiffModule" + ] } } } diff --git a/apps/mobile/modules/t3-terminal/README.md b/apps/mobile/modules/t3-terminal/README.md index 09d927f4733..768e3c0704c 100644 --- a/apps/mobile/modules/t3-terminal/README.md +++ b/apps/mobile/modules/t3-terminal/README.md @@ -18,9 +18,9 @@ uses that callback I/O model: 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. +Android implements the same view contract with upstream `libghostty-vt` for terminal state, parsing, +reflow, and scrollback. An Android Canvas view renders compact snapshots produced by the JNI bridge, +so the React Native screen and RPC code stay platform-neutral. Vendored Ghostty revision and license details are in `THIRD_PARTY_NOTICES.md`. @@ -36,3 +36,15 @@ 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`. + +## Rebuilding libghostty-vt for Android + +The checked-in Android shared libraries and headers are pinned to the revision recorded in +`Vendor/libghostty-vt/VERSION`. Set `ANDROID_NDK_HOME` and run: + +```bash +apps/mobile/modules/t3-terminal/scripts/build-libghostty-android.sh +``` + +The script downloads Zig 0.15.2 when needed, checks out the pinned upstream Ghostty revision, and +rebuilds all four Android ABIs with 16 KB page-size support. diff --git a/apps/mobile/modules/t3-terminal/THIRD_PARTY_NOTICES.md b/apps/mobile/modules/t3-terminal/THIRD_PARTY_NOTICES.md index 0ed6e1d1487..990dd4bbe9c 100644 --- a/apps/mobile/modules/t3-terminal/THIRD_PARTY_NOTICES.md +++ b/apps/mobile/modules/t3-terminal/THIRD_PARTY_NOTICES.md @@ -14,3 +14,21 @@ iOS 16 support fork. That fork was created from VVTerm's custom-I/O Ghostty fork Ghostty's MIT license applies to the vendored framework. Keep this notice in sync when updating `Vendor/libghostty`. + +## Ghostty / libghostty-vt + +The Android terminal renderer vendors upstream `libghostty-vt` shared libraries and C headers. + +- Upstream project: https://github.com/ghostty-org/ghostty +- Vendored revision: `9f62873bf195e4d8a762d768a1405a5f2f7b1697` +- License: MIT + +Ghostty's MIT license applies to the vendored Android libraries. Keep this notice in sync when +updating `Vendor/libghostty-vt`. + +## MesloLGS NF (Android terminal font) + +- Files: `android/src/main/assets/fonts/MesloLGS-NF-{Regular,Bold}.ttf` +- Source: https://github.com/romkatv/powerlevel10k-media (Meslo LG patched with Nerd Fonts glyphs) +- Upstream: Meslo LG by André Berg (customization of Apple's Menlo), Nerd Fonts patcher +- License: Apache License 2.0 diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/LICENSE b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/LICENSE new file mode 100644 index 00000000000..0a07a66cd1a --- /dev/null +++ b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Mitchell Hashimoto, Ghostty contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/VERSION b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/VERSION new file mode 100644 index 00000000000..aa5d5e7421e --- /dev/null +++ b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/VERSION @@ -0,0 +1 @@ +9f62873bf195e4d8a762d768a1405a5f2f7b1697 diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt.h new file mode 100644 index 00000000000..94a85033434 --- /dev/null +++ b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt.h @@ -0,0 +1,154 @@ +/** + * @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 terminal "Terminal" - Complete terminal emulator state and rendering + * - @ref render "Render State" - Incremental render state updates for custom renderers + * - @ref formatter "Formatter" - Format terminal content as plain text, VT sequences, or HTML + * - @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 build_info "Build Info" - Query compile-time build configuration + * - @ref allocator "Memory Management" - Memory management and custom allocators + * - @ref wasm "WebAssembly Utilities" - WebAssembly convenience functions + * + * Encoding related APIs: + * - @ref focus "Focus Encoding" - Encode focus in/out events into terminal sequences + * - @ref key "Key Encoding" - Encode key events into terminal sequences + * - @ref mouse "Mouse Encoding" - Encode mouse events into terminal sequences + * + * @section examples_sec Examples + * + * Complete working examples: + * - @ref c-vt-build-info/src/main.c - Build info query example + * - @ref c-vt/src/main.c - OSC parser example + * - @ref c-vt-encode-key/src/main.c - Key encoding example + * - @ref c-vt-encode-mouse/src/main.c - Mouse encoding example + * - @ref c-vt-paste/src/main.c - Paste safety check example + * - @ref c-vt-sgr/src/main.c - SGR parser example + * - @ref c-vt-formatter/src/main.c - Terminal formatter example + * - @ref c-vt-grid-traverse/src/main.c - Grid traversal example using grid refs + * - @ref c-vt-grid-ref-tracked/src/main.c - Tracked grid ref example + * + */ + +/** @example c-vt-build-info/src/main.c + * This example demonstrates how to query compile-time build configuration + * such as SIMD support, Kitty graphics, and tmux control mode availability. + */ + +/** @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-encode-key/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-encode-mouse/src/main.c + * This example demonstrates how to use the mouse encoder to convert mouse events + * into terminal escape sequences using the SGR mouse format. + */ + +/** @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. + */ + +/** @example c-vt-formatter/src/main.c + * This example demonstrates how to use the terminal and formatter APIs to + * create a terminal, write VT-encoded content into it, and format the screen + * contents as plain text. + */ + +/** @example c-vt-grid-traverse/src/main.c + * This example demonstrates how to traverse the entire terminal grid using + * grid refs to inspect cell codepoints, row wrap state, and cell styles. + */ + +/** @example c-vt-grid-ref-tracked/src/main.c + * This example demonstrates how to track a grid ref as the terminal scrolls, + * detect when it loses its value, and move it to a new point. + */ + +/** @example c-vt-selection-gesture/src/main.c + * This example demonstrates how to use synthetic selection gesture events to + * derive drag and deep-press selection snapshots. + */ + +/** @example c-vt-kitty-graphics/src/main.c + * This example demonstrates how to use the system interface to install a + * PNG decoder callback and send a Kitty Graphics Protocol image. + */ + +#ifndef GHOSTTY_VT_H +#define GHOSTTY_VT_H + +#ifdef __cplusplus +extern "C" { +#endif + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef __cplusplus +} +#endif + +#endif /* GHOSTTY_VT_H */ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/allocator.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/allocator.h new file mode 100644 index 00000000000..2e8685e8445 --- /dev/null +++ b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/allocator.h @@ -0,0 +1,255 @@ +/** + * @file allocator.h + * + * Memory management interface for libghostty-vt. + */ + +#ifndef GHOSTTY_VT_ALLOCATOR_H +#define GHOSTTY_VT_ALLOCATOR_H + +#include +#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 + * + * ## Alloc/Free Helpers + * + * ghostty_alloc() and ghostty_free() provide a simple malloc/free-style + * interface for allocating and freeing byte buffers through the library's + * allocator. These are useful when: + * + * - You need to allocate a buffer to pass into a libghostty-vt function + * (e.g. preparing input data for ghostty_terminal_vt_write()). + * - You need to free a buffer returned by a libghostty-vt function + * (e.g. the output of ghostty_formatter_format_alloc()). + * - You are on a platform where the library's internal allocator differs + * from the consumer's C runtime (e.g. Windows, where Zig's libc and + * MSVC's CRT maintain separate heaps), so calling the standard C + * free() on library-allocated memory would be undefined behavior. + * + * Always use the same allocator (or NULL) for both the allocation and + * the corresponding free. + * + * @{ + */ + +/** + * 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; + +/** + * Allocate a buffer of `len` bytes. + * + * Uses the provided allocator, or the default allocator if NULL is passed. + * The returned buffer must be freed with ghostty_free() using the same + * allocator. + * + * @param allocator Pointer to the allocator to use, or NULL for the default + * @param len Number of bytes to allocate + * @return Pointer to the allocated buffer, or NULL if allocation failed + * + * @ingroup allocator + */ +GHOSTTY_API uint8_t* ghostty_alloc(const GhosttyAllocator* allocator, size_t len); + +/** + * Free memory that was allocated by a libghostty-vt function. + * + * Use this to free buffers returned by functions such as + * ghostty_formatter_format_alloc(). Pass the same allocator that was + * used for the allocation, or NULL if the default allocator was used. + * + * On platforms where the library's internal allocator differs from the + * consumer's C runtime (e.g. Windows, where Zig's libc and MSVC's CRT + * maintain separate heaps), calling the standard C free() on memory + * allocated by the library causes undefined behavior. This function + * guarantees the correct allocator is used regardless of platform. + * + * It is safe to pass a NULL pointer; the call is a no-op in that case. + * + * @param allocator Pointer to the allocator that was used to allocate the + * memory, or NULL if the default allocator was used + * @param ptr Pointer to the memory to free (may be NULL) + * @param len Length of the allocation in bytes (must match the original + * allocation size) + * + * @ingroup allocator + */ +GHOSTTY_API void ghostty_free(const GhosttyAllocator* allocator, uint8_t* ptr, size_t len); + +/** @} */ + +#endif /* GHOSTTY_VT_ALLOCATOR_H */ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/build_info.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/build_info.h new file mode 100644 index 00000000000..8573556f7f8 --- /dev/null +++ b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/build_info.h @@ -0,0 +1,150 @@ +/** + * @file build_info.h + * + * Build info - query compile-time build configuration of libghostty-vt. + */ + +#ifndef GHOSTTY_VT_BUILD_INFO_H +#define GHOSTTY_VT_BUILD_INFO_H + +/** @defgroup build_info Build Info + * + * Query compile-time build configuration of libghostty-vt. + * + * These values reflect the options the library was built with and are + * constant for the lifetime of the process. + * + * ## Basic Usage + * + * Use ghostty_build_info() to query individual build options: + * + * @snippet c-vt-build-info/src/main.c build-info-query + * + * @{ + */ + +#include +#include + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * Build optimization mode. + */ +typedef enum GHOSTTY_ENUM_TYPED { + GHOSTTY_OPTIMIZE_DEBUG = 0, + GHOSTTY_OPTIMIZE_RELEASE_SAFE = 1, + GHOSTTY_OPTIMIZE_RELEASE_SMALL = 2, + GHOSTTY_OPTIMIZE_RELEASE_FAST = 3, + GHOSTTY_OPTIMIZE_MODE_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyOptimizeMode; + +/** + * Build info data types that can be queried. + * + * Each variant documents the expected output pointer type. + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Invalid data type. Never results in any data extraction. */ + GHOSTTY_BUILD_INFO_INVALID = 0, + + /** + * Whether SIMD-accelerated code paths are enabled. + * + * Output type: bool * + */ + GHOSTTY_BUILD_INFO_SIMD = 1, + + /** + * Whether Kitty graphics protocol support is available. + * + * Output type: bool * + */ + GHOSTTY_BUILD_INFO_KITTY_GRAPHICS = 2, + + /** + * Whether tmux control mode support is available. + * + * Output type: bool * + */ + GHOSTTY_BUILD_INFO_TMUX_CONTROL_MODE = 3, + + /** + * The optimization mode the library was built with. + * + * Output type: GhosttyOptimizeMode * + */ + GHOSTTY_BUILD_INFO_OPTIMIZE = 4, + + /** + * The full version string (e.g. "1.2.3" or "1.2.3-dev+abcdef"). + * + * Output type: GhosttyString * + */ + GHOSTTY_BUILD_INFO_VERSION_STRING = 5, + + /** + * The major version number. + * + * Output type: size_t * + */ + GHOSTTY_BUILD_INFO_VERSION_MAJOR = 6, + + /** + * The minor version number. + * + * Output type: size_t * + */ + GHOSTTY_BUILD_INFO_VERSION_MINOR = 7, + + /** + * The patch version number. + * + * Output type: size_t * + */ + GHOSTTY_BUILD_INFO_VERSION_PATCH = 8, + + /** + * The pre metadata string (e.g. "alpha", "beta", "dev"). Has zero length if + * no pre metadata is present. + * + * Output type: GhosttyString * + */ + GHOSTTY_BUILD_INFO_VERSION_PRE = 9, + + /** + * The build metadata string (e.g. commit hash). Has zero length if + * no build metadata is present. + * + * Output type: GhosttyString * + */ + GHOSTTY_BUILD_INFO_VERSION_BUILD = 10, + GHOSTTY_BUILD_INFO_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyBuildInfo; + +/** + * Query a compile-time build configuration value. + * + * The caller must pass a pointer to the correct output type for the + * requested data (see GhosttyBuildInfo variants for types). + * + * @param data The build info field to query + * @param out Pointer to store the result (type depends on data parameter) + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the + * data type is invalid + * + * @ingroup build_info + */ +GHOSTTY_API GhosttyResult ghostty_build_info(GhosttyBuildInfo data, void *out); + +#ifdef __cplusplus +} +#endif + +/** @} */ + +#endif /* GHOSTTY_VT_BUILD_INFO_H */ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/color.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/color.h new file mode 100644 index 00000000000..9dc21864eb9 --- /dev/null +++ b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/color.h @@ -0,0 +1,97 @@ +/** + * @file color.h + * + * Color types and utilities. + */ + +#ifndef GHOSTTY_VT_COLOR_H +#define GHOSTTY_VT_COLOR_H + +#include +#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 + */ +GHOSTTY_API 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-vt/include/ghostty/vt/device.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/device.h new file mode 100644 index 00000000000..0a1567280b8 --- /dev/null +++ b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/device.h @@ -0,0 +1,151 @@ +/** + * @file device.h + * + * Device types used by the terminal for device status and device attribute + * queries. + */ + +#ifndef GHOSTTY_VT_DEVICE_H +#define GHOSTTY_VT_DEVICE_H + +#include +#include + +/* DA1 conformance levels (Pp parameter). */ +#define GHOSTTY_DA_CONFORMANCE_VT100 1 +#define GHOSTTY_DA_CONFORMANCE_VT101 1 +#define GHOSTTY_DA_CONFORMANCE_VT102 6 +#define GHOSTTY_DA_CONFORMANCE_VT125 12 +#define GHOSTTY_DA_CONFORMANCE_VT131 7 +#define GHOSTTY_DA_CONFORMANCE_VT132 4 +#define GHOSTTY_DA_CONFORMANCE_VT220 62 +#define GHOSTTY_DA_CONFORMANCE_VT240 62 +#define GHOSTTY_DA_CONFORMANCE_VT320 63 +#define GHOSTTY_DA_CONFORMANCE_VT340 63 +#define GHOSTTY_DA_CONFORMANCE_VT420 64 +#define GHOSTTY_DA_CONFORMANCE_VT510 65 +#define GHOSTTY_DA_CONFORMANCE_VT520 65 +#define GHOSTTY_DA_CONFORMANCE_VT525 65 +#define GHOSTTY_DA_CONFORMANCE_LEVEL_2 62 +#define GHOSTTY_DA_CONFORMANCE_LEVEL_3 63 +#define GHOSTTY_DA_CONFORMANCE_LEVEL_4 64 +#define GHOSTTY_DA_CONFORMANCE_LEVEL_5 65 + +/* DA1 feature codes (Ps parameters). */ +#define GHOSTTY_DA_FEATURE_COLUMNS_132 1 +#define GHOSTTY_DA_FEATURE_PRINTER 2 +#define GHOSTTY_DA_FEATURE_REGIS 3 +#define GHOSTTY_DA_FEATURE_SIXEL 4 +#define GHOSTTY_DA_FEATURE_SELECTIVE_ERASE 6 +#define GHOSTTY_DA_FEATURE_USER_DEFINED_KEYS 8 +#define GHOSTTY_DA_FEATURE_NATIONAL_REPLACEMENT 9 +#define GHOSTTY_DA_FEATURE_TECHNICAL_CHARACTERS 15 +#define GHOSTTY_DA_FEATURE_LOCATOR 16 +#define GHOSTTY_DA_FEATURE_TERMINAL_STATE 17 +#define GHOSTTY_DA_FEATURE_WINDOWING 18 +#define GHOSTTY_DA_FEATURE_HORIZONTAL_SCROLLING 21 +#define GHOSTTY_DA_FEATURE_ANSI_COLOR 22 +#define GHOSTTY_DA_FEATURE_RECTANGULAR_EDITING 28 +#define GHOSTTY_DA_FEATURE_ANSI_TEXT_LOCATOR 29 +#define GHOSTTY_DA_FEATURE_CLIPBOARD 52 + +/* DA2 device type identifiers (Pp parameter). */ +#define GHOSTTY_DA_DEVICE_TYPE_VT100 0 +#define GHOSTTY_DA_DEVICE_TYPE_VT220 1 +#define GHOSTTY_DA_DEVICE_TYPE_VT240 2 +#define GHOSTTY_DA_DEVICE_TYPE_VT330 18 +#define GHOSTTY_DA_DEVICE_TYPE_VT340 19 +#define GHOSTTY_DA_DEVICE_TYPE_VT320 24 +#define GHOSTTY_DA_DEVICE_TYPE_VT382 32 +#define GHOSTTY_DA_DEVICE_TYPE_VT420 41 +#define GHOSTTY_DA_DEVICE_TYPE_VT510 61 +#define GHOSTTY_DA_DEVICE_TYPE_VT520 64 +#define GHOSTTY_DA_DEVICE_TYPE_VT525 65 + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * Color scheme reported in response to a CSI ? 996 n query. + * + * @ingroup terminal + */ +typedef enum GHOSTTY_ENUM_TYPED { + GHOSTTY_COLOR_SCHEME_LIGHT = 0, + GHOSTTY_COLOR_SCHEME_DARK = 1, + GHOSTTY_COLOR_SCHEME_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyColorScheme; + +/** + * Primary device attributes (DA1) response data. + * + * Returned as part of GhosttyDeviceAttributes in response to a CSI c query. + * The conformance_level is the Pp parameter and features contains the Ps + * feature codes. + * + * @ingroup terminal + */ +typedef struct { + /** Conformance level (Pp parameter). E.g. 62 for VT220. */ + uint16_t conformance_level; + + /** DA1 feature codes. Only the first num_features entries are valid. */ + uint16_t features[64]; + + /** Number of valid entries in the features array. */ + size_t num_features; +} GhosttyDeviceAttributesPrimary; + +/** + * Secondary device attributes (DA2) response data. + * + * Returned as part of GhosttyDeviceAttributes in response to a CSI > c query. + * Response format: CSI > Pp ; Pv ; Pc c + * + * @ingroup terminal + */ +typedef struct { + /** Terminal type identifier (Pp). E.g. 1 for VT220. */ + uint16_t device_type; + + /** Firmware/patch version number (Pv). */ + uint16_t firmware_version; + + /** ROM cartridge registration number (Pc). Always 0 for emulators. */ + uint16_t rom_cartridge; +} GhosttyDeviceAttributesSecondary; + +/** + * Tertiary device attributes (DA3) response data. + * + * Returned as part of GhosttyDeviceAttributes in response to a CSI = c query. + * Response format: DCS ! | D...D ST (DECRPTUI). + * + * @ingroup terminal + */ +typedef struct { + /** Unit ID encoded as 8 uppercase hex digits in the response. */ + uint32_t unit_id; +} GhosttyDeviceAttributesTertiary; + +/** + * Device attributes response data for all three DA levels. + * + * Filled by the device_attributes callback in response to CSI c, + * CSI > c, or CSI = c queries. The terminal uses whichever sub-struct + * matches the request type. + * + * @ingroup terminal + */ +typedef struct { + GhosttyDeviceAttributesPrimary primary; + GhosttyDeviceAttributesSecondary secondary; + GhosttyDeviceAttributesTertiary tertiary; +} GhosttyDeviceAttributes; + +#ifdef __cplusplus +} +#endif + +#endif /* GHOSTTY_VT_DEVICE_H */ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/focus.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/focus.h new file mode 100644 index 00000000000..b9940f79247 --- /dev/null +++ b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/focus.h @@ -0,0 +1,76 @@ +/** + * @file focus.h + * + * Focus encoding - encode focus in/out events into terminal escape sequences. + */ + +#ifndef GHOSTTY_VT_FOCUS_H +#define GHOSTTY_VT_FOCUS_H + +/** @defgroup focus Focus Encoding + * + * Utilities for encoding focus gained/lost events into terminal escape + * sequences (CSI I / CSI O) for focus reporting mode (mode 1004). + * + * ## Basic Usage + * + * Use ghostty_focus_encode() to encode a focus event into a caller-provided + * buffer. If the buffer is too small, the function returns + * GHOSTTY_OUT_OF_SPACE and sets the required size in the output parameter. + * + * ## Example + * + * @snippet c-vt-encode-focus/src/main.c focus-encode + * + * @{ + */ + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * Focus event types for focus reporting mode (mode 1004). + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Terminal window gained focus */ + GHOSTTY_FOCUS_GAINED = 0, + /** Terminal window lost focus */ + GHOSTTY_FOCUS_LOST = 1, + GHOSTTY_FOCUS_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyFocusEvent; + +/** + * Encode a focus event into a terminal escape sequence. + * + * Encodes a focus gained (CSI I) or focus lost (CSI O) report into the + * provided buffer. + * + * If the buffer is too small, the function returns GHOSTTY_OUT_OF_SPACE + * and writes the required buffer size to @p out_written. The caller can + * then retry with a sufficiently sized buffer. + * + * @param event The focus event to encode + * @param buf Output buffer to write the encoded sequence into (may be NULL) + * @param buf_len Size of the output buffer in bytes + * @param[out] out_written On success, the number of bytes written. On + * GHOSTTY_OUT_OF_SPACE, the required buffer size. + * @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_SPACE if the buffer + * is too small + */ +GHOSTTY_API GhosttyResult ghostty_focus_encode( + GhosttyFocusEvent event, + char* buf, + size_t buf_len, + size_t* out_written); + +#ifdef __cplusplus +} +#endif + +/** @} */ + +#endif /* GHOSTTY_VT_FOCUS_H */ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/formatter.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/formatter.h new file mode 100644 index 00000000000..5cdcd11a3a7 --- /dev/null +++ b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/formatter.h @@ -0,0 +1,207 @@ +/** + * @file formatter.h + * + * Format terminal content as plain text, VT sequences, or HTML. + */ + +#ifndef GHOSTTY_VT_FORMATTER_H +#define GHOSTTY_VT_FORMATTER_H + +#include +#include +#include +#include +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** @defgroup formatter Formatter + * + * Format terminal content as plain text, VT sequences, or HTML. + * + * A formatter captures a reference to a terminal and formatting options. + * It can be used repeatedly to produce output that reflects the current + * terminal state at the time of each format call. + * + * The terminal must outlive the formatter. + * + * @{ + */ + +/** + * Extra screen state to include in styled output. + * + * @ingroup formatter + */ +typedef struct { + /** Size of this struct in bytes. Must be set to sizeof(GhosttyFormatterScreenExtra). */ + size_t size; + + /** Emit cursor position using CUP (CSI H). */ + bool cursor; + + /** Emit current SGR style state based on the cursor's active style_id. */ + bool style; + + /** Emit current hyperlink state using OSC 8 sequences. */ + bool hyperlink; + + /** Emit character protection mode using DECSCA. */ + bool protection; + + /** Emit Kitty keyboard protocol state using CSI > u and CSI = sequences. */ + bool kitty_keyboard; + + /** Emit character set designations and invocations. */ + bool charsets; +} GhosttyFormatterScreenExtra; + +/** + * Extra terminal state to include in styled output. + * + * @ingroup formatter + */ +typedef struct { + /** Size of this struct in bytes. Must be set to sizeof(GhosttyFormatterTerminalExtra). */ + size_t size; + + /** Emit the palette using OSC 4 sequences. */ + bool palette; + + /** Emit terminal modes that differ from their defaults using CSI h/l. */ + bool modes; + + /** Emit scrolling region state using DECSTBM and DECSLRM sequences. */ + bool scrolling_region; + + /** Emit tabstop positions by clearing all tabs and setting each one. */ + bool tabstops; + + /** Emit the present working directory using OSC 7. */ + bool pwd; + + /** Emit keyboard modes such as ModifyOtherKeys. */ + bool keyboard; + + /** Screen-level extras. */ + GhosttyFormatterScreenExtra screen; +} GhosttyFormatterTerminalExtra; + +/** + * Options for creating a terminal formatter. + * + * @ingroup formatter + */ +typedef struct { + /** Size of this struct in bytes. Must be set to sizeof(GhosttyFormatterTerminalOptions). */ + size_t size; + + /** Output format to emit. */ + GhosttyFormatterFormat emit; + + /** Whether to unwrap soft-wrapped lines. */ + bool unwrap; + + /** Whether to trim trailing whitespace on non-blank lines. */ + bool trim; + + /** Extra terminal state to include in styled output. */ + GhosttyFormatterTerminalExtra extra; + + /** Optional selection to restrict output to a range. + * If NULL, the entire screen is formatted. */ + const GhosttySelection *selection; +} GhosttyFormatterTerminalOptions; + +/** + * Create a formatter for a terminal's active screen. + * + * The terminal must outlive the formatter. The formatter stores a borrowed + * reference to the terminal and reads its current state on each format call. + * + * @param allocator Pointer to allocator, or NULL to use the default allocator + * @param formatter Pointer to store the created formatter handle + * @param terminal The terminal to format (must not be NULL) + * @param options Formatting options + * @return GHOSTTY_SUCCESS on success, or an error code on failure + * + * @ingroup formatter + */ +GHOSTTY_API GhosttyResult ghostty_formatter_terminal_new( + const GhosttyAllocator* allocator, + GhosttyFormatter* formatter, + GhosttyTerminal terminal, + GhosttyFormatterTerminalOptions options); + +/** + * Run the formatter and produce output into the caller-provided buffer. + * + * Each call formats the current terminal state. Pass NULL for buf to + * query the required buffer size without writing any output; in that case + * out_written receives the required size and the return value is + * GHOSTTY_OUT_OF_SPACE. + * + * If the buffer is too small, returns GHOSTTY_OUT_OF_SPACE and sets + * out_written to the required size. The caller can then retry with a + * larger buffer. + * + * @param formatter The formatter handle (must not be NULL) + * @param buf Pointer to the output buffer, or NULL to query size + * @param buf_len Length of the output buffer in bytes + * @param out_written Pointer to receive the number of bytes written, + * or the required size on failure + * @return GHOSTTY_SUCCESS on success, or an error code on failure + * + * @ingroup formatter + */ +GHOSTTY_API GhosttyResult ghostty_formatter_format_buf(GhosttyFormatter formatter, + uint8_t* buf, + size_t buf_len, + size_t* out_written); + +/** + * Run the formatter and return an allocated buffer with the output. + * + * Each call formats the current terminal state. The buffer is allocated + * using the provided allocator (or the default allocator if NULL). + * The caller is responsible for freeing the returned buffer with + * ghostty_free(), passing the same allocator (or NULL for the default) + * that was used for the allocation. + * + * @param formatter The formatter handle (must not be NULL) + * @param allocator Pointer to allocator, or NULL to use the default allocator + * @param out_ptr Pointer to receive the allocated buffer + * @param out_len Pointer to receive the length of the output in bytes + * @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_MEMORY on allocation + * failure + * + * @ingroup formatter + */ +GHOSTTY_API GhosttyResult ghostty_formatter_format_alloc(GhosttyFormatter formatter, + const GhosttyAllocator* allocator, + uint8_t** out_ptr, + size_t* out_len); + +/** + * Free a formatter instance. + * + * Releases all resources associated with the formatter. After this call, + * the formatter handle becomes invalid. + * + * @param formatter The formatter handle to free (may be NULL) + * + * @ingroup formatter + */ +GHOSTTY_API void ghostty_formatter_free(GhosttyFormatter formatter); + +/** @} */ + +#ifdef __cplusplus +} +#endif + +#endif /* GHOSTTY_VT_FORMATTER_H */ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/grid_ref.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/grid_ref.h new file mode 100644 index 00000000000..c43791dc238 --- /dev/null +++ b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/grid_ref.h @@ -0,0 +1,212 @@ +/** + * @file grid_ref.h + * + * Terminal grid reference type for referencing a resolved position in the + * terminal grid. + */ + +#ifndef GHOSTTY_VT_GRID_REF_H +#define GHOSTTY_VT_GRID_REF_H + +#include +#include +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** @defgroup grid_ref Grid Reference + * + * A grid reference is a reference to a specific cell position in the + * terminal. Obtain a grid reference from `ghostty_terminal_grid_ref` + * for untracked or `ghostty_terminal_grid_ref_track` for tracked. Untracked + * vs tracked is explained next. + * + * Important: The grid reference APIs are not meant to be used as the core of a render + * loop. They are not built to sustain the framerates needed for rendering large + * screens. Use the render state API for that. + * + * ## Untracked vs Tracked References + * + * ### Untracked Reference + * + * An untracked grid reference is a value type that snapshots a specific + * cell. It is only valid until the next update to the terminal instance. + * There is no guarantee that it will remain valid after any operation, + * even if a seemingly unrelated part of the grid is changed. These are meant + * to be read and have their values cached immediately after obtaining it. + * + * An untracked grid reference has a performance cost in its initial lookup, + * but doesn't affect the ongoing performance of the terminal in any way, + * since it is a one-time snapshot. + * + * ### Tracked Reference + * + * A tracked grid reference follows its cell across normal screen operations. + * For example scrolling, scrollback pruning, resize/reflow, and other + * terminal mutations update the tracked reference automatically. + * + * A tracked reference can still lose its original semantic location. This can + * happen when the underlying grid is reset, pruned, or otherwise discarded in a + * way that cannot be mapped to a meaningful new cell. In that state, + * ghostty_tracked_grid_ref_has_value() returns false and + * ghostty_tracked_grid_ref_snapshot() / ghostty_tracked_grid_ref_point() return + * GHOSTTY_NO_VALUE. The handle remains valid, and callers may move it to a new + * point with ghostty_tracked_grid_ref_set(). + * + * To read cell data from a tracked reference, first snapshot it with + * ghostty_tracked_grid_ref_snapshot(). The returned `GhosttyGridRef` is again + * an untracked reference and follows the same short lifetime rules as any other + * untracked grid reference. + * + * A tracked reference belongs to the terminal screen/page-list that was active + * when it was created or last set. Converting it to a point uses that owning + * screen/page-list, even if the terminal has since switched between primary and + * alternate screens. Calling ghostty_tracked_grid_ref_set() resolves the new + * point against the terminal's currently active screen/page-list and may move + * the tracked reference between screens. + * + * Tracked references are owned by the caller and must be freed with + * ghostty_tracked_grid_ref_free(). If the terminal that created a tracked + * reference is freed first, the handle remains valid only for tracked-grid-ref + * APIs: it reports no value and can still be freed. + * + * Each tracked reference adds bookkeeping to terminal mutations. Use them + * sparingly for long-lived anchors such as selections, search state, marks, + * or application-side bookmarks. + * + * ## Lifetime + * + * An untracked reference is a snapshot. It doesn't need to be freed. + * The safety of accessing the value is documented explicitly above: it + * is only safe to access any data until the next terminal mutating + * operation (including free). + * + * A tracked reference is allocated and must be freed when it is no + * longer needed. A tracked reference may outlive the terminal that created it; + * after terminal free, it reports no value and can still be freed. + * + * ## Examples + * + * @snippet c-vt-grid-traverse/src/main.c grid-ref-traverse + * @snippet c-vt-grid-ref-tracked/src/main.c grid-ref-tracked + * + * @{ + */ + +/** + * A resolved reference to a terminal cell position. + * + * This is a sized struct. Use GHOSTTY_INIT_SIZED() to initialize it. + * + * @ingroup grid_ref + */ +typedef struct { + size_t size; + void *node; + uint16_t x; + uint16_t y; +} GhosttyGridRef; + +/** + * Get the cell from a grid reference. + * + * @param ref Pointer to the grid reference + * @param[out] out_cell On success, set to the cell at the ref's position (may be NULL) + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the ref's + * node is NULL + * + * @ingroup grid_ref + */ +GHOSTTY_API GhosttyResult ghostty_grid_ref_cell(const GhosttyGridRef *ref, + GhosttyCell *out_cell); + +/** + * Get the row from a grid reference. + * + * @param ref Pointer to the grid reference + * @param[out] out_row On success, set to the row at the ref's position (may be NULL) + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the ref's + * node is NULL + * + * @ingroup grid_ref + */ +GHOSTTY_API GhosttyResult ghostty_grid_ref_row(const GhosttyGridRef *ref, + GhosttyRow *out_row); + +/** + * Get the grapheme cluster codepoints for the cell at the grid reference's + * position. + * + * Writes the full grapheme cluster (the cell's primary codepoint followed by + * any combining codepoints) into the provided buffer. If the cell has no text, + * out_len is set to 0 and GHOSTTY_SUCCESS is returned. + * + * If the buffer is too small (or NULL), the function returns + * GHOSTTY_OUT_OF_SPACE and writes the required number of codepoints to + * out_len. The caller can then retry with a sufficiently sized buffer. + * + * @param ref Pointer to the grid reference + * @param buf Output buffer of uint32_t codepoints (may be NULL) + * @param buf_len Number of uint32_t elements in the buffer + * @param[out] out_len On success, the number of codepoints written. On + * GHOSTTY_OUT_OF_SPACE, the required buffer size in codepoints. + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the ref's + * node is NULL, GHOSTTY_OUT_OF_SPACE if the buffer is too small + * + * @ingroup grid_ref + */ +GHOSTTY_API GhosttyResult ghostty_grid_ref_graphemes(const GhosttyGridRef *ref, + uint32_t *buf, + size_t buf_len, + size_t *out_len); + +/** + * Get the hyperlink URI for the cell at the grid reference's position. + * + * Writes the URI bytes into the provided buffer. If the cell has no + * hyperlink, out_len is set to 0 and GHOSTTY_SUCCESS is returned. + * + * If the buffer is too small (or NULL), the function returns + * GHOSTTY_OUT_OF_SPACE and writes the required number of bytes to + * out_len. The caller can then retry with a sufficiently sized buffer. + * + * @param ref Pointer to the grid reference + * @param buf Output buffer for the URI bytes (may be NULL) + * @param buf_len Size of the output buffer in bytes + * @param[out] out_len On success, the number of bytes written. On + * GHOSTTY_OUT_OF_SPACE, the required buffer size in bytes. + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the ref's + * node is NULL, GHOSTTY_OUT_OF_SPACE if the buffer is too small + * + * @ingroup grid_ref + */ +GHOSTTY_API GhosttyResult ghostty_grid_ref_hyperlink_uri( + const GhosttyGridRef *ref, + uint8_t *buf, + size_t buf_len, + size_t *out_len); + +/** + * Get the style of the cell at the grid reference's position. + * + * @param ref Pointer to the grid reference + * @param[out] out_style On success, set to the cell's style (may be NULL) + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the ref's + * node is NULL + * + * @ingroup grid_ref + */ +GHOSTTY_API GhosttyResult ghostty_grid_ref_style(const GhosttyGridRef *ref, + GhosttyStyle *out_style); + +/** @} */ + +#ifdef __cplusplus +} +#endif + +#endif /* GHOSTTY_VT_GRID_REF_H */ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/grid_ref_tracked.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/grid_ref_tracked.h new file mode 100644 index 00000000000..b56aefacdb6 --- /dev/null +++ b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/grid_ref_tracked.h @@ -0,0 +1,139 @@ +/** + * @file grid_ref_tracked.h + * + * Tracked terminal grid references. + */ + +#ifndef GHOSTTY_VT_GRID_REF_TRACKED_H +#define GHOSTTY_VT_GRID_REF_TRACKED_H + +#include +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * Tracked grid references are owned grid references that move with the + * terminal. See @ref grid_ref for the full overview of tracked and untracked + * grid reference behavior. + * + * @ingroup grid_ref + */ + +/** + * Free a tracked grid reference. + * + * Passing NULL is allowed and has no effect. A tracked reference may be freed + * after the terminal that created it is freed. + * + * @param ref Tracked grid reference to free. + * + * @ingroup grid_ref + */ +GHOSTTY_API void ghostty_tracked_grid_ref_free(GhosttyTrackedGridRef ref); + +/** + * Return whether a tracked grid reference currently has a meaningful value. + * + * If the terminal that created the tracked reference has been freed, this + * returns false. + * + * @param ref Tracked grid reference. + * @return true if the reference currently has a meaningful value. + * + * @ingroup grid_ref + */ +GHOSTTY_API bool ghostty_tracked_grid_ref_has_value( + GhosttyTrackedGridRef ref); + +/** + * Convert a tracked grid reference to a point in the requested coordinate + * space. + * + * This is the tracked equivalent of ghostty_terminal_point_from_grid_ref(). + * Unlike snapshotting, this does not expose an intermediate untracked + * GhosttyGridRef. + * + * A tracked reference is resolved against the terminal screen/page-list that + * currently owns the reference. If the terminal has switched between primary + * and alternate screens since the reference was created or last set, this may + * be different from the terminal's currently active screen. + * + * If the tracked reference no longer has a meaningful value, this returns + * GHOSTTY_NO_VALUE. GHOSTTY_NO_VALUE is also returned when the reference cannot + * be represented in the requested coordinate space, including after the + * terminal that created the tracked reference has been freed. + * + * @param ref Tracked grid reference. + * @param tag Coordinate space to convert into. + * @param[out] out_point On success, receives the coordinate. May be NULL. + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if ref is invalid, + * or GHOSTTY_NO_VALUE if there is no representable value. + * + * @ingroup grid_ref + */ +GHOSTTY_API GhosttyResult ghostty_tracked_grid_ref_point( + GhosttyTrackedGridRef ref, + GhosttyPointTag tag, + GhosttyPointCoordinate *out_point); + +/** + * Move an existing tracked grid reference to a new terminal point. + * + * On success, the tracked reference begins tracking the new point and any prior + * "no value" state is cleared. On GHOSTTY_OUT_OF_MEMORY, the original tracked + * reference is left unchanged. + * + * The terminal must be the same terminal that created the tracked reference. + * The point is resolved against the terminal screen/page-list that is active at + * the time this function is called. If the terminal has switched between + * primary and alternate screens, this may move the tracked reference from one + * screen/page-list to the other. + * + * @param ref Tracked grid reference. + * @param terminal Terminal instance that owns the reference. + * @param point New point to track. + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if ref, terminal, + * or point is invalid, or GHOSTTY_OUT_OF_MEMORY if allocation fails. + * + * @ingroup grid_ref + */ +GHOSTTY_API GhosttyResult ghostty_tracked_grid_ref_set( + GhosttyTrackedGridRef ref, + GhosttyTerminal terminal, + GhosttyPoint point); + +/** + * Snapshot a tracked grid reference into a regular GhosttyGridRef. + * + * The returned GhosttyGridRef is an untracked snapshot and has the same + * lifetime rules as ghostty_terminal_grid_ref(): it is only valid until the + * next terminal update. Snapshot immediately before calling + * ghostty_grid_ref_cell(), ghostty_grid_ref_row(), + * ghostty_grid_ref_graphemes(), ghostty_grid_ref_hyperlink_uri(), or + * ghostty_grid_ref_style(). + * + * If the tracked reference no longer has a meaningful value, this returns + * GHOSTTY_NO_VALUE. This includes references whose owning terminal has been + * freed. + * + * @param ref Tracked grid reference. + * @param[out] out_ref On success, receives an untracked snapshot. May be NULL. + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if ref is invalid, + * or GHOSTTY_NO_VALUE if the tracked location was discarded. + * + * @ingroup grid_ref + */ +GHOSTTY_API GhosttyResult ghostty_tracked_grid_ref_snapshot( + GhosttyTrackedGridRef ref, + GhosttyGridRef *out_ref); + +#ifdef __cplusplus +} +#endif + +#endif /* GHOSTTY_VT_GRID_REF_TRACKED_H */ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/key.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/key.h new file mode 100644 index 00000000000..61b95475357 --- /dev/null +++ b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/key.h @@ -0,0 +1,73 @@ +/** + * @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() + * or ghostty_key_encoder_setopt_from_terminal() if you have a + * GhosttyTerminal. + * 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 + * + * For a complete working example, see example/c-vt-encode-key in the + * repository. + * + * ## Example + * + * @snippet c-vt-encode-key/src/main.c key-encode + * + * ## Example: Encoding with Terminal State + * + * When you have a GhosttyTerminal, you can sync its modes (cursor key + * application, Kitty flags, etc.) into the encoder automatically: + * + * @code{.c} + * // Create a terminal and feed it some VT data that changes modes + * GhosttyTerminal terminal; + * ghostty_terminal_new(NULL, &terminal, + * (GhosttyTerminalOptions){.cols = 80, .rows = 24, .max_scrollback = 0}); + * + * // Application might write data that enables Kitty keyboard protocol, etc. + * ghostty_terminal_vt_write(terminal, vt_data, vt_len); + * + * // Create an encoder and sync its options from the terminal + * GhosttyKeyEncoder encoder; + * ghostty_key_encoder_new(NULL, &encoder); + * ghostty_key_encoder_setopt_from_terminal(encoder, terminal); + * + * // Encode a key event using the terminal-derived options + * char buf[128]; + * size_t written = 0; + * ghostty_key_encoder_encode(encoder, event, buf, sizeof(buf), &written); + * + * ghostty_key_encoder_free(encoder); + * ghostty_terminal_free(terminal); + * @endcode + * + * @{ + */ + +#include +#include + +/** @} */ + +#endif /* GHOSTTY_VT_KEY_H */ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/key/encoder.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/key/encoder.h new file mode 100644 index 00000000000..3aeec6597b1 --- /dev/null +++ b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/key/encoder.h @@ -0,0 +1,255 @@ +/** + * @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 +#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 GhosttyKeyEncoderImpl *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 GHOSTTY_ENUM_TYPED { + /** 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, + GHOSTTY_OPTION_AS_ALT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} 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 GHOSTTY_ENUM_TYPED { + /** 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, + + /** Backarrow key mode (value: bool) + * See https://vt100.net/dec/ek-vt3xx-tp-002.pdf page 170 + * If `false` (the default), `backspace` emits 0x7f + * If `true`, `backspace` emits 0x08 + */ + GHOSTTY_KEY_ENCODER_OPT_BACKARROW_KEY_MODE = 7, + + GHOSTTY_KEY_ENCODER_OPT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} 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 + */ +GHOSTTY_API 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 + */ +GHOSTTY_API 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). + * + * If you are using a terminal instance, you can set the key encoding + * options based on the active terminal state (e.g. legacy vs Kitty mode + * and associated flags) with ghostty_key_encoder_setopt_from_terminal(). + * + * 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 + */ +GHOSTTY_API void ghostty_key_encoder_setopt(GhosttyKeyEncoder encoder, GhosttyKeyEncoderOption option, const void *value); + +/** + * Set encoder options from a terminal's current state. + * + * Reads the terminal's current modes and flags and applies them to the + * encoder's options. This sets cursor key application mode, keypad mode, + * alt escape prefix, modifyOtherKeys state, and Kitty keyboard protocol + * flags from the terminal state. + * + * Note that the `macos_option_as_alt` option cannot be determined from + * terminal state and is reset to `GHOSTTY_OPTION_AS_ALT_FALSE` by this + * call. Use ghostty_key_encoder_setopt() to set it afterward if needed. + * + * @param encoder The encoder handle, must not be NULL + * @param terminal The terminal handle, must not be NULL + * + * @ingroup key + */ +GHOSTTY_API void ghostty_key_encoder_setopt_from_terminal(GhosttyKeyEncoder encoder, GhosttyTerminal terminal); + +/** + * 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_SPACE + * 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_SPACE 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_SPACE) + * size_t required = 0; + * GhosttyResult result = ghostty_key_encoder_encode(encoder, event, NULL, 0, &required); + * assert(result == GHOSTTY_OUT_OF_SPACE); + * + * // 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_SPACE) { + * // 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 + */ +GHOSTTY_API 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-vt/include/ghostty/vt/key/event.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/key/event.h new file mode 100644 index 00000000000..eba433c6a55 --- /dev/null +++ b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/key/event.h @@ -0,0 +1,482 @@ +/** + * @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 GhosttyKeyEventImpl *GhosttyKeyEvent; + +/** + * Keyboard input event types. + * + * @ingroup key + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** 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, + GHOSTTY_KEY_ACTION_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} 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_ENUM_TYPED { + 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, + GHOSTTY_KEY_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} 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 + */ +GHOSTTY_API 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 + */ +GHOSTTY_API 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 + */ +GHOSTTY_API 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 + */ +GHOSTTY_API 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 + */ +GHOSTTY_API 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 + */ +GHOSTTY_API 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 + */ +GHOSTTY_API 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 + */ +GHOSTTY_API 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 + */ +GHOSTTY_API 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 + */ +GHOSTTY_API 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 + */ +GHOSTTY_API 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 + */ +GHOSTTY_API bool ghostty_key_event_get_composing(GhosttyKeyEvent event); + +/** + * Set the UTF-8 text generated by the key for the current keyboard layout. + * + * Must contain the unmodified character before any Ctrl/Meta transformations. + * The encoder derives modifier sequences from the logical key and mods + * bitmask, not from this text. Do not pass C0 control characters + * (U+0000-U+001F, U+007F) or platform function key codes (e.g. macOS PUA + * U+F700-U+F8FF); pass NULL instead and let the encoder use the logical key. + * + * 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 + */ +GHOSTTY_API 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 + */ +GHOSTTY_API 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 + */ +GHOSTTY_API 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 + */ +GHOSTTY_API 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-vt/include/ghostty/vt/kitty_graphics.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/kitty_graphics.h new file mode 100644 index 00000000000..9bace3a3ccf --- /dev/null +++ b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/kitty_graphics.h @@ -0,0 +1,775 @@ +/** + * @file kitty_graphics.h + * + * Kitty graphics protocol + * + * See @ref kitty_graphics for a full usage guide. + */ + +#ifndef GHOSTTY_VT_KITTY_GRAPHICS_H +#define GHOSTTY_VT_KITTY_GRAPHICS_H + +#include +#include +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** @defgroup kitty_graphics Kitty Graphics + * + * API for inspecting images and placements stored via the + * [Kitty graphics protocol](https://sw.kovidgoyal.net/kitty/graphics-protocol/). + * + * The central object is @ref GhosttyKittyGraphics, an opaque handle to + * the image storage associated with a terminal's active screen. From it + * you can iterate over placements and look up individual images. + * + * ## Obtaining a KittyGraphics Handle + * + * A @ref GhosttyKittyGraphics handle is obtained from a terminal via + * ghostty_terminal_get() with @ref GHOSTTY_TERMINAL_DATA_KITTY_GRAPHICS. + * The handle is borrowed from the terminal and remains valid until the + * next mutating terminal call (e.g. ghostty_terminal_vt_write() or + * ghostty_terminal_reset()). + * + * Before images can be stored, Kitty graphics must be enabled on the + * terminal by setting a non-zero storage limit with + * @ref GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_STORAGE_LIMIT, and a PNG + * decoder callback must be installed via ghostty_sys_set() with + * @ref GHOSTTY_SYS_OPT_DECODE_PNG. + * + * @snippet c-vt-kitty-graphics/src/main.c kitty-graphics-decode-png + * + * ## Iterating Placements + * + * Placements are inspected through a @ref GhosttyKittyGraphicsPlacementIterator. + * The typical workflow is: + * + * 1. Create an iterator with ghostty_kitty_graphics_placement_iterator_new(). + * 2. Populate it from the storage with ghostty_kitty_graphics_get() using + * @ref GHOSTTY_KITTY_GRAPHICS_DATA_PLACEMENT_ITERATOR. + * 3. Optionally filter by z-layer with + * ghostty_kitty_graphics_placement_iterator_set(). + * 4. Advance with ghostty_kitty_graphics_placement_next() and read + * per-placement data with ghostty_kitty_graphics_placement_get(). + * 5. For each placement, look up its image with + * ghostty_kitty_graphics_image() to access pixel data and dimensions. + * 6. Free the iterator with ghostty_kitty_graphics_placement_iterator_free(). + * + * ## Looking Up Images + * + * Given an image ID (obtained from a placement via + * @ref GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_IMAGE_ID), call + * ghostty_kitty_graphics_image() to get a @ref GhosttyKittyGraphicsImage + * handle. From this handle, ghostty_kitty_graphics_image_get() provides + * the image dimensions, pixel format, compression, and a borrowed pointer + * to the raw pixel data. + * + * ## Rendering Helpers + * + * Several functions assist with rendering a placement: + * + * - ghostty_kitty_graphics_placement_pixel_size() — rendered pixel + * dimensions accounting for source rect and aspect ratio. + * - ghostty_kitty_graphics_placement_grid_size() — number of grid + * columns and rows the placement occupies. + * - ghostty_kitty_graphics_placement_viewport_pos() — viewport-relative + * grid position (may be negative for partially scrolled placements). + * - ghostty_kitty_graphics_placement_source_rect() — resolved source + * rectangle in pixels, clamped to image bounds. + * - ghostty_kitty_graphics_placement_rect() — bounding rectangle as a + * @ref GhosttySelection. + * + * ## Lifetime and Thread Safety + * + * All handles borrowed from the terminal (GhosttyKittyGraphics, + * GhosttyKittyGraphicsImage) are invalidated by any mutating terminal + * call. The placement iterator is independently owned and must be freed + * by the caller, but the data it yields is only valid while the + * underlying terminal is not mutated. + * + * ## Example + * + * The following example creates a terminal, sends a Kitty graphics + * image, then iterates placements and prints image metadata: + * + * @snippet c-vt-kitty-graphics/src/main.c kitty-graphics-main + * + * @{ + */ + +/** + * Queryable data kinds for ghostty_kitty_graphics_get(). + * + * @ingroup kitty_graphics + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Invalid / sentinel value. */ + GHOSTTY_KITTY_GRAPHICS_DATA_INVALID = 0, + + /** + * Populate a pre-allocated placement iterator with placement data from + * the storage. Iterator data is only valid as long as the underlying + * terminal is not mutated. + * + * Output type: GhosttyKittyGraphicsPlacementIterator * + */ + GHOSTTY_KITTY_GRAPHICS_DATA_PLACEMENT_ITERATOR = 1, + GHOSTTY_KITTY_GRAPHICS_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyKittyGraphicsData; + +/** + * Queryable data kinds for ghostty_kitty_graphics_placement_get(). + * + * @ingroup kitty_graphics + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Invalid / sentinel value. */ + GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_INVALID = 0, + + /** + * The image ID this placement belongs to. + * + * Output type: uint32_t * + */ + GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_IMAGE_ID = 1, + + /** + * The placement ID. + * + * Output type: uint32_t * + */ + GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_PLACEMENT_ID = 2, + + /** + * Whether this is a virtual placement (unicode placeholder). + * + * Output type: bool * + */ + GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_IS_VIRTUAL = 3, + + /** + * Pixel offset from the left edge of the cell. + * + * Output type: uint32_t * + */ + GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_X_OFFSET = 4, + + /** + * Pixel offset from the top edge of the cell. + * + * Output type: uint32_t * + */ + GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_Y_OFFSET = 5, + + /** + * Source rectangle x origin in pixels. + * + * Output type: uint32_t * + */ + GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_SOURCE_X = 6, + + /** + * Source rectangle y origin in pixels. + * + * Output type: uint32_t * + */ + GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_SOURCE_Y = 7, + + /** + * Source rectangle width in pixels (0 = full image width). + * + * Output type: uint32_t * + */ + GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_SOURCE_WIDTH = 8, + + /** + * Source rectangle height in pixels (0 = full image height). + * + * Output type: uint32_t * + */ + GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_SOURCE_HEIGHT = 9, + + /** + * Number of columns this placement occupies. + * + * Output type: uint32_t * + */ + GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_COLUMNS = 10, + + /** + * Number of rows this placement occupies. + * + * Output type: uint32_t * + */ + GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_ROWS = 11, + + /** + * Z-index for this placement. + * + * Output type: int32_t * + */ + GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_Z = 12, + + GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyKittyGraphicsPlacementData; + +/** + * Z-layer classification for kitty graphics placements. + * + * Based on the kitty protocol z-index conventions: + * - BELOW_BG: z < INT32_MIN/2 (drawn below cell background) + * - BELOW_TEXT: INT32_MIN/2 <= z < 0 (above background, below text) + * - ABOVE_TEXT: z >= 0 (above text) + * - ALL: no filtering (current behavior) + * + * @ingroup kitty_graphics + */ +typedef enum GHOSTTY_ENUM_TYPED { + GHOSTTY_KITTY_PLACEMENT_LAYER_ALL = 0, + GHOSTTY_KITTY_PLACEMENT_LAYER_BELOW_BG = 1, + GHOSTTY_KITTY_PLACEMENT_LAYER_BELOW_TEXT = 2, + GHOSTTY_KITTY_PLACEMENT_LAYER_ABOVE_TEXT = 3, + GHOSTTY_KITTY_PLACEMENT_LAYER_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyKittyPlacementLayer; + +/** + * Settable options for ghostty_kitty_graphics_placement_iterator_set(). + * + * @ingroup kitty_graphics + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** + * Set the z-layer filter for the iterator. + * + * Input type: GhosttyKittyPlacementLayer * + */ + GHOSTTY_KITTY_GRAPHICS_PLACEMENT_ITERATOR_OPTION_LAYER = 0, + GHOSTTY_KITTY_GRAPHICS_PLACEMENT_ITERATOR_OPTION_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyKittyGraphicsPlacementIteratorOption; + +/** + * Pixel format of a Kitty graphics image. + * + * @ingroup kitty_graphics + */ +typedef enum GHOSTTY_ENUM_TYPED { + GHOSTTY_KITTY_IMAGE_FORMAT_RGB = 0, + GHOSTTY_KITTY_IMAGE_FORMAT_RGBA = 1, + GHOSTTY_KITTY_IMAGE_FORMAT_PNG = 2, + GHOSTTY_KITTY_IMAGE_FORMAT_GRAY_ALPHA = 3, + GHOSTTY_KITTY_IMAGE_FORMAT_GRAY = 4, + GHOSTTY_KITTY_IMAGE_FORMAT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyKittyImageFormat; + +/** + * Compression of a Kitty graphics image. + * + * @ingroup kitty_graphics + */ +typedef enum GHOSTTY_ENUM_TYPED { + GHOSTTY_KITTY_IMAGE_COMPRESSION_NONE = 0, + GHOSTTY_KITTY_IMAGE_COMPRESSION_ZLIB_DEFLATE = 1, + GHOSTTY_KITTY_IMAGE_COMPRESSION_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyKittyImageCompression; + +/** + * Queryable data kinds for ghostty_kitty_graphics_image_get(). + * + * @ingroup kitty_graphics + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Invalid / sentinel value. */ + GHOSTTY_KITTY_IMAGE_DATA_INVALID = 0, + + /** + * The image ID. + * + * Output type: uint32_t * + */ + GHOSTTY_KITTY_IMAGE_DATA_ID = 1, + + /** + * The image number. + * + * Output type: uint32_t * + */ + GHOSTTY_KITTY_IMAGE_DATA_NUMBER = 2, + + /** + * Image width in pixels. + * + * Output type: uint32_t * + */ + GHOSTTY_KITTY_IMAGE_DATA_WIDTH = 3, + + /** + * Image height in pixels. + * + * Output type: uint32_t * + */ + GHOSTTY_KITTY_IMAGE_DATA_HEIGHT = 4, + + /** + * Pixel format of the image. + * + * Output type: GhosttyKittyImageFormat * + */ + GHOSTTY_KITTY_IMAGE_DATA_FORMAT = 5, + + /** + * Compression of the image. + * + * Output type: GhosttyKittyImageCompression * + */ + GHOSTTY_KITTY_IMAGE_DATA_COMPRESSION = 6, + + /** + * Borrowed pointer to the raw pixel data. Valid as long as the + * underlying terminal is not mutated. + * + * Output type: const uint8_t ** + */ + GHOSTTY_KITTY_IMAGE_DATA_DATA_PTR = 7, + + /** + * Length of the raw pixel data in bytes. + * + * Output type: size_t * + */ + GHOSTTY_KITTY_IMAGE_DATA_DATA_LEN = 8, + + GHOSTTY_KITTY_IMAGE_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyKittyGraphicsImageData; + +/** + * Combined rendering geometry for a placement in a single sized struct. + * + * Combines the results of ghostty_kitty_graphics_placement_pixel_size(), + * ghostty_kitty_graphics_placement_grid_size(), + * ghostty_kitty_graphics_placement_viewport_pos(), and + * ghostty_kitty_graphics_placement_source_rect() into one call. This is + * an optimization over calling those four functions individually, + * particularly useful in environments with high per-call overhead such + * as FFI or Cgo. + * + * This struct uses the sized-struct ABI pattern. Initialize with + * GHOSTTY_INIT_SIZED(GhosttyKittyGraphicsPlacementRenderInfo) before calling + * ghostty_kitty_graphics_placement_render_info(). + * + * @ingroup kitty_graphics + */ +typedef struct { + /** Size of this struct in bytes. Must be set to sizeof(GhosttyKittyGraphicsPlacementRenderInfo). */ + size_t size; + /** Rendered width in pixels. */ + uint32_t pixel_width; + /** Rendered height in pixels. */ + uint32_t pixel_height; + /** Number of grid columns the placement occupies. */ + uint32_t grid_cols; + /** Number of grid rows the placement occupies. */ + uint32_t grid_rows; + /** Viewport-relative column (may be negative for partially visible placements). */ + int32_t viewport_col; + /** Viewport-relative row (may be negative for partially visible placements). */ + int32_t viewport_row; + /** False when the placement is fully off-screen or virtual. */ + bool viewport_visible; + /** Resolved source rectangle x origin in pixels. */ + uint32_t source_x; + /** Resolved source rectangle y origin in pixels. */ + uint32_t source_y; + /** Resolved source rectangle width in pixels. */ + uint32_t source_width; + /** Resolved source rectangle height in pixels. */ + uint32_t source_height; +} GhosttyKittyGraphicsPlacementRenderInfo; + +/** + * Get data from a kitty graphics storage instance. + * + * The output pointer must be of the appropriate type for the requested + * data kind. + * + * Returns GHOSTTY_NO_VALUE when Kitty graphics are disabled at build time. + * + * @param graphics The kitty graphics handle + * @param data The type of data to extract + * @param[out] out Pointer to store the extracted data + * @return GHOSTTY_SUCCESS on success + * + * @ingroup kitty_graphics + */ +GHOSTTY_API GhosttyResult ghostty_kitty_graphics_get( + GhosttyKittyGraphics graphics, + GhosttyKittyGraphicsData data, + void* out); + +/** + * Look up a Kitty graphics image by its image ID. + * + * Returns NULL if no image with the given ID exists or if Kitty graphics + * are disabled at build time. + * + * @param graphics The kitty graphics handle + * @param image_id The image ID to look up + * @return An opaque image handle, or NULL if not found + * + * @ingroup kitty_graphics + */ +GHOSTTY_API GhosttyKittyGraphicsImage ghostty_kitty_graphics_image( + GhosttyKittyGraphics graphics, + uint32_t image_id); + +/** + * Get data from a Kitty graphics image. + * + * The output pointer must be of the appropriate type for the requested + * data kind. + * + * @param image The image handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param data The data kind to query + * @param[out] out Pointer to receive the queried value + * @return GHOSTTY_SUCCESS on success + * + * @ingroup kitty_graphics + */ +GHOSTTY_API GhosttyResult ghostty_kitty_graphics_image_get( + GhosttyKittyGraphicsImage image, + GhosttyKittyGraphicsImageData data, + void* out); + +/** + * Get multiple data fields from a Kitty graphics image in a single call. + * + * This is an optimization over calling ghostty_kitty_graphics_image_get() + * repeatedly, particularly useful in environments with high per-call + * overhead such as FFI or Cgo. + * + * Each element in the keys array specifies a data kind, and the + * corresponding element in the values array receives the result. + * The type of each values[i] pointer must match the output type + * documented for keys[i]. + * + * Processing stops at the first error; on success out_written + * is set to count, on error it is set to the index of the + * failing key (i.e. the number of values successfully written). + * + * @param image The image handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param count Number of key/value pairs + * @param keys Array of data kinds to query + * @param values Array of output pointers (types must match each key's + * documented output type) + * @param[out] out_written On return, receives the number of values + * successfully written (may be NULL) + * @return GHOSTTY_SUCCESS if all queries succeed + * + * @ingroup kitty_graphics + */ +GHOSTTY_API GhosttyResult ghostty_kitty_graphics_image_get_multi( + GhosttyKittyGraphicsImage image, + size_t count, + const GhosttyKittyGraphicsImageData* keys, + void** values, + size_t* out_written); + +/** + * Create a new placement iterator instance. + * + * All fields except the allocator are left undefined until populated + * via ghostty_kitty_graphics_get() with + * GHOSTTY_KITTY_GRAPHICS_DATA_PLACEMENT_ITERATOR. + * + * @param allocator Pointer to allocator, or NULL to use the default allocator + * @param[out] out_iterator On success, receives the created iterator handle + * @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_MEMORY on allocation + * failure + * + * @ingroup kitty_graphics + */ +GHOSTTY_API GhosttyResult ghostty_kitty_graphics_placement_iterator_new( + const GhosttyAllocator* allocator, + GhosttyKittyGraphicsPlacementIterator* out_iterator); + +/** + * Free a placement iterator. + * + * @param iterator The iterator handle to free (may be NULL) + * + * @ingroup kitty_graphics + */ +GHOSTTY_API void ghostty_kitty_graphics_placement_iterator_free( + GhosttyKittyGraphicsPlacementIterator iterator); + +/** + * Set an option on a placement iterator. + * + * Use GHOSTTY_KITTY_GRAPHICS_PLACEMENT_ITERATOR_OPTION_LAYER with a + * GhosttyKittyPlacementLayer value to filter placements by z-layer. + * The filter is applied during iteration: ghostty_kitty_graphics_placement_next() + * will skip placements that do not match the configured layer. + * + * The default layer is GHOSTTY_KITTY_PLACEMENT_LAYER_ALL (no filtering). + * + * @param iterator The iterator handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param option The option to set + * @param value Pointer to the value (type depends on option; NULL returns + * GHOSTTY_INVALID_VALUE) + * @return GHOSTTY_SUCCESS on success + * + * @ingroup kitty_graphics + */ +GHOSTTY_API GhosttyResult ghostty_kitty_graphics_placement_iterator_set( + GhosttyKittyGraphicsPlacementIterator iterator, + GhosttyKittyGraphicsPlacementIteratorOption option, + const void* value); + +/** + * Advance the placement iterator to the next placement. + * + * If a layer filter has been set via + * ghostty_kitty_graphics_placement_iterator_set(), only placements + * matching that layer are returned. + * + * @param iterator The iterator handle (may be NULL) + * @return true if advanced to the next placement, false if at the end + * + * @ingroup kitty_graphics + */ +GHOSTTY_API bool ghostty_kitty_graphics_placement_next( + GhosttyKittyGraphicsPlacementIterator iterator); + +/** + * Get data from the current placement in a placement iterator. + * + * Call ghostty_kitty_graphics_placement_next() at least once before + * calling this function. + * + * @param iterator The iterator handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param data The data kind to query + * @param[out] out Pointer to receive the queried value + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the + * iterator is NULL or not positioned on a placement + * + * @ingroup kitty_graphics + */ +GHOSTTY_API GhosttyResult ghostty_kitty_graphics_placement_get( + GhosttyKittyGraphicsPlacementIterator iterator, + GhosttyKittyGraphicsPlacementData data, + void* out); + +/** + * Get multiple data fields from the current placement in a single call. + * + * This is an optimization over calling ghostty_kitty_graphics_placement_get() + * repeatedly, particularly useful in environments with high per-call + * overhead such as FFI or Cgo. + * + * Each element in the keys array specifies a data kind, and the + * corresponding element in the values array receives the result. + * The type of each values[i] pointer must match the output type + * documented for keys[i]. + * + * Processing stops at the first error; on success out_written + * is set to count, on error it is set to the index of the + * failing key (i.e. the number of values successfully written). + * + * @param iterator The iterator handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param count Number of key/value pairs + * @param keys Array of data kinds to query + * @param values Array of output pointers (types must match each key's + * documented output type) + * @param[out] out_written On return, receives the number of values + * successfully written (may be NULL) + * @return GHOSTTY_SUCCESS if all queries succeed + * + * @ingroup kitty_graphics + */ +GHOSTTY_API GhosttyResult ghostty_kitty_graphics_placement_get_multi( + GhosttyKittyGraphicsPlacementIterator iterator, + size_t count, + const GhosttyKittyGraphicsPlacementData* keys, + void** values, + size_t* out_written); + +/** + * Compute the grid rectangle occupied by the current placement. + * + * Uses the placement's pin, the image dimensions, and the terminal's + * cell/pixel geometry to calculate the bounding rectangle. Virtual + * placements (unicode placeholders) return GHOSTTY_NO_VALUE. + * + * @param terminal The terminal handle + * @param image The image handle for this placement's image + * @param iterator The placement iterator positioned on a placement + * @param[out] out_selection On success, receives the bounding rectangle + * as a selection with rectangle=true + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if any handle + * is NULL or the iterator is not positioned, GHOSTTY_NO_VALUE for + * virtual placements or when Kitty graphics are disabled + * + * @ingroup kitty_graphics + */ +GHOSTTY_API GhosttyResult ghostty_kitty_graphics_placement_rect( + GhosttyKittyGraphicsPlacementIterator iterator, + GhosttyKittyGraphicsImage image, + GhosttyTerminal terminal, + GhosttySelection* out_selection); + +/** + * Compute the rendered pixel size of the current placement. + * + * Takes into account the placement's source rectangle, specified + * columns/rows, and aspect ratio to calculate the final rendered + * pixel dimensions. + * + * @param iterator The placement iterator positioned on a placement + * @param image The image handle for this placement's image + * @param terminal The terminal handle + * @param[out] out_width On success, receives the width in pixels + * @param[out] out_height On success, receives the height in pixels + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if any handle + * is NULL or the iterator is not positioned, GHOSTTY_NO_VALUE when + * Kitty graphics are disabled + * + * @ingroup kitty_graphics + */ +GHOSTTY_API GhosttyResult ghostty_kitty_graphics_placement_pixel_size( + GhosttyKittyGraphicsPlacementIterator iterator, + GhosttyKittyGraphicsImage image, + GhosttyTerminal terminal, + uint32_t* out_width, + uint32_t* out_height); + +/** + * Compute the grid cell size of the current placement. + * + * Returns the number of columns and rows that the placement occupies + * in the terminal grid. If the placement specifies explicit columns + * and rows, those are returned directly; otherwise they are calculated + * from the pixel size and cell dimensions. + * + * @param iterator The placement iterator positioned on a placement + * @param image The image handle for this placement's image + * @param terminal The terminal handle + * @param[out] out_cols On success, receives the number of columns + * @param[out] out_rows On success, receives the number of rows + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if any handle + * is NULL or the iterator is not positioned, GHOSTTY_NO_VALUE when + * Kitty graphics are disabled + * + * @ingroup kitty_graphics + */ +GHOSTTY_API GhosttyResult ghostty_kitty_graphics_placement_grid_size( + GhosttyKittyGraphicsPlacementIterator iterator, + GhosttyKittyGraphicsImage image, + GhosttyTerminal terminal, + uint32_t* out_cols, + uint32_t* out_rows); + +/** + * Get the viewport-relative grid position of the current placement. + * + * Converts the placement's internal pin to viewport-relative column and + * row coordinates. The returned coordinates represent the top-left + * corner of the placement in the viewport's grid coordinate space. + * + * The row value can be negative when the placement's origin has + * scrolled above the top of the viewport. For example, a 4-row + * image that has scrolled up by 2 rows returns row=-2, meaning + * its top 2 rows are above the visible area but its bottom 2 rows + * are still on screen. Embedders should use these coordinates + * directly when computing the destination rectangle for rendering; + * the embedder is responsible for clipping the portion of the image + * that falls outside the viewport. + * + * Returns GHOSTTY_SUCCESS for any placement that is at least + * partially visible in the viewport. Returns GHOSTTY_NO_VALUE when + * the placement is completely outside the viewport (its bottom edge + * is above the viewport or its top edge is at or below the last + * viewport row), or when the placement is a virtual (unicode + * placeholder) placement. + * + * @param iterator The placement iterator positioned on a placement + * @param image The image handle for this placement's image + * @param terminal The terminal handle + * @param[out] out_col On success, receives the viewport-relative column + * @param[out] out_row On success, receives the viewport-relative row + * (may be negative for partially visible placements) + * @return GHOSTTY_SUCCESS on success, GHOSTTY_NO_VALUE if fully + * off-screen or virtual, GHOSTTY_INVALID_VALUE if any handle + * is NULL or the iterator is not positioned + * + * @ingroup kitty_graphics + */ +GHOSTTY_API GhosttyResult ghostty_kitty_graphics_placement_viewport_pos( + GhosttyKittyGraphicsPlacementIterator iterator, + GhosttyKittyGraphicsImage image, + GhosttyTerminal terminal, + int32_t* out_col, + int32_t* out_row); + +/** + * Get the resolved source rectangle for the current placement. + * + * Applies kitty protocol semantics: a width or height of 0 in the + * placement means "use the full image dimension", and the resulting + * rectangle is clamped to the actual image bounds. The returned + * values are in pixels and are ready to use for texture sampling. + * + * @param iterator The placement iterator positioned on a placement + * @param image The image handle for this placement's image + * @param[out] out_x Source rect x origin in pixels + * @param[out] out_y Source rect y origin in pixels + * @param[out] out_width Source rect width in pixels + * @param[out] out_height Source rect height in pixels + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if any + * handle is NULL or the iterator is not positioned + * + * @ingroup kitty_graphics + */ +GHOSTTY_API GhosttyResult ghostty_kitty_graphics_placement_source_rect( + GhosttyKittyGraphicsPlacementIterator iterator, + GhosttyKittyGraphicsImage image, + uint32_t* out_x, + uint32_t* out_y, + uint32_t* out_width, + uint32_t* out_height); + +/** + * Get all rendering geometry for a placement in a single call. + * + * Combines pixel size, grid size, viewport position, and source + * rectangle into one struct. Initialize with + * GHOSTTY_INIT_SIZED(GhosttyKittyGraphicsPlacementRenderInfo). + * + * When viewport_visible is false, the placement is fully off-screen + * or is a virtual placement; viewport_col and viewport_row may + * contain meaningless values in that case. + * + * @param iterator The iterator positioned on a placement + * @param image The image handle for this placement's image + * @param terminal The terminal handle + * @param[out] out_info Pointer to receive the rendering geometry + * @return GHOSTTY_SUCCESS on success + * + * @ingroup kitty_graphics + */ +GHOSTTY_API GhosttyResult ghostty_kitty_graphics_placement_render_info( + GhosttyKittyGraphicsPlacementIterator iterator, + GhosttyKittyGraphicsImage image, + GhosttyTerminal terminal, + GhosttyKittyGraphicsPlacementRenderInfo* out_info); + +/** @} */ + +#ifdef __cplusplus +} +#endif + +#endif /* GHOSTTY_VT_KITTY_GRAPHICS_H */ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/modes.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/modes.h new file mode 100644 index 00000000000..8e1fd91179e --- /dev/null +++ b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/modes.h @@ -0,0 +1,198 @@ +/** + * @file modes.h + * + * Terminal mode utilities - pack and unpack ANSI/DEC mode identifiers. + */ + +#ifndef GHOSTTY_VT_MODES_H +#define GHOSTTY_VT_MODES_H + +/** @defgroup modes Mode Utilities + * + * Utilities for working with terminal modes. A mode is a compact + * 16-bit representation of a terminal mode identifier that encodes both + * the numeric mode value (up to 15 bits) and whether the mode is an ANSI + * mode or a DEC private mode (?-prefixed). + * + * The packed layout (least-significant bit first) is: + * - Bits 0–14: mode value (u15) + * - Bit 15: ANSI flag (0 = DEC private mode, 1 = ANSI mode) + * + * ## Example + * + * @snippet c-vt-modes/src/main.c modes-pack-unpack + * + * ## DECRPM Report Encoding + * + * Use ghostty_mode_report_encode() to encode a DECRPM response into a + * caller-provided buffer: + * + * @snippet c-vt-modes/src/main.c modes-decrpm + * + * @{ + */ + +#include +#include +#include + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** @name ANSI Modes + * Modes for standard ANSI modes. + * @{ + */ +#define GHOSTTY_MODE_KAM (ghostty_mode_new(2, true)) /**< Keyboard action (disable keyboard) */ +#define GHOSTTY_MODE_INSERT (ghostty_mode_new(4, true)) /**< Insert mode */ +#define GHOSTTY_MODE_SRM (ghostty_mode_new(12, true)) /**< Send/receive mode */ +#define GHOSTTY_MODE_LINEFEED (ghostty_mode_new(20, true)) /**< Linefeed/new line mode */ +/** @} */ + +/** @name DEC Private Modes + * Modes for DEC private modes (?-prefixed). + * @{ + */ +#define GHOSTTY_MODE_DECCKM (ghostty_mode_new(1, false)) /**< Cursor keys */ +#define GHOSTTY_MODE_132_COLUMN (ghostty_mode_new(3, false)) /**< 132/80 column mode */ +#define GHOSTTY_MODE_SLOW_SCROLL (ghostty_mode_new(4, false)) /**< Slow scroll */ +#define GHOSTTY_MODE_REVERSE_COLORS (ghostty_mode_new(5, false)) /**< Reverse video */ +#define GHOSTTY_MODE_ORIGIN (ghostty_mode_new(6, false)) /**< Origin mode */ +#define GHOSTTY_MODE_WRAPAROUND (ghostty_mode_new(7, false)) /**< Auto-wrap mode */ +#define GHOSTTY_MODE_AUTOREPEAT (ghostty_mode_new(8, false)) /**< Auto-repeat keys */ +#define GHOSTTY_MODE_X10_MOUSE (ghostty_mode_new(9, false)) /**< X10 mouse reporting */ +#define GHOSTTY_MODE_CURSOR_BLINKING (ghostty_mode_new(12, false)) /**< Cursor blink */ +#define GHOSTTY_MODE_CURSOR_VISIBLE (ghostty_mode_new(25, false)) /**< Cursor visible (DECTCEM) */ +#define GHOSTTY_MODE_ENABLE_MODE_3 (ghostty_mode_new(40, false)) /**< Allow 132 column mode */ +#define GHOSTTY_MODE_REVERSE_WRAP (ghostty_mode_new(45, false)) /**< Reverse wrap */ +#define GHOSTTY_MODE_ALT_SCREEN_LEGACY (ghostty_mode_new(47, false)) /**< Alternate screen (legacy) */ +#define GHOSTTY_MODE_KEYPAD_KEYS (ghostty_mode_new(66, false)) /**< Application keypad */ +#define GHOSTTY_MODE_BACKARROW_KEY_MODE (ghostty_mode_new(67, false)) /**< Backarrow key mode (DECBKM) */ +#define GHOSTTY_MODE_LEFT_RIGHT_MARGIN (ghostty_mode_new(69, false)) /**< Left/right margin mode */ +#define GHOSTTY_MODE_NORMAL_MOUSE (ghostty_mode_new(1000, false)) /**< Normal mouse tracking */ +#define GHOSTTY_MODE_BUTTON_MOUSE (ghostty_mode_new(1002, false)) /**< Button-event mouse tracking */ +#define GHOSTTY_MODE_ANY_MOUSE (ghostty_mode_new(1003, false)) /**< Any-event mouse tracking */ +#define GHOSTTY_MODE_FOCUS_EVENT (ghostty_mode_new(1004, false)) /**< Focus in/out events */ +#define GHOSTTY_MODE_UTF8_MOUSE (ghostty_mode_new(1005, false)) /**< UTF-8 mouse format */ +#define GHOSTTY_MODE_SGR_MOUSE (ghostty_mode_new(1006, false)) /**< SGR mouse format */ +#define GHOSTTY_MODE_ALT_SCROLL (ghostty_mode_new(1007, false)) /**< Alternate scroll mode */ +#define GHOSTTY_MODE_URXVT_MOUSE (ghostty_mode_new(1015, false)) /**< URxvt mouse format */ +#define GHOSTTY_MODE_SGR_PIXELS_MOUSE (ghostty_mode_new(1016, false)) /**< SGR-Pixels mouse format */ +#define GHOSTTY_MODE_NUMLOCK_KEYPAD (ghostty_mode_new(1035, false)) /**< Ignore keypad with NumLock */ +#define GHOSTTY_MODE_ALT_ESC_PREFIX (ghostty_mode_new(1036, false)) /**< Alt key sends ESC prefix */ +#define GHOSTTY_MODE_ALT_SENDS_ESC (ghostty_mode_new(1039, false)) /**< Alt sends escape */ +#define GHOSTTY_MODE_REVERSE_WRAP_EXT (ghostty_mode_new(1045, false)) /**< Extended reverse wrap */ +#define GHOSTTY_MODE_ALT_SCREEN (ghostty_mode_new(1047, false)) /**< Alternate screen */ +#define GHOSTTY_MODE_SAVE_CURSOR (ghostty_mode_new(1048, false)) /**< Save cursor (DECSC) */ +#define GHOSTTY_MODE_ALT_SCREEN_SAVE (ghostty_mode_new(1049, false)) /**< Alt screen + save cursor + clear */ +#define GHOSTTY_MODE_BRACKETED_PASTE (ghostty_mode_new(2004, false)) /**< Bracketed paste mode */ +#define GHOSTTY_MODE_SYNC_OUTPUT (ghostty_mode_new(2026, false)) /**< Synchronized output */ +#define GHOSTTY_MODE_GRAPHEME_CLUSTER (ghostty_mode_new(2027, false)) /**< Grapheme cluster mode */ +#define GHOSTTY_MODE_COLOR_SCHEME_REPORT (ghostty_mode_new(2031, false)) /**< Report color scheme */ +#define GHOSTTY_MODE_IN_BAND_RESIZE (ghostty_mode_new(2048, false)) /**< In-band size reports */ +/** @} */ + +/** + * A packed 16-bit terminal mode. + * + * Encodes a mode value (bits 0–14) and an ANSI flag (bit 15) into a + * single 16-bit integer. Use the inline helper functions to construct + * and inspect modes rather than manipulating bits directly. + */ +typedef uint16_t GhosttyMode; + +/** + * Create a mode from a mode value and ANSI flag. + * + * @param value The numeric mode value (0–32767) + * @param ansi true for an ANSI mode, false for a DEC private mode + * @return The packed mode + * + * @ingroup modes + */ +static inline GhosttyMode ghostty_mode_new(uint16_t value, bool ansi) { + return (GhosttyMode)((value & 0x7FFF) | ((uint16_t)ansi << 15)); +} + +/** + * Extract the numeric mode value from a mode. + * + * @param mode The mode + * @return The mode value (0–32767) + * + * @ingroup modes + */ +static inline uint16_t ghostty_mode_value(GhosttyMode mode) { + return mode & 0x7FFF; +} + +/** + * Check whether a mode represents an ANSI mode. + * + * @param mode The mode + * @return true if this is an ANSI mode, false if it is a DEC private mode + * + * @ingroup modes + */ +static inline bool ghostty_mode_ansi(GhosttyMode mode) { + return (mode >> 15) != 0; +} + +/** + * DECRPM report state values. + * + * These correspond to the Ps2 parameter in a DECRPM response + * sequence (CSI ? Ps1 ; Ps2 $ y). + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Mode is not recognized */ + GHOSTTY_MODE_REPORT_NOT_RECOGNIZED = 0, + /** Mode is set (enabled) */ + GHOSTTY_MODE_REPORT_SET = 1, + /** Mode is reset (disabled) */ + GHOSTTY_MODE_REPORT_RESET = 2, + /** Mode is permanently set */ + GHOSTTY_MODE_REPORT_PERMANENTLY_SET = 3, + /** Mode is permanently reset */ + GHOSTTY_MODE_REPORT_PERMANENTLY_RESET = 4, + GHOSTTY_MODE_REPORT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyModeReportState; + +/** + * Encode a DECRPM (DEC Private Mode Report) response sequence. + * + * Writes a mode report escape sequence into the provided buffer. + * The generated sequence has the form: + * - DEC private mode: CSI ? Ps1 ; Ps2 $ y + * - ANSI mode: CSI Ps1 ; Ps2 $ y + * + * If the buffer is too small, the function returns GHOSTTY_OUT_OF_SPACE + * and writes the required buffer size to @p out_written. The caller can + * then retry with a sufficiently sized buffer. + * + * @param mode The mode identifying the mode to report on + * @param state The report state for this mode + * @param buf Output buffer to write the encoded sequence into (may be NULL) + * @param buf_len Size of the output buffer in bytes + * @param[out] out_written On success, the number of bytes written. On + * GHOSTTY_OUT_OF_SPACE, the required buffer size. + * @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_SPACE if the buffer + * is too small + */ +GHOSTTY_API GhosttyResult ghostty_mode_report_encode( + GhosttyMode mode, + GhosttyModeReportState state, + char* buf, + size_t buf_len, + size_t* out_written); + +#ifdef __cplusplus +} +#endif + +/** @} */ + +#endif /* GHOSTTY_VT_MODES_H */ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/mouse.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/mouse.h new file mode 100644 index 00000000000..4ba5f52e38b --- /dev/null +++ b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/mouse.h @@ -0,0 +1,70 @@ +/** + * @file mouse.h + * + * Mouse encoding module - encode mouse events into terminal escape sequences. + */ + +#ifndef GHOSTTY_VT_MOUSE_H +#define GHOSTTY_VT_MOUSE_H + +/** @defgroup mouse Mouse Encoding + * + * Utilities for encoding mouse events into terminal escape sequences, + * supporting X10, UTF-8, SGR, URxvt, and SGR-Pixels mouse protocols. + * + * ## Basic Usage + * + * 1. Create an encoder instance with ghostty_mouse_encoder_new(). + * 2. Configure encoder options with ghostty_mouse_encoder_setopt() or + * ghostty_mouse_encoder_setopt_from_terminal(). + * 3. For each mouse event: + * - Create a mouse event with ghostty_mouse_event_new(). + * - Set event properties (action, button, modifiers, position). + * - Encode with ghostty_mouse_encoder_encode(). + * - Free the event with ghostty_mouse_event_free() or reuse it. + * 4. Free the encoder with ghostty_mouse_encoder_free() when done. + * + * For a complete working example, see example/c-vt-encode-mouse in the + * repository. + * + * ## Example + * + * @snippet c-vt-encode-mouse/src/main.c mouse-encode + * + * ## Example: Encoding with Terminal State + * + * When you have a GhosttyTerminal, you can sync its tracking mode and + * output format into the encoder automatically: + * + * @code{.c} + * // Create a terminal and feed it some VT data that enables mouse tracking + * GhosttyTerminal terminal; + * ghostty_terminal_new(NULL, &terminal, + * (GhosttyTerminalOptions){.cols = 80, .rows = 24, .max_scrollback = 0}); + * + * // Application might write data that enables mouse reporting, etc. + * ghostty_terminal_vt_write(terminal, vt_data, vt_len); + * + * // Create an encoder and sync its options from the terminal + * GhosttyMouseEncoder encoder; + * ghostty_mouse_encoder_new(NULL, &encoder); + * ghostty_mouse_encoder_setopt_from_terminal(encoder, terminal); + * + * // Encode a mouse event using the terminal-derived options + * char buf[128]; + * size_t written = 0; + * ghostty_mouse_encoder_encode(encoder, event, buf, sizeof(buf), &written); + * + * ghostty_mouse_encoder_free(encoder); + * ghostty_terminal_free(terminal); + * @endcode + * + * @{ + */ + +#include +#include + +/** @} */ + +#endif /* GHOSTTY_VT_MOUSE_H */ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/mouse/encoder.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/mouse/encoder.h new file mode 100644 index 00000000000..d84d863c8d7 --- /dev/null +++ b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/mouse/encoder.h @@ -0,0 +1,214 @@ +/** + * @file encoder.h + * + * Mouse event encoding to terminal escape sequences. + */ + +#ifndef GHOSTTY_VT_MOUSE_ENCODER_H +#define GHOSTTY_VT_MOUSE_ENCODER_H + +#include +#include +#include +#include +#include +#include +#include + +/** + * Opaque handle to a mouse encoder instance. + * + * This handle represents a mouse encoder that converts normalized + * mouse events into terminal escape sequences. + * + * @ingroup mouse + */ +typedef struct GhosttyMouseEncoderImpl *GhosttyMouseEncoder; + +/** + * Mouse tracking mode. + * + * @ingroup mouse + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Mouse reporting disabled. */ + GHOSTTY_MOUSE_TRACKING_NONE = 0, + + /** X10 mouse mode. */ + GHOSTTY_MOUSE_TRACKING_X10 = 1, + + /** Normal mouse mode (button press/release only). */ + GHOSTTY_MOUSE_TRACKING_NORMAL = 2, + + /** Button-event tracking mode. */ + GHOSTTY_MOUSE_TRACKING_BUTTON = 3, + + /** Any-event tracking mode. */ + GHOSTTY_MOUSE_TRACKING_ANY = 4, + GHOSTTY_MOUSE_TRACKING_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyMouseTrackingMode; + +/** + * Mouse output format. + * + * @ingroup mouse + */ +typedef enum GHOSTTY_ENUM_TYPED { + GHOSTTY_MOUSE_FORMAT_X10 = 0, + GHOSTTY_MOUSE_FORMAT_UTF8 = 1, + GHOSTTY_MOUSE_FORMAT_SGR = 2, + GHOSTTY_MOUSE_FORMAT_URXVT = 3, + GHOSTTY_MOUSE_FORMAT_SGR_PIXELS = 4, + GHOSTTY_MOUSE_FORMAT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyMouseFormat; + +/** + * Mouse encoder size and geometry context. + * + * This describes the rendered terminal geometry used to convert + * surface-space positions into encoded coordinates. + * + * @ingroup mouse + */ +typedef struct { + /** Size of this struct in bytes. Must be set to sizeof(GhosttyMouseEncoderSize). */ + size_t size; + + /** Full screen width in pixels. */ + uint32_t screen_width; + + /** Full screen height in pixels. */ + uint32_t screen_height; + + /** Cell width in pixels. Must be non-zero. */ + uint32_t cell_width; + + /** Cell height in pixels. Must be non-zero. */ + uint32_t cell_height; + + /** Top padding in pixels. */ + uint32_t padding_top; + + /** Bottom padding in pixels. */ + uint32_t padding_bottom; + + /** Right padding in pixels. */ + uint32_t padding_right; + + /** Left padding in pixels. */ + uint32_t padding_left; +} GhosttyMouseEncoderSize; + +/** + * Mouse encoder option identifiers. + * + * These values are used with ghostty_mouse_encoder_setopt() to configure + * the behavior of the mouse encoder. + * + * @ingroup mouse + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Mouse tracking mode (value: GhosttyMouseTrackingMode). */ + GHOSTTY_MOUSE_ENCODER_OPT_EVENT = 0, + + /** Mouse output format (value: GhosttyMouseFormat). */ + GHOSTTY_MOUSE_ENCODER_OPT_FORMAT = 1, + + /** Renderer size context (value: GhosttyMouseEncoderSize). */ + GHOSTTY_MOUSE_ENCODER_OPT_SIZE = 2, + + /** Whether any mouse button is currently pressed (value: bool). */ + GHOSTTY_MOUSE_ENCODER_OPT_ANY_BUTTON_PRESSED = 3, + + /** Whether to enable motion deduplication by last cell (value: bool). */ + GHOSTTY_MOUSE_ENCODER_OPT_TRACK_LAST_CELL = 4, + GHOSTTY_MOUSE_ENCODER_OPT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyMouseEncoderOption; + +/** + * Create a new mouse encoder instance. + * + * @param allocator Pointer to allocator, 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 mouse + */ +GHOSTTY_API GhosttyResult ghostty_mouse_encoder_new(const GhosttyAllocator *allocator, + GhosttyMouseEncoder *encoder); + +/** + * Free a mouse encoder instance. + * + * @param encoder The encoder handle to free (may be NULL) + * + * @ingroup mouse + */ +GHOSTTY_API void ghostty_mouse_encoder_free(GhosttyMouseEncoder encoder); + +/** + * Set an option on the mouse encoder. + * + * A null pointer value does nothing. It does not reset to defaults. + * + * @param encoder The encoder handle, must not be NULL + * @param option The option to set + * @param value Pointer to option value (type depends on option) + * + * @ingroup mouse + */ +GHOSTTY_API void ghostty_mouse_encoder_setopt(GhosttyMouseEncoder encoder, + GhosttyMouseEncoderOption option, + const void *value); + +/** + * Set encoder options from a terminal's current state. + * + * This sets tracking mode and output format from terminal state. + * It does not modify size or any-button state. + * + * @param encoder The encoder handle, must not be NULL + * @param terminal The terminal handle, must not be NULL + * + * @ingroup mouse + */ +GHOSTTY_API void ghostty_mouse_encoder_setopt_from_terminal(GhosttyMouseEncoder encoder, + GhosttyTerminal terminal); + +/** + * Reset internal encoder state. + * + * This clears motion deduplication state (last tracked cell). + * + * @param encoder The encoder handle (may be NULL) + * + * @ingroup mouse + */ +GHOSTTY_API void ghostty_mouse_encoder_reset(GhosttyMouseEncoder encoder); + +/** + * Encode a mouse event into a terminal escape sequence. + * + * Not all mouse events produce output. In such cases this returns + * GHOSTTY_SUCCESS with out_len set to 0. + * + * If the output buffer is too small, this returns GHOSTTY_OUT_OF_SPACE + * and out_len contains the required size. + * + * @param encoder The encoder handle, must not be NULL + * @param event The mouse event to encode, must not be NULL + * @param out_buf Buffer to write encoded bytes to, or NULL to query required size + * @param out_buf_size Size of out_buf in bytes + * @param out_len Pointer to store bytes written (or required bytes on failure) + * @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_SPACE if buffer is too small, + * or another error code + * + * @ingroup mouse + */ +GHOSTTY_API GhosttyResult ghostty_mouse_encoder_encode(GhosttyMouseEncoder encoder, + GhosttyMouseEvent event, + char *out_buf, + size_t out_buf_size, + size_t *out_len); + +#endif /* GHOSTTY_VT_MOUSE_ENCODER_H */ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/mouse/event.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/mouse/event.h new file mode 100644 index 00000000000..a24b0c079bb --- /dev/null +++ b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/mouse/event.h @@ -0,0 +1,195 @@ +/** + * @file event.h + * + * Mouse event representation and manipulation. + */ + +#ifndef GHOSTTY_VT_MOUSE_EVENT_H +#define GHOSTTY_VT_MOUSE_EVENT_H + +#include +#include +#include +#include + +/** + * Opaque handle to a mouse event. + * + * This handle represents a normalized mouse input event containing + * action, button, modifiers, and surface-space position. + * + * @ingroup mouse + */ +typedef struct GhosttyMouseEventImpl *GhosttyMouseEvent; + +/** + * Mouse event action type. + * + * @ingroup mouse + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Mouse button was pressed. */ + GHOSTTY_MOUSE_ACTION_PRESS = 0, + + /** Mouse button was released. */ + GHOSTTY_MOUSE_ACTION_RELEASE = 1, + + /** Mouse moved. */ + GHOSTTY_MOUSE_ACTION_MOTION = 2, + GHOSTTY_MOUSE_ACTION_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyMouseAction; + +/** + * Mouse button identity. + * + * @ingroup mouse + */ +typedef enum GHOSTTY_ENUM_TYPED { + GHOSTTY_MOUSE_BUTTON_UNKNOWN = 0, + GHOSTTY_MOUSE_BUTTON_LEFT = 1, + GHOSTTY_MOUSE_BUTTON_RIGHT = 2, + GHOSTTY_MOUSE_BUTTON_MIDDLE = 3, + GHOSTTY_MOUSE_BUTTON_FOUR = 4, + GHOSTTY_MOUSE_BUTTON_FIVE = 5, + GHOSTTY_MOUSE_BUTTON_SIX = 6, + GHOSTTY_MOUSE_BUTTON_SEVEN = 7, + GHOSTTY_MOUSE_BUTTON_EIGHT = 8, + GHOSTTY_MOUSE_BUTTON_NINE = 9, + GHOSTTY_MOUSE_BUTTON_TEN = 10, + GHOSTTY_MOUSE_BUTTON_ELEVEN = 11, + GHOSTTY_MOUSE_BUTTON_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyMouseButton; + +/** + * Mouse position in surface-space pixels. + * + * @ingroup mouse + */ +typedef struct { + float x; + float y; +} GhosttyMousePosition; + +/** + * Create a new mouse event instance. + * + * @param allocator Pointer to allocator, or NULL to use the default allocator + * @param event Pointer to store the created event handle + * @return GHOSTTY_SUCCESS on success, or an error code on failure + * + * @ingroup mouse + */ +GHOSTTY_API GhosttyResult ghostty_mouse_event_new(const GhosttyAllocator *allocator, + GhosttyMouseEvent *event); + +/** + * Free a mouse event instance. + * + * @param event The mouse event handle to free (may be NULL) + * + * @ingroup mouse + */ +GHOSTTY_API void ghostty_mouse_event_free(GhosttyMouseEvent event); + +/** + * Set the event action. + * + * @param event The event handle, must not be NULL + * @param action The action to set + * + * @ingroup mouse + */ +GHOSTTY_API void ghostty_mouse_event_set_action(GhosttyMouseEvent event, + GhosttyMouseAction action); + +/** + * Get the event action. + * + * @param event The event handle, must not be NULL + * @return The event action + * + * @ingroup mouse + */ +GHOSTTY_API GhosttyMouseAction ghostty_mouse_event_get_action(GhosttyMouseEvent event); + +/** + * Set the event button. + * + * This sets a concrete button identity for the event. + * To represent "no button" (for motion events), use + * ghostty_mouse_event_clear_button(). + * + * @param event The event handle, must not be NULL + * @param button The button to set + * + * @ingroup mouse + */ +GHOSTTY_API void ghostty_mouse_event_set_button(GhosttyMouseEvent event, + GhosttyMouseButton button); + +/** + * Clear the event button. + * + * This sets the event button to "none". + * + * @param event The event handle, must not be NULL + * + * @ingroup mouse + */ +GHOSTTY_API void ghostty_mouse_event_clear_button(GhosttyMouseEvent event); + +/** + * Get the event button. + * + * @param event The event handle, must not be NULL + * @param out_button Output pointer for the button value (may be NULL) + * @return true if a button is set, false if no button is set + * + * @ingroup mouse + */ +GHOSTTY_API bool ghostty_mouse_event_get_button(GhosttyMouseEvent event, + GhosttyMouseButton *out_button); + +/** + * Set keyboard modifiers held during the event. + * + * @param event The event handle, must not be NULL + * @param mods Modifier bitmask + * + * @ingroup mouse + */ +GHOSTTY_API void ghostty_mouse_event_set_mods(GhosttyMouseEvent event, + GhosttyMods mods); + +/** + * Get keyboard modifiers held during the event. + * + * @param event The event handle, must not be NULL + * @return Modifier bitmask + * + * @ingroup mouse + */ +GHOSTTY_API GhosttyMods ghostty_mouse_event_get_mods(GhosttyMouseEvent event); + +/** + * Set the event position in surface-space pixels. + * + * @param event The event handle, must not be NULL + * @param position The position to set + * + * @ingroup mouse + */ +GHOSTTY_API void ghostty_mouse_event_set_position(GhosttyMouseEvent event, + GhosttyMousePosition position); + +/** + * Get the event position in surface-space pixels. + * + * @param event The event handle, must not be NULL + * @return The current event position + * + * @ingroup mouse + */ +GHOSTTY_API GhosttyMousePosition ghostty_mouse_event_get_position(GhosttyMouseEvent event); + +#endif /* GHOSTTY_VT_MOUSE_EVENT_H */ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/osc.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/osc.h new file mode 100644 index 00000000000..9409ebc738f --- /dev/null +++ b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/osc.h @@ -0,0 +1,215 @@ +/** + * @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 + +/** @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_ENUM_TYPED { + 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, + GHOSTTY_OSC_COMMAND_TYPE_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} 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 GHOSTTY_ENUM_TYPED { + /** 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, + GHOSTTY_OSC_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} 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 + */ +GHOSTTY_API 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 + */ +GHOSTTY_API 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 + */ +GHOSTTY_API 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 + */ +GHOSTTY_API 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 + */ +GHOSTTY_API 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 + */ +GHOSTTY_API 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 + */ +GHOSTTY_API 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-vt/include/ghostty/vt/paste.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/paste.h new file mode 100644 index 00000000000..b3df5be4e0d --- /dev/null +++ b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/paste.h @@ -0,0 +1,101 @@ +/** + * @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 and encoding paste data for terminal input. + * + * ## Basic Usage + * + * Use ghostty_paste_is_safe() to check if paste data contains potentially + * dangerous sequences before sending it to the terminal. + * + * Use ghostty_paste_encode() to encode paste data for writing to the pty, + * including bracketed paste wrapping and unsafe byte stripping. + * + * ## Examples + * + * ### Safety Check + * + * @snippet c-vt-paste/src/main.c paste-safety + * + * ### Encoding + * + * @snippet c-vt-paste/src/main.c paste-encode + * + * @{ + */ + +#include +#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 + */ +GHOSTTY_API bool ghostty_paste_is_safe(const char* data, size_t len); + +/** + * Encode paste data for writing to the terminal pty. + * + * This function prepares paste data for terminal input by: + * - Stripping unsafe control bytes (NUL, ESC, DEL, etc.) by replacing + * them with spaces + * - Wrapping the data in bracketed paste sequences if @p bracketed is true + * - Replacing newlines with carriage returns if @p bracketed is false + * + * The input @p data buffer is modified in place during encoding. The + * encoded result (potentially with bracketed paste prefix/suffix) is + * written to the output buffer. + * + * If the output buffer is too small, the function returns + * GHOSTTY_OUT_OF_SPACE and sets the required size in @p out_written. + * The caller can then retry with a sufficiently sized buffer. + * + * @param data The paste data to encode (modified in place, may be NULL) + * @param data_len The length of the input data in bytes + * @param bracketed Whether bracketed paste mode is active + * @param buf Output buffer to write the encoded result into (may be NULL) + * @param buf_len Size of the output buffer in bytes + * @param[out] out_written On success, the number of bytes written. On + * GHOSTTY_OUT_OF_SPACE, the required buffer size. + * @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_SPACE if the buffer + * is too small + */ +GHOSTTY_API GhosttyResult ghostty_paste_encode( + char* data, + size_t data_len, + bool bracketed, + char* buf, + size_t buf_len, + size_t* out_written); + +#ifdef __cplusplus +} +#endif + +/** @} */ + +#endif /* GHOSTTY_VT_PASTE_H */ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/point.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/point.h new file mode 100644 index 00000000000..8b717f4940c --- /dev/null +++ b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/point.h @@ -0,0 +1,89 @@ +/** + * @file point.h + * + * Terminal point types for referencing locations in the terminal grid. + */ + +#ifndef GHOSTTY_VT_POINT_H +#define GHOSTTY_VT_POINT_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** @defgroup point Point + * + * Types for referencing x/y positions in the terminal grid under + * different coordinate systems (active area, viewport, full screen, + * scrollback history). + * + * @{ + */ + +/** + * A coordinate in the terminal grid. + * + * @ingroup point + */ +typedef struct { + /** Column (0-indexed). */ + uint16_t x; + + /** Row (0-indexed). May exceed page size for screen/history tags. */ + uint32_t y; +} GhosttyPointCoordinate; + +/** + * Point reference tag. + * + * Determines which coordinate system a point uses. + * + * @ingroup point + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Active area where the cursor can move. */ + GHOSTTY_POINT_TAG_ACTIVE = 0, + + /** Visible viewport (changes when scrolled). */ + GHOSTTY_POINT_TAG_VIEWPORT = 1, + + /** Full screen including scrollback. */ + GHOSTTY_POINT_TAG_SCREEN = 2, + + /** Scrollback history only (before active area). */ + GHOSTTY_POINT_TAG_HISTORY = 3, + GHOSTTY_POINT_TAG_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, + } GhosttyPointTag; + +/** + * Point value union. + * + * @ingroup point + */ +typedef union { + /** Coordinate (used for all tag variants). */ + GhosttyPointCoordinate coordinate; + + /** Padding for ABI compatibility. Do not use. */ + uint64_t _padding[2]; +} GhosttyPointValue; + +/** + * Tagged union for a point in the terminal grid. + * + * @ingroup point + */ +typedef struct { + GhosttyPointTag tag; + GhosttyPointValue value; +} GhosttyPoint; + +/** @} */ + +#ifdef __cplusplus +} +#endif + +#endif /* GHOSTTY_VT_POINT_H */ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/render.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/render.h new file mode 100644 index 00000000000..c5b1d0d4fc2 --- /dev/null +++ b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/render.h @@ -0,0 +1,729 @@ +/** + * @file render.h + * + * Render state for creating high performance renderers. + */ + +#ifndef GHOSTTY_VT_RENDER_H +#define GHOSTTY_VT_RENDER_H + +#include +#include +#include +#include +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** @defgroup render Render State + * + * Represents the state required to render a visible screen (a viewport) + * of a terminal instance. This is stateful and optimized for repeated + * updates from a single terminal instance and only updating dirty regions + * of the screen. + * + * The key design principle of this API is that it only needs read/write + * access to the terminal instance during the update call. This allows + * the render state to minimally impact terminal IO performance and also + * allows the renderer to be safely multi-threaded (as long as a lock is + * held during the update call to ensure exclusive access to the terminal + * instance). + * + * The basic usage of this API is: + * + * 1. Create an empty render state + * 2. Update it from a terminal instance whenever you need. + * 3. Read from the render state to get the data needed to draw your frame. + * + * ## Dirty Tracking + * + * Dirty tracking is a key feature of the render state that allows renderers + * to efficiently determine what parts of the screen have changed and only + * redraw changed regions. + * + * The render state API keeps track of dirty state at two independent layers: + * a global dirty state that indicates whether the entire frame is clean, + * partially dirty, or fully dirty, and a per-row dirty state that allows + * tracking which rows in a partially dirty frame have changed. + * + * The user of the render state API is expected to unset both of these. + * The `update` call does not unset dirty state, it only updates it. + * + * An extremely important detail: setting one dirty state doesn't unset + * the other. For example, setting the global dirty state to false does not + * reset the row-level dirty flags. So, the caller of the render state API must + * be careful to manage both layers of dirty state correctly. + * + * ## Examples + * + * ### Creating and updating render state + * @snippet c-vt-render/src/main.c render-state-update + * + * ### Checking dirty state + * @snippet c-vt-render/src/main.c render-dirty-check + * + * ### Reading colors + * @snippet c-vt-render/src/main.c render-colors + * + * ### Reading cursor state + * @snippet c-vt-render/src/main.c render-cursor + * + * ### Iterating rows and cells + * @snippet c-vt-render/src/main.c render-row-iterate + * + * ### Resetting dirty state after rendering + * @snippet c-vt-render/src/main.c render-dirty-reset + * + * @{ + */ + +/** + * Dirty state of a render state after update. + * + * @ingroup render + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Not dirty at all; rendering can be skipped. */ + GHOSTTY_RENDER_STATE_DIRTY_FALSE = 0, + + /** Some rows changed; renderer can redraw incrementally. */ + GHOSTTY_RENDER_STATE_DIRTY_PARTIAL = 1, + + /** Global state changed; renderer should redraw everything. */ + GHOSTTY_RENDER_STATE_DIRTY_FULL = 2, + GHOSTTY_RENDER_STATE_DIRTY_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyRenderStateDirty; + +/** + * Visual style of the cursor. + * + * @ingroup render + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Bar cursor (DECSCUSR 5, 6). */ + GHOSTTY_RENDER_STATE_CURSOR_VISUAL_STYLE_BAR = 0, + + /** Block cursor (DECSCUSR 1, 2). */ + GHOSTTY_RENDER_STATE_CURSOR_VISUAL_STYLE_BLOCK = 1, + + /** Underline cursor (DECSCUSR 3, 4). */ + GHOSTTY_RENDER_STATE_CURSOR_VISUAL_STYLE_UNDERLINE = 2, + + /** Hollow block cursor. */ + GHOSTTY_RENDER_STATE_CURSOR_VISUAL_STYLE_BLOCK_HOLLOW = 3, + GHOSTTY_RENDER_STATE_CURSOR_VISUAL_STYLE_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyRenderStateCursorVisualStyle; + +/** + * Queryable data kinds for ghostty_render_state_get(). + * + * @ingroup render + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Invalid / sentinel value. */ + GHOSTTY_RENDER_STATE_DATA_INVALID = 0, + + /** Viewport width in cells (uint16_t). */ + GHOSTTY_RENDER_STATE_DATA_COLS = 1, + + /** Viewport height in cells (uint16_t). */ + GHOSTTY_RENDER_STATE_DATA_ROWS = 2, + + /** Current dirty state (GhosttyRenderStateDirty). */ + GHOSTTY_RENDER_STATE_DATA_DIRTY = 3, + + /** Populate a pre-allocated GhosttyRenderStateRowIterator with row data + * from the render state (GhosttyRenderStateRowIterator). Row data is + * only valid as long as the underlying render state is not updated. + * It is unsafe to use row data after updating the render state. + * */ + GHOSTTY_RENDER_STATE_DATA_ROW_ITERATOR = 4, + + /** Default/current background color (GhosttyColorRgb). */ + GHOSTTY_RENDER_STATE_DATA_COLOR_BACKGROUND = 5, + + /** Default/current foreground color (GhosttyColorRgb). */ + GHOSTTY_RENDER_STATE_DATA_COLOR_FOREGROUND = 6, + + /** Cursor color when explicitly set by terminal state (GhosttyColorRgb). + * Returns GHOSTTY_INVALID_VALUE if no explicit cursor color is set; + * use COLOR_CURSOR_HAS_VALUE to check first. */ + GHOSTTY_RENDER_STATE_DATA_COLOR_CURSOR = 7, + + /** Whether an explicit cursor color is set (bool). */ + GHOSTTY_RENDER_STATE_DATA_COLOR_CURSOR_HAS_VALUE = 8, + + /** The active 256-color palette (GhosttyColorRgb[256]). */ + GHOSTTY_RENDER_STATE_DATA_COLOR_PALETTE = 9, + + /** The visual style of the cursor (GhosttyRenderStateCursorVisualStyle). */ + GHOSTTY_RENDER_STATE_DATA_CURSOR_VISUAL_STYLE = 10, + + /** Whether the cursor is visible based on terminal modes (bool). */ + GHOSTTY_RENDER_STATE_DATA_CURSOR_VISIBLE = 11, + + /** Whether the cursor should blink based on terminal modes (bool). */ + GHOSTTY_RENDER_STATE_DATA_CURSOR_BLINKING = 12, + + /** Whether the cursor is at a password input field (bool). */ + GHOSTTY_RENDER_STATE_DATA_CURSOR_PASSWORD_INPUT = 13, + + /** Whether the cursor is visible within the viewport (bool). + * If false, the cursor viewport position values are undefined. */ + GHOSTTY_RENDER_STATE_DATA_CURSOR_VIEWPORT_HAS_VALUE = 14, + + /** Cursor viewport x position in cells (uint16_t). + * Only valid when CURSOR_VIEWPORT_HAS_VALUE is true. */ + GHOSTTY_RENDER_STATE_DATA_CURSOR_VIEWPORT_X = 15, + + /** Cursor viewport y position in cells (uint16_t). + * Only valid when CURSOR_VIEWPORT_HAS_VALUE is true. */ + GHOSTTY_RENDER_STATE_DATA_CURSOR_VIEWPORT_Y = 16, + + /** Whether the cursor is on the tail of a wide character (bool). + * Only valid when CURSOR_VIEWPORT_HAS_VALUE is true. */ + GHOSTTY_RENDER_STATE_DATA_CURSOR_VIEWPORT_WIDE_TAIL = 17, + GHOSTTY_RENDER_STATE_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyRenderStateData; + +/** + * Settable options for ghostty_render_state_set(). + * + * @ingroup render + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Set dirty state (GhosttyRenderStateDirty). */ + GHOSTTY_RENDER_STATE_OPTION_DIRTY = 0, + GHOSTTY_RENDER_STATE_OPTION_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyRenderStateOption; + +/** + * Queryable data kinds for ghostty_render_state_row_get(). + * + * @ingroup render + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Invalid / sentinel value. */ + GHOSTTY_RENDER_STATE_ROW_DATA_INVALID = 0, + + /** Whether the current row is dirty (bool). */ + GHOSTTY_RENDER_STATE_ROW_DATA_DIRTY = 1, + + /** The raw row value (GhosttyRow). */ + GHOSTTY_RENDER_STATE_ROW_DATA_RAW = 2, + + /** Populate a pre-allocated GhosttyRenderStateRowCells with cell data for + * the current row (GhosttyRenderStateRowCells). Cell data is only + * valid as long as the underlying render state is not updated. + * It is unsafe to use cell data after updating the render state. */ + GHOSTTY_RENDER_STATE_ROW_DATA_CELLS = 3, + + /** Row-local selected cell range (GhosttyRenderStateRowSelection). */ + GHOSTTY_RENDER_STATE_ROW_DATA_SELECTION = 4, + GHOSTTY_RENDER_STATE_ROW_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyRenderStateRowData; + +/** + * Settable options for ghostty_render_state_row_set(). + * + * @ingroup render + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Set dirty state for the current row (bool). */ + GHOSTTY_RENDER_STATE_ROW_OPTION_DIRTY = 0, + GHOSTTY_RENDER_STATE_ROW_OPTION_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyRenderStateRowOption; + +/** + * Row-local selection range. + * + * This struct uses the sized-struct ABI pattern. Initialize with + * GHOSTTY_INIT_SIZED(GhosttyRenderStateRowSelection) before querying + * GHOSTTY_RENDER_STATE_ROW_DATA_SELECTION. + * + * Querying GHOSTTY_RENDER_STATE_ROW_DATA_SELECTION returns GHOSTTY_NO_VALUE + * if the current row does not intersect the current selection. + * + * @ingroup render + */ +typedef struct { + /** Size of this struct in bytes. Must be set to sizeof(GhosttyRenderStateRowSelection). */ + size_t size; + + /** Start column of the row-local selection range, inclusive. */ + uint16_t start_x; + + /** End column of the row-local selection range, inclusive. */ + uint16_t end_x; +} GhosttyRenderStateRowSelection; + +/** + * Render-state color information. + * + * This struct uses the sized-struct ABI pattern. Initialize with + * GHOSTTY_INIT_SIZED(GhosttyRenderStateColors) before calling + * ghostty_render_state_colors_get(). + * + * Example: + * @code + * GhosttyRenderStateColors colors = GHOSTTY_INIT_SIZED(GhosttyRenderStateColors); + * GhosttyResult result = ghostty_render_state_colors_get(state, &colors); + * @endcode + * + * @ingroup render + */ +typedef struct { + /** Size of this struct in bytes. Must be set to sizeof(GhosttyRenderStateColors). */ + size_t size; + + /** The default/current background color for the render state. */ + GhosttyColorRgb background; + + /** The default/current foreground color for the render state. */ + GhosttyColorRgb foreground; + + /** The cursor color when explicitly set by terminal state. */ + GhosttyColorRgb cursor; + + /** + * True when cursor contains a valid explicit cursor color value. + * If this is false, the cursor color should be ignored; it will + * contain undefined data. + * */ + bool cursor_has_value; + + /** The active 256-color palette for this render state. */ + GhosttyColorRgb palette[256]; +} GhosttyRenderStateColors; + +/** + * Create a new render state instance. + * + * @param allocator Pointer to allocator, or NULL to use the default allocator + * @param state Pointer to store the created render state handle + * @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_MEMORY on allocation + * failure + * + * @ingroup render + */ +GHOSTTY_API GhosttyResult ghostty_render_state_new(const GhosttyAllocator* allocator, + GhosttyRenderState* state); + +/** + * Free a render state instance. + * + * Releases all resources associated with the render state. After this call, + * the render state handle becomes invalid. + * + * @param state The render state handle to free (may be NULL) + * + * @ingroup render + */ +GHOSTTY_API void ghostty_render_state_free(GhosttyRenderState state); + +/** + * Update a render state instance from a terminal. + * + * This consumes terminal/screen dirty state in the same way as the internal + * render state update path. + * + * @param state The render state handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param terminal The terminal handle to read from (NULL returns GHOSTTY_INVALID_VALUE) + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if `state` or + * `terminal` is NULL, GHOSTTY_OUT_OF_MEMORY if updating the state requires + * allocation and that allocation fails + * + * @ingroup render + */ +GHOSTTY_API GhosttyResult ghostty_render_state_update(GhosttyRenderState state, + GhosttyTerminal terminal); + +/** + * Get a value from a render state. + * + * The `out` pointer must point to a value of the type corresponding to the + * requested data kind (see GhosttyRenderStateData). + * + * @param state The render state handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param data The data kind to query + * @param[out] out Pointer to receive the queried value + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if `state` is + * NULL or `data` is not a recognized enum value + * + * @ingroup render + */ +GHOSTTY_API GhosttyResult ghostty_render_state_get(GhosttyRenderState state, + GhosttyRenderStateData data, + void* out); + +/** + * Get multiple data fields from a render state in a single call. + * + * Each element in the keys array specifies a data kind, and the + * corresponding element in the values array receives the result. + * + * Processing stops at the first error; on success out_written + * is set to count, on error it is set to the index of the + * failing key (i.e. the number of values successfully written). + * + * @param state The render state handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param count Number of key/value pairs + * @param keys Array of data kinds to query + * @param values Array of output pointers (types must match each key's + * documented output type) + * @param[out] out_written On return, receives the number of values + * successfully written (may be NULL) + * @return GHOSTTY_SUCCESS if all queries succeed + * + * @ingroup render + */ +GHOSTTY_API GhosttyResult ghostty_render_state_get_multi( + GhosttyRenderState state, + size_t count, + const GhosttyRenderStateData* keys, + void** values, + size_t* out_written); + +/** + * Set an option on a render state. + * + * The `value` pointer must point to a value of the type corresponding to the + * requested option kind (see GhosttyRenderStateOption). + * + * @param state The render state handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param option The option to set + * @param[in] value Pointer to the value to set (NULL returns + * GHOSTTY_INVALID_VALUE) + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if `state` or + * `value` is NULL + * + * @ingroup render + */ +GHOSTTY_API GhosttyResult ghostty_render_state_set(GhosttyRenderState state, + GhosttyRenderStateOption option, + const void* value); + +/** + * Get the current color information from a render state. + * + * This writes as many fields as fit in the caller-provided sized struct. + * `out_colors->size` must be set by the caller (typically via + * GHOSTTY_INIT_SIZED(GhosttyRenderStateColors)). + * + * @param state The render state handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param[out] out_colors Sized output struct to receive render-state colors + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if `state` or + * `out_colors` is NULL, or if `out_colors->size` is smaller than + * `sizeof(size_t)` + * + * @ingroup render + */ +GHOSTTY_API GhosttyResult ghostty_render_state_colors_get(GhosttyRenderState state, + GhosttyRenderStateColors* out_colors); + +/** + * Create a new row iterator instance. + * + * All fields except the allocator are left undefined until populated + * via ghostty_render_state_get() with + * GHOSTTY_RENDER_STATE_DATA_ROW_ITERATOR. + * + * @param allocator Pointer to allocator, or NULL to use the default allocator + * @param[out] out_iterator On success, receives the created iterator handle + * @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_MEMORY on allocation + * failure + * + * @ingroup render + */ +GHOSTTY_API GhosttyResult ghostty_render_state_row_iterator_new( + const GhosttyAllocator* allocator, + GhosttyRenderStateRowIterator* out_iterator); + +/** + * Free a render-state row iterator. + * + * @param iterator The iterator handle to free (may be NULL) + * + * @ingroup render + */ +GHOSTTY_API void ghostty_render_state_row_iterator_free(GhosttyRenderStateRowIterator iterator); + +/** + * Move a render-state row iterator to the next row. + * + * Returns true if the iterator moved successfully and row data is + * available to read at the new position. + * + * @param iterator The iterator handle to advance (may be NULL) + * @return true if advanced to the next row, false if `iterator` is + * NULL or if the iterator has reached the end + * + * @ingroup render + */ +GHOSTTY_API bool ghostty_render_state_row_iterator_next(GhosttyRenderStateRowIterator iterator); + +/** + * Get a value from the current row in a render-state row iterator. + * + * The `out` pointer must point to a value of the type corresponding to the + * requested data kind (see GhosttyRenderStateRowData). + * Call ghostty_render_state_row_iterator_next() at least once before + * calling this function. + * + * @param iterator The iterator handle to query (NULL returns GHOSTTY_INVALID_VALUE) + * @param data The data kind to query + * @param[out] out Pointer to receive the queried value + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if + * `iterator` is NULL or the iterator is not positioned on a row + * + * @ingroup render + */ +GHOSTTY_API GhosttyResult ghostty_render_state_row_get( + GhosttyRenderStateRowIterator iterator, + GhosttyRenderStateRowData data, + void* out); + +/** + * Get multiple data fields from the current row in a single call. + * + * Each element in the keys array specifies a data kind, and the + * corresponding element in the values array receives the result. + * + * Processing stops at the first error; on success out_written + * is set to count, on error it is set to the index of the + * failing key (i.e. the number of values successfully written). + * + * @param iterator The iterator handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param count Number of key/value pairs + * @param keys Array of data kinds to query + * @param values Array of output pointers (types must match each key's + * documented output type) + * @param[out] out_written On return, receives the number of values + * successfully written (may be NULL) + * @return GHOSTTY_SUCCESS if all queries succeed + * + * @ingroup render + */ +GHOSTTY_API GhosttyResult ghostty_render_state_row_get_multi( + GhosttyRenderStateRowIterator iterator, + size_t count, + const GhosttyRenderStateRowData* keys, + void** values, + size_t* out_written); + +/** + * Set an option on the current row in a render-state row iterator. + * + * The `value` pointer must point to a value of the type corresponding to the + * requested option kind (see GhosttyRenderStateRowOption). + * Call ghostty_render_state_row_iterator_next() at least once before + * calling this function. + * + * @param iterator The iterator handle to update (NULL returns GHOSTTY_INVALID_VALUE) + * @param option The option to set + * @param[in] value Pointer to the value to set (NULL returns + * GHOSTTY_INVALID_VALUE) + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if + * `iterator` is NULL or the iterator is not positioned on a row + * + * @ingroup render + */ +GHOSTTY_API GhosttyResult ghostty_render_state_row_set( + GhosttyRenderStateRowIterator iterator, + GhosttyRenderStateRowOption option, + const void* value); + +/** + * Create a new row cells instance. + * + * All fields except the allocator are left undefined until populated + * via ghostty_render_state_row_get() with + * GHOSTTY_RENDER_STATE_ROW_DATA_CELLS. + * + * You can reuse this value repeatedly with ghostty_render_state_row_get() to + * avoid allocating a new cells container for every row. + * + * @param allocator Pointer to allocator, or NULL to use the default allocator + * @param[out] out_cells On success, receives the created row cells handle + * @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_MEMORY on allocation + * failure + * + * @ingroup render + */ +GHOSTTY_API GhosttyResult ghostty_render_state_row_cells_new( + const GhosttyAllocator* allocator, + GhosttyRenderStateRowCells* out_cells); + +/** + * Queryable data kinds for ghostty_render_state_row_cells_get(). + * + * @ingroup render + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Invalid / sentinel value. */ + GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_INVALID = 0, + + /** The raw cell value (GhosttyCell). */ + GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_RAW = 1, + + /** The style for the current cell (GhosttyStyle). */ + GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_STYLE = 2, + + /** The total number of grapheme codepoints including the base codepoint + * (uint32_t). Returns 0 if the cell has no text. */ + GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_GRAPHEMES_LEN = 3, + + /** Write grapheme codepoints into a caller-provided buffer (uint32_t*). + * The buffer must be at least graphemes_len elements. The base codepoint + * is written first, followed by any extra codepoints. */ + GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_GRAPHEMES_BUF = 4, + + /** The resolved background color of the cell (GhosttyColorRgb). + * Flattens the three possible sources: content-tag bg_color_rgb, + * content-tag bg_color_palette (looked up in the palette), or the + * style's bg_color. Returns GHOSTTY_INVALID_VALUE if the cell has + * no background color, in which case the caller should use whatever + * default background color it wants (e.g. the terminal background). */ + GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_BG_COLOR = 5, + + /** The resolved foreground color of the cell (GhosttyColorRgb). + * Resolves palette indices through the palette. Bold color handling + * is not applied; the caller should handle bold styling separately. + * Returns GHOSTTY_INVALID_VALUE if the cell has no explicit foreground + * color, in which case the caller should use whatever default foreground + * color it wants (e.g. the terminal foreground). */ + GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_FG_COLOR = 6, + + /** Whether the cell is contained within the current selection (bool). + * This returns true when the cell's column is within the current row's + * row-local selection range, and false otherwise. Rendering policy for + * selected cells (colors, inversion, etc.) is left to the caller. + * + * Renderers that can draw cells in spans may be more efficient querying + * GHOSTTY_RENDER_STATE_ROW_DATA_SELECTION once per row and applying that + * range directly, avoiding one C API call per cell for selection state. */ + GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_SELECTED = 7, + + /** Whether the cell has any explicit styling (bool). + * This is equivalent to querying the raw cell's + * GHOSTTY_CELL_DATA_HAS_STYLING value, but avoids materializing the raw + * GhosttyCell for renderers that only need to know whether fetching the + * full style is necessary. */ + GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_HAS_STYLING = 8, + + /** + * Encode the current cell's full grapheme cluster as UTF-8 into a + * caller-provided buffer (GhosttyBuffer). + * + * The base codepoint is encoded first, followed by any extra grapheme + * codepoints. Returns GHOSTTY_SUCCESS with len=0 when the cell has no text. + * + * If ptr is NULL or cap is too small for a non-empty cell, returns + * GHOSTTY_OUT_OF_SPACE without writing any bytes and sets len to the required + * buffer size in bytes. + */ + GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_GRAPHEMES_UTF8 = 9, + GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyRenderStateRowCellsData; + +/** + * Move a render-state row cells iterator to the next cell. + * + * Returns true if the iterator moved successfully and cell data is + * available to read at the new position. + * + * @param cells The row cells handle to advance (may be NULL) + * @return true if advanced to the next cell, false if `cells` is + * NULL or if the iterator has reached the end + * + * @ingroup render + */ +GHOSTTY_API bool ghostty_render_state_row_cells_next(GhosttyRenderStateRowCells cells); + +/** + * Move a render-state row cells iterator to a specific column. + * + * Positions the iterator at the given x (column) index so that + * subsequent reads return data for that cell. + * + * @param cells The row cells handle to reposition (NULL returns + * GHOSTTY_INVALID_VALUE) + * @param x The zero-based column index to select + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if `cells` + * is NULL or `x` is out of range + * + * @ingroup render + */ +GHOSTTY_API GhosttyResult ghostty_render_state_row_cells_select( + GhosttyRenderStateRowCells cells, uint16_t x); + +/** + * Get a value from the current cell in a render-state row cells iterator. + * + * The `out` pointer must point to a value of the type corresponding to the + * requested data kind (see GhosttyRenderStateRowCellsData). + * Call ghostty_render_state_row_cells_next() or + * ghostty_render_state_row_cells_select() at least once before + * calling this function. + * + * @param cells The row cells handle to query (NULL returns GHOSTTY_INVALID_VALUE) + * @param data The data kind to query + * @param[out] out Pointer to receive the queried value + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if + * `cells` is NULL or the iterator is not positioned on a cell + * + * @ingroup render + */ +GHOSTTY_API GhosttyResult ghostty_render_state_row_cells_get( + GhosttyRenderStateRowCells cells, + GhosttyRenderStateRowCellsData data, + void* out); + +/** + * Get multiple data fields from the current cell in a single call. + * + * Each element in the keys array specifies a data kind, and the + * corresponding element in the values array receives the result. + * + * Processing stops at the first error; on success out_written + * is set to count, on error it is set to the index of the + * failing key (i.e. the number of values successfully written). + * + * @param cells The row cells handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param count Number of key/value pairs + * @param keys Array of data kinds to query + * @param values Array of output pointers (types must match each key's + * documented output type) + * @param[out] out_written On return, receives the number of values + * successfully written (may be NULL) + * @return GHOSTTY_SUCCESS if all queries succeed + * + * @ingroup render + */ +GHOSTTY_API GhosttyResult ghostty_render_state_row_cells_get_multi( + GhosttyRenderStateRowCells cells, + size_t count, + const GhosttyRenderStateRowCellsData* keys, + void** values, + size_t* out_written); + +/** + * Free a row cells instance. + * + * @param cells The row cells handle to free (may be NULL) + * + * @ingroup render + */ +GHOSTTY_API void ghostty_render_state_row_cells_free(GhosttyRenderStateRowCells cells); + +/** @} */ + +#ifdef __cplusplus +} +#endif + +#endif /* GHOSTTY_VT_RENDER_H */ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/screen.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/screen.h new file mode 100644 index 00000000000..9f639b58313 --- /dev/null +++ b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/screen.h @@ -0,0 +1,400 @@ +/** + * @file screen.h + * + * Terminal screen cell and row types. + */ + +#ifndef GHOSTTY_VT_SCREEN_H +#define GHOSTTY_VT_SCREEN_H + +#include +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** @defgroup screen Screen + * + * Terminal screen cell and row types. + * + * These types represent the contents of a terminal screen. A GhosttyCell + * is a single grid cell and a GhosttyRow is a single row. Both are opaque + * values whose fields are accessed via ghostty_cell_get() and + * ghostty_row_get() respectively. + * + * @{ + */ + +/** + * Opaque cell value. + * + * Represents a single terminal cell. The internal layout is opaque and + * must be queried via ghostty_cell_get(). Obtain cell values from + * terminal query APIs. + * + * @ingroup screen + */ +typedef uint64_t GhosttyCell; + +/** + * Opaque row value. + * + * Represents a single terminal row. The internal layout is opaque and + * must be queried via ghostty_row_get(). Obtain row values from + * terminal query APIs. + * + * @ingroup screen + */ +typedef uint64_t GhosttyRow; + +/** + * Cell content tag. + * + * Describes what kind of content a cell holds. + * + * @ingroup screen + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** A single codepoint (may be zero for empty). */ + GHOSTTY_CELL_CONTENT_CODEPOINT = 0, + + /** A codepoint that is part of a multi-codepoint grapheme cluster. */ + GHOSTTY_CELL_CONTENT_CODEPOINT_GRAPHEME = 1, + + /** No text; background color from palette. */ + GHOSTTY_CELL_CONTENT_BG_COLOR_PALETTE = 2, + + /** No text; background color as RGB. */ + GHOSTTY_CELL_CONTENT_BG_COLOR_RGB = 3, + GHOSTTY_CELL_CONTENT_TAG_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyCellContentTag; + +/** + * Cell wide property. + * + * Describes the width behavior of a cell. + * + * @ingroup screen + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Not a wide character, cell width 1. */ + GHOSTTY_CELL_WIDE_NARROW = 0, + + /** Wide character, cell width 2. */ + GHOSTTY_CELL_WIDE_WIDE = 1, + + /** Spacer after wide character. Do not render. */ + GHOSTTY_CELL_WIDE_SPACER_TAIL = 2, + + /** Spacer at end of soft-wrapped line for a wide character. */ + GHOSTTY_CELL_WIDE_SPACER_HEAD = 3, + GHOSTTY_CELL_WIDE_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyCellWide; + +/** + * Semantic content type of a cell. + * + * Set by semantic prompt sequences (OSC 133) to distinguish between + * command output, user input, and shell prompt text. + * + * @ingroup screen + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Regular output content, such as command output. */ + GHOSTTY_CELL_SEMANTIC_OUTPUT = 0, + + /** Content that is part of user input. */ + GHOSTTY_CELL_SEMANTIC_INPUT = 1, + + /** Content that is part of a shell prompt. */ + GHOSTTY_CELL_SEMANTIC_PROMPT = 2, + GHOSTTY_CELL_SEMANTIC_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyCellSemanticContent; + +/** + * Cell data types. + * + * These values specify what type of data to extract from a cell + * using `ghostty_cell_get`. + * + * @ingroup screen + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Invalid data type. Never results in any data extraction. */ + GHOSTTY_CELL_DATA_INVALID = 0, + + /** + * The codepoint of the cell (0 if empty or bg-color-only). + * + * Output type: uint32_t * + */ + GHOSTTY_CELL_DATA_CODEPOINT = 1, + + /** + * The content tag describing what kind of content is in the cell. + * + * Output type: GhosttyCellContentTag * + */ + GHOSTTY_CELL_DATA_CONTENT_TAG = 2, + + /** + * The wide property of the cell. + * + * Output type: GhosttyCellWide * + */ + GHOSTTY_CELL_DATA_WIDE = 3, + + /** + * Whether the cell has text to render. + * + * Output type: bool * + */ + GHOSTTY_CELL_DATA_HAS_TEXT = 4, + + /** + * Whether the cell has non-default styling. + * + * Output type: bool * + */ + GHOSTTY_CELL_DATA_HAS_STYLING = 5, + + /** + * The style ID for the cell (for use with style lookups). + * + * Output type: uint16_t * + */ + GHOSTTY_CELL_DATA_STYLE_ID = 6, + + /** + * Whether the cell has a hyperlink. + * + * Output type: bool * + */ + GHOSTTY_CELL_DATA_HAS_HYPERLINK = 7, + + /** + * Whether the cell is protected. + * + * Output type: bool * + */ + GHOSTTY_CELL_DATA_PROTECTED = 8, + + /** + * The semantic content type of the cell (from OSC 133). + * + * Output type: GhosttyCellSemanticContent * + */ + GHOSTTY_CELL_DATA_SEMANTIC_CONTENT = 9, + + /** + * The palette index for the cell's background color. + * Only valid when content_tag is GHOSTTY_CELL_CONTENT_BG_COLOR_PALETTE. + * + * Output type: GhosttyColorPaletteIndex * + */ + GHOSTTY_CELL_DATA_COLOR_PALETTE = 10, + + /** + * The RGB value for the cell's background color. + * Only valid when content_tag is GHOSTTY_CELL_CONTENT_BG_COLOR_RGB. + * + * Output type: GhosttyColorRgb * + */ + GHOSTTY_CELL_DATA_COLOR_RGB = 11, + GHOSTTY_CELL_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyCellData; + +/** + * Row semantic prompt state. + * + * Indicates whether any cells in a row are part of a shell prompt, + * as reported by OSC 133 sequences. + * + * @ingroup screen + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** No prompt cells in this row. */ + GHOSTTY_ROW_SEMANTIC_NONE = 0, + + /** Prompt cells exist and this is a primary prompt line. */ + GHOSTTY_ROW_SEMANTIC_PROMPT = 1, + + /** Prompt cells exist and this is a continuation line. */ + GHOSTTY_ROW_SEMANTIC_PROMPT_CONTINUATION = 2, + GHOSTTY_ROW_SEMANTIC_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyRowSemanticPrompt; + +/** + * Row data types. + * + * These values specify what type of data to extract from a row + * using `ghostty_row_get`. + * + * @ingroup screen + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Invalid data type. Never results in any data extraction. */ + GHOSTTY_ROW_DATA_INVALID = 0, + + /** + * Whether this row is soft-wrapped. + * + * Output type: bool * + */ + GHOSTTY_ROW_DATA_WRAP = 1, + + /** + * Whether this row is a continuation of a soft-wrapped row. + * + * Output type: bool * + */ + GHOSTTY_ROW_DATA_WRAP_CONTINUATION = 2, + + /** + * Whether any cells in this row have grapheme clusters. + * + * Output type: bool * + */ + GHOSTTY_ROW_DATA_GRAPHEME = 3, + + /** + * Whether any cells in this row have styling (may have false positives). + * + * Output type: bool * + */ + GHOSTTY_ROW_DATA_STYLED = 4, + + /** + * Whether any cells in this row have hyperlinks (may have false positives). + * + * Output type: bool * + */ + GHOSTTY_ROW_DATA_HYPERLINK = 5, + + /** + * The semantic prompt state of this row. + * + * Output type: GhosttyRowSemanticPrompt * + */ + GHOSTTY_ROW_DATA_SEMANTIC_PROMPT = 6, + + /** + * Whether this row contains a Kitty virtual placeholder. + * + * Output type: bool * + */ + GHOSTTY_ROW_DATA_KITTY_VIRTUAL_PLACEHOLDER = 7, + + /** + * Whether this row is dirty and requires a redraw. + * + * Output type: bool * + */ + GHOSTTY_ROW_DATA_DIRTY = 8, + GHOSTTY_ROW_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyRowData; + +/** + * Get data from a cell. + * + * Extracts typed data from the given cell based on the specified + * data type. The output pointer must be of the appropriate type for the + * requested data kind. Valid data types and output types are documented + * in the `GhosttyCellData` enum. + * + * @param cell The cell value + * @param data The type of data to extract + * @param out Pointer to store the extracted data (type depends on data parameter) + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the + * data type is invalid + * + * @ingroup screen + */ +GHOSTTY_API GhosttyResult ghostty_cell_get(GhosttyCell cell, + GhosttyCellData data, + void *out); + +/** + * Get multiple data fields from a cell in a single call. + * + * Each element in the keys array specifies a data kind, and the + * corresponding element in the values array receives the result. + * + * Processing stops at the first error; on success out_written + * is set to count, on error it is set to the index of the + * failing key (i.e. the number of values successfully written). + * + * @param cell The cell value + * @param count Number of key/value pairs + * @param keys Array of data kinds to query + * @param values Array of output pointers (types must match each key's + * documented output type) + * @param[out] out_written On return, receives the number of values + * successfully written (may be NULL) + * @return GHOSTTY_SUCCESS if all queries succeed + * + * @ingroup screen + */ +GHOSTTY_API GhosttyResult ghostty_cell_get_multi(GhosttyCell cell, + size_t count, + const GhosttyCellData* keys, + void** values, + size_t* out_written); + +/** + * Get data from a row. + * + * Extracts typed data from the given row based on the specified + * data type. The output pointer must be of the appropriate type for the + * requested data kind. Valid data types and output types are documented + * in the `GhosttyRowData` enum. + * + * @param row The row value + * @param data The type of data to extract + * @param out Pointer to store the extracted data (type depends on data parameter) + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the + * data type is invalid + * + * @ingroup screen + */ +GHOSTTY_API GhosttyResult ghostty_row_get(GhosttyRow row, + GhosttyRowData data, + void *out); + +/** + * Get multiple data fields from a row in a single call. + * + * Each element in the keys array specifies a data kind, and the + * corresponding element in the values array receives the result. + * + * Processing stops at the first error; on success out_written + * is set to count, on error it is set to the index of the + * failing key (i.e. the number of values successfully written). + * + * @param row The row value + * @param count Number of key/value pairs + * @param keys Array of data kinds to query + * @param values Array of output pointers (types must match each key's + * documented output type) + * @param[out] out_written On return, receives the number of values + * successfully written (may be NULL) + * @return GHOSTTY_SUCCESS if all queries succeed + * + * @ingroup screen + */ +GHOSTTY_API GhosttyResult ghostty_row_get_multi(GhosttyRow row, + size_t count, + const GhosttyRowData* keys, + void** values, + size_t* out_written); + +/** @} */ + +#ifdef __cplusplus +} +#endif + +#endif /* GHOSTTY_VT_SCREEN_H */ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/selection.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/selection.h new file mode 100644 index 00000000000..3b926aab628 --- /dev/null +++ b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/selection.h @@ -0,0 +1,1061 @@ +/** + * @file selection.h + * + * Selection range type for specifying a region of terminal content. + */ + +#ifndef GHOSTTY_VT_SELECTION_H +#define GHOSTTY_VT_SELECTION_H + +#include +#include +#include +#include +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** @defgroup selection Selection + * + * A snapshot selection range defined by two grid references that identifies + * a contiguous or rectangular region of terminal content. + * + * The start and end values are GhosttyGridRef values. They are therefore + * untracked grid references and inherit the same lifetime rules: they are + * only safe to use until the next mutating operation on the terminal that + * produced them, including freeing the terminal. To keep a selection valid + * across terminal mutations, callers must maintain tracked grid references + * for the endpoints and reconstruct a GhosttySelection from fresh snapshots + * when needed. + * + * Selection gestures provide a reusable state machine for turning UI pointer + * interactions into selection snapshots. A caller creates one + * GhosttySelectionGesture per active gesture stream, reuses typed + * GhosttySelectionGestureEvent objects for synthetic press, drag, release, + * autoscroll tick, and deep-press events, and applies each event with + * ghostty_selection_gesture_event(). The returned GhosttySelection is a + * snapshot; the embedder decides whether to render it, format/copy it, or + * install it as the terminal's active selection. + * + * ## Examples + * + * @snippet c-vt-selection/src/main.c selection-main + * @snippet c-vt-selection-gesture/src/main.c selection-gesture-main + * + * @{ + */ + +/** + * Opaque handle to state for interpreting terminal selection gestures. + * + * The gesture owns only the state required to interpret pointer events. Calls + * that use a gesture are not concurrency-safe and must be serialized with + * terminal mutations. + * + * @ingroup selection + */ +typedef struct GhosttySelectionGestureImpl* GhosttySelectionGesture; + +/** + * Opaque handle to reusable input data for selection gesture operations. + * + * Event options are set with ghostty_selection_gesture_event_set(). Individual + * gesture operations document which options are required or optional. + * + * @ingroup selection + */ +typedef struct GhosttySelectionGestureEventImpl* GhosttySelectionGestureEvent; + +/** + * A snapshot selection range defined by two grid references. + * + * Both endpoints are inclusive. The endpoints preserve selection direction + * and may be reversed; callers must not assume that start is the top-left + * endpoint or that end is the bottom-right endpoint. + * + * When rectangle is false, the endpoints describe a linear selection. When + * rectangle is true, the same endpoints are interpreted as opposite corners + * of a rectangular/block selection. + * + * The start and end values are untracked GhosttyGridRef snapshots and are + * only valid until the next mutating operation on the terminal that produced + * them unless the selection is reconstructed from tracked references. + * + * This is a sized struct. Use GHOSTTY_INIT_SIZED() to initialize it. + * + * @ingroup selection + */ +typedef struct { + /** Size of this struct in bytes. Must be set to sizeof(GhosttySelection). */ + size_t size; + + /** + * Start of the selection range (inclusive). + * + * This may be after end in terminal order. It is an untracked + * GhosttyGridRef snapshot and follows untracked grid-ref lifetime rules. + */ + GhosttyGridRef start; + + /** + * End of the selection range (inclusive). + * + * This may be before start in terminal order. It is an untracked + * GhosttyGridRef snapshot and follows untracked grid-ref lifetime rules. + */ + GhosttyGridRef end; + + /** + * Whether the endpoints are interpreted as a rectangular/block selection + * rather than a linear selection. + */ + bool rectangle; +} GhosttySelection; + +/** + * Options for deriving a word selection from a terminal grid reference. + * + * This is a sized struct. Use GHOSTTY_INIT_SIZED() to initialize it. + * If boundary_codepoints is NULL and boundary_codepoints_len is 0, Ghostty's + * default word-boundary codepoints are used. If boundary_codepoints_len is + * non-zero, boundary_codepoints must not be NULL. + * + * @ingroup selection + */ +typedef struct { + /** Size of this struct in bytes. Must be set to sizeof(GhosttyTerminalSelectWordOptions). */ + size_t size; + + /** Grid reference under which to derive the word selection. */ + GhosttyGridRef ref; + + /** Optional word-boundary codepoints as uint32_t scalar values. */ + const uint32_t* boundary_codepoints; + + /** Number of entries in boundary_codepoints. */ + size_t boundary_codepoints_len; +} GhosttyTerminalSelectWordOptions; + +/** + * Options for deriving the nearest word selection between two grid references. + * + * This is a sized struct. Use GHOSTTY_INIT_SIZED() to initialize it. + * If boundary_codepoints is NULL and boundary_codepoints_len is 0, Ghostty's + * default word-boundary codepoints are used. If boundary_codepoints_len is + * non-zero, boundary_codepoints must not be NULL. + * + * @ingroup selection + */ +typedef struct { + /** Size of this struct in bytes. Must be set to sizeof(GhosttyTerminalSelectWordBetweenOptions). */ + size_t size; + + /** Starting grid reference for the inclusive search range. */ + GhosttyGridRef start; + + /** Ending grid reference for the inclusive search range. */ + GhosttyGridRef end; + + /** Optional word-boundary codepoints as uint32_t scalar values. */ + const uint32_t* boundary_codepoints; + + /** Number of entries in boundary_codepoints. */ + size_t boundary_codepoints_len; +} GhosttyTerminalSelectWordBetweenOptions; + +/** + * Options for deriving a line selection from a terminal grid reference. + * + * This is a sized struct. Use GHOSTTY_INIT_SIZED() to initialize it. + * If whitespace is NULL and whitespace_len is 0, Ghostty's default line-trim + * whitespace codepoints are used. If whitespace_len is non-zero, whitespace + * must not be NULL. + * + * @ingroup selection + */ +typedef struct { + /** Size of this struct in bytes. Must be set to sizeof(GhosttyTerminalSelectLineOptions). */ + size_t size; + + /** Grid reference under which to derive the line selection. */ + GhosttyGridRef ref; + + /** Optional codepoints to trim from the start and end of the line. */ + const uint32_t* whitespace; + + /** Number of entries in whitespace. */ + size_t whitespace_len; + + /** Whether semantic prompt state changes should bound the line selection. */ + bool semantic_prompt_boundary; +} GhosttyTerminalSelectLineOptions; + +/** + * Options for one-shot formatting of a terminal selection. + * + * This is a sized struct. Use GHOSTTY_INIT_SIZED() to initialize it. + * + * If selection is NULL, the terminal's current active selection is used. + * If selection is non-NULL, that caller-provided snapshot selection is used. + * + * The selection is formatted from the terminal's active screen using the same + * formatting semantics as GhosttyFormatter. For copy/clipboard behavior + * matching Ghostty's Screen.selectionString(), use plain output with unwrap + * and trim both set to true. + * + * @ingroup selection + */ +typedef struct { + /** Size of this struct in bytes. Must be set to sizeof(GhosttyTerminalSelectionFormatOptions). */ + size_t size; + + /** Output format to emit. */ + GhosttyFormatterFormat emit; + + /** Whether to unwrap soft-wrapped lines. */ + bool unwrap; + + /** Whether to trim trailing whitespace on non-blank lines. */ + bool trim; + + /** + * Optional selection to format. + * + * If NULL, the terminal's current active selection is used. If the terminal + * has no active selection, formatting returns GHOSTTY_NO_VALUE. + * + * If non-NULL, the pointed-to selection must be a valid snapshot selection + * for this terminal and must obey GhosttySelection lifetime rules. + */ + const GhosttySelection *selection; +} GhosttyTerminalSelectionFormatOptions; + +/** + * Ordering of a selection's endpoints in terminal coordinates. + * + * Mirrored orders are only produced by rectangular selections whose start + * and end endpoints are on opposite diagonal corners that are not simple + * top-left-to-bottom-right or bottom-right-to-top-left orderings. + * + * @ingroup selection + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Start is before end in top-left to bottom-right order. */ + GHOSTTY_SELECTION_ORDER_FORWARD = 0, + + /** End is before start in top-left to bottom-right order. */ + GHOSTTY_SELECTION_ORDER_REVERSE = 1, + + /** Rectangular selection from top-right to bottom-left. */ + GHOSTTY_SELECTION_ORDER_MIRRORED_FORWARD = 2, + + /** Rectangular selection from bottom-left to top-right. */ + GHOSTTY_SELECTION_ORDER_MIRRORED_REVERSE = 3, + + GHOSTTY_SELECTION_ORDER_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttySelectionOrder; + +/** + * Operation used to adjust a selection endpoint. + * + * Adjustment mutates the selection's logical end endpoint, not whichever + * endpoint is visually bottom/right. This preserves keyboard and drag + * behavior for both forward and reversed selections. + * + * @ingroup selection + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Move left to the previous non-empty cell, wrapping upward. */ + GHOSTTY_SELECTION_ADJUST_LEFT = 0, + + /** Move right to the next non-empty cell, wrapping downward. */ + GHOSTTY_SELECTION_ADJUST_RIGHT = 1, + + /** + * Move up one row at the current column, or to the beginning of the + * line if already at the top. + */ + GHOSTTY_SELECTION_ADJUST_UP = 2, + + /** + * Move down to the next non-blank row at the current column, or to the + * end of the line if none exists. + */ + GHOSTTY_SELECTION_ADJUST_DOWN = 3, + + /** Move to the top-left cell of the screen. */ + GHOSTTY_SELECTION_ADJUST_HOME = 4, + + /** Move to the right edge of the last non-blank row on the screen. */ + GHOSTTY_SELECTION_ADJUST_END = 5, + + /** + * Move up by one terminal page height, or to home if that would move + * past the top. + */ + GHOSTTY_SELECTION_ADJUST_PAGE_UP = 6, + + /** + * Move down by one terminal page height, or to end if that would move + * past the bottom. + */ + GHOSTTY_SELECTION_ADJUST_PAGE_DOWN = 7, + + /** Move to the left edge of the current line. */ + GHOSTTY_SELECTION_ADJUST_BEGINNING_OF_LINE = 8, + + /** Move to the right edge of the current line. */ + GHOSTTY_SELECTION_ADJUST_END_OF_LINE = 9, + + GHOSTTY_SELECTION_ADJUST_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttySelectionAdjust; + +/** + * Selection behavior chosen for a gesture's click sequence. + * + * @ingroup selection + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Cell-granular drag selection. */ + GHOSTTY_SELECTION_GESTURE_BEHAVIOR_CELL = 0, + + /** Word selection on press and word-granular drag selection. */ + GHOSTTY_SELECTION_GESTURE_BEHAVIOR_WORD = 1, + + /** Line selection on press and line-granular drag selection. */ + GHOSTTY_SELECTION_GESTURE_BEHAVIOR_LINE = 2, + + /** Semantic command output selection on press and drag. */ + GHOSTTY_SELECTION_GESTURE_BEHAVIOR_OUTPUT = 3, + + GHOSTTY_SELECTION_GESTURE_BEHAVIOR_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttySelectionGestureBehavior; + +/** + * Selection behaviors for single-, double-, and triple-click gestures. + * + * @ingroup selection + */ +typedef struct { + /** Behavior for single-click selection gestures. */ + GhosttySelectionGestureBehavior single_click; + + /** Behavior for double-click selection gestures. */ + GhosttySelectionGestureBehavior double_click; + + /** Behavior for triple-click selection gestures. */ + GhosttySelectionGestureBehavior triple_click; +} GhosttySelectionGestureBehaviors; + +/** + * Display geometry used to interpret selection gesture drag events. + * + * @ingroup selection + */ +typedef struct { + /** Number of columns in the rendered terminal grid. Must be non-zero. */ + uint32_t columns; + + /** Width of one terminal cell in surface pixels. Must be non-zero. */ + uint32_t cell_width; + + /** Left padding before the terminal grid begins in surface pixels. */ + uint32_t padding_left; + + /** Height of the rendered terminal surface in surface pixels. Must be non-zero. */ + uint32_t screen_height; +} GhosttySelectionGestureGeometry; + +/** + * Current autoscroll direction for an active selection drag gesture. + * + * @ingroup selection + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** No selection autoscroll is requested. */ + GHOSTTY_SELECTION_GESTURE_AUTOSCROLL_NONE = 0, + + /** Selection dragging should autoscroll the viewport upward. */ + GHOSTTY_SELECTION_GESTURE_AUTOSCROLL_UP = 1, + + /** Selection dragging should autoscroll the viewport downward. */ + GHOSTTY_SELECTION_GESTURE_AUTOSCROLL_DOWN = 2, + + GHOSTTY_SELECTION_GESTURE_AUTOSCROLL_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttySelectionGestureAutoscroll; + +/** + * Data fields readable from a selection gesture with + * ghostty_selection_gesture_get(). + * + * @ingroup selection + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Current click count: uint8_t*. 0 means inactive. */ + GHOSTTY_SELECTION_GESTURE_DATA_CLICK_COUNT = 0, + + /** Whether the current/last left-click gesture has dragged: bool*. */ + GHOSTTY_SELECTION_GESTURE_DATA_DRAGGED = 1, + + /** Current autoscroll request: GhosttySelectionGestureAutoscroll*. */ + GHOSTTY_SELECTION_GESTURE_DATA_AUTOSCROLL = 2, + + /** Current gesture behavior: GhosttySelectionGestureBehavior*. */ + GHOSTTY_SELECTION_GESTURE_DATA_BEHAVIOR = 3, + + /** + * Current left-click anchor: GhosttyGridRef*. + * + * Returns GHOSTTY_NO_VALUE if there is no valid active anchor. On success, + * writes an untracked GhosttyGridRef snapshot with normal GhosttyGridRef + * lifetime rules. + */ + GHOSTTY_SELECTION_GESTURE_DATA_ANCHOR = 4, + + GHOSTTY_SELECTION_GESTURE_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttySelectionGestureData; + +/** + * Selection gesture event type. + * + * The event type is fixed when the event is created. Each event type documents + * which options are valid and which options are required by gesture operations. + * + * @ingroup selection + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Press event for ghostty_selection_gesture_event(). */ + GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_PRESS = 0, + + /** Release event for ghostty_selection_gesture_event(). */ + GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_RELEASE = 1, + + /** Drag event for ghostty_selection_gesture_event(). */ + GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_DRAG = 2, + + /** Autoscroll tick event for ghostty_selection_gesture_event(). */ + GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_AUTOSCROLL_TICK = 3, + + /** Deep press event for ghostty_selection_gesture_event(). */ + GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_DEEP_PRESS = 4, + + GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttySelectionGestureEventType; + +/** + * Options stored on a reusable selection gesture event. + * + * Passing NULL as the value to ghostty_selection_gesture_event_set() clears the + * corresponding option. + * + * @ingroup selection + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** + * Grid reference under the pointer: GhosttyGridRef*. + * + * Required for PRESS and DRAG events. Optional for RELEASE events; when unset + * or cleared, release records that the pointer did not map to a valid cell. + */ + GHOSTTY_SELECTION_GESTURE_EVENT_OPT_REF = 0, + + /** + * Surface-space pointer position: GhosttySurfacePosition*. + * + * Valid for PRESS, DRAG, and AUTOSCROLL_TICK. + */ + GHOSTTY_SELECTION_GESTURE_EVENT_OPT_POSITION = 1, + + /** Maximum repeat-click distance in pixels: double*. */ + GHOSTTY_SELECTION_GESTURE_EVENT_OPT_REPEAT_DISTANCE = 2, + + /** + * Optional monotonic event time in nanoseconds: uint64_t*. + * + * If unset, press treats the event as untimed and only single-click behavior + * is available. + */ + GHOSTTY_SELECTION_GESTURE_EVENT_OPT_TIME_NS = 3, + + /** Maximum interval between repeat clicks in nanoseconds: uint64_t*. */ + GHOSTTY_SELECTION_GESTURE_EVENT_OPT_REPEAT_INTERVAL_NS = 4, + + /** + * Word-boundary codepoints: GhosttyCodepoints*. + * + * The codepoints are copied into event-owned storage when set. If unset, + * operations that need word boundaries use Ghostty's defaults. + * + * Valid for PRESS, DRAG, AUTOSCROLL_TICK, and DEEP_PRESS. + */ + GHOSTTY_SELECTION_GESTURE_EVENT_OPT_WORD_BOUNDARY_CODEPOINTS = 5, + + /** + * Selection behavior table: GhosttySelectionGestureBehaviors*. + * + * If unset, press uses the default behavior table: cell, word, line. + */ + GHOSTTY_SELECTION_GESTURE_EVENT_OPT_BEHAVIORS = 6, + + /** Whether a drag or autoscroll tick should produce a rectangular selection: bool*. */ + GHOSTTY_SELECTION_GESTURE_EVENT_OPT_RECTANGLE = 7, + + /** Drag display geometry: GhosttySelectionGestureGeometry*. Required for DRAG and AUTOSCROLL_TICK. */ + GHOSTTY_SELECTION_GESTURE_EVENT_OPT_GEOMETRY = 8, + + /** Viewport coordinate for an autoscroll tick: GhosttyPointCoordinate*. Required for AUTOSCROLL_TICK. */ + GHOSTTY_SELECTION_GESTURE_EVENT_OPT_VIEWPORT = 9, + + GHOSTTY_SELECTION_GESTURE_EVENT_OPT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttySelectionGestureEventOption; + +/** + * Create a reusable selection gesture event object. + * + * @param allocator Allocator, or NULL for the default allocator + * @param out_event Receives the created event handle + * @param type Event type. This is fixed for the lifetime of the event. + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if out_event is + * NULL or type is invalid, or GHOSTTY_OUT_OF_MEMORY if allocation fails + * + * @ingroup selection + */ +GHOSTTY_API GhosttyResult ghostty_selection_gesture_event_new( + const GhosttyAllocator* allocator, + GhosttySelectionGestureEvent* out_event, + GhosttySelectionGestureEventType type); + +/** + * Free a selection gesture event object. + * + * Passing NULL is allowed and is a no-op. + * + * @param event Selection gesture event handle to free + * + * @ingroup selection + */ +GHOSTTY_API void ghostty_selection_gesture_event_free( + GhosttySelectionGestureEvent event); + +/** + * Set or clear an option on a selection gesture event. + * + * The value type depends on option and is documented by + * GhosttySelectionGestureEventOption. Passing NULL for value clears the option. + * + * @param event Selection gesture event handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param option Event option to set or clear + * @param value Pointer to the input value for option, or NULL to clear + * @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_MEMORY if copying + * event-owned data fails, or GHOSTTY_INVALID_VALUE if event, option, or + * value is invalid + * + * @ingroup selection + */ +GHOSTTY_API GhosttyResult ghostty_selection_gesture_event_set( + GhosttySelectionGestureEvent event, + GhosttySelectionGestureEventOption option, + const void* value); + +/** + * Apply a selection gesture event and return the resulting selection snapshot. + * + * This dispatches to the gesture operation matching the event's fixed type. + * For GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_PRESS, the event must have + * GHOSTTY_SELECTION_GESTURE_EVENT_OPT_REF set before calling this function. + * All other press options use their initialized defaults when unset or cleared. + * + * For GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_RELEASE, only + * GHOSTTY_SELECTION_GESTURE_EVENT_OPT_REF is valid. It is optional; if unset or + * cleared, release records that the pointer did not map to a valid cell. Release + * events update gesture state but do not produce a selection, so this function + * returns GHOSTTY_NO_VALUE after applying them. + * + * For GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_DRAG, + * GHOSTTY_SELECTION_GESTURE_EVENT_OPT_REF and + * GHOSTTY_SELECTION_GESTURE_EVENT_OPT_GEOMETRY are required. Position, + * rectangle, and word-boundary codepoints are optional and use initialized + * defaults when unset or cleared. + * + * For GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_AUTOSCROLL_TICK, + * GHOSTTY_SELECTION_GESTURE_EVENT_OPT_VIEWPORT and + * GHOSTTY_SELECTION_GESTURE_EVENT_OPT_GEOMETRY are required. Position, + * rectangle, and word-boundary codepoints are optional and use initialized + * defaults when unset or cleared. + * + * For GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_DEEP_PRESS, only + * GHOSTTY_SELECTION_GESTURE_EVENT_OPT_WORD_BOUNDARY_CODEPOINTS is valid. It is + * optional and uses initialized defaults when unset or cleared. + * + * The returned selection is not installed as the terminal's current selection. + * It is a snapshot with the same lifetime rules as GhosttySelection. + * + * @param gesture Selection gesture handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param terminal Terminal used to interpret and update gesture state + * @param event Selection gesture event handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param[out] out_selection On success, receives the resulting selection. May + * be NULL to apply the event and discard the selection result. + * @return GHOSTTY_SUCCESS on success, GHOSTTY_NO_VALUE if the event does not + * currently produce a selection, GHOSTTY_OUT_OF_MEMORY if tracking + * gesture state fails, or GHOSTTY_INVALID_VALUE if gesture, terminal, + * event, or required event data is invalid + * + * @ingroup selection + */ +GHOSTTY_API GhosttyResult ghostty_selection_gesture_event( + GhosttySelectionGesture gesture, + GhosttyTerminal terminal, + GhosttySelectionGestureEvent event, + GhosttySelection* out_selection); + +/** + * Create a selection gesture object. + * + * The gesture stores mutable state for terminal text selection gestures. The + * gesture is not bound to a terminal at creation time; terminal-dependent APIs + * take the terminal explicitly. + * + * @param allocator Allocator, or NULL for the default allocator + * @param out_gesture Receives the created gesture handle + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if out_gesture is + * NULL, or GHOSTTY_OUT_OF_MEMORY if allocation fails + * + * @ingroup selection + */ +GHOSTTY_API GhosttyResult ghostty_selection_gesture_new( + const GhosttyAllocator* allocator, + GhosttySelectionGesture* out_gesture); + +/** + * Free a selection gesture object. + * + * This releases any tracked terminal references owned by the gesture using the + * provided terminal, then frees the gesture object. Passing NULL for gesture is + * allowed and is a no-op. + * + * If the terminal is still alive, pass the terminal most recently used with the + * gesture so any tracked terminal references can be released correctly. If the + * terminal has already been freed, pass NULL for terminal; the terminal's page + * storage has already released the underlying tracked references, so the + * gesture wrapper can be safely discarded without touching the stale terminal + * state. + * + * @param gesture Selection gesture handle to free + * @param terminal Terminal used to release tracked gesture state, or NULL if + * the terminal has already been freed + * + * @ingroup selection + */ +GHOSTTY_API void ghostty_selection_gesture_free( + GhosttySelectionGesture gesture, + GhosttyTerminal terminal); + +/** + * Reset any active selection gesture state. + * + * This cancels the active click sequence and releases any tracked terminal + * references owned by the gesture without freeing the gesture object. + * Passing NULL is allowed and is a no-op. + * + * @param gesture Selection gesture handle to reset + * @param terminal Terminal used to release tracked gesture state + * + * @ingroup selection + */ +GHOSTTY_API void ghostty_selection_gesture_reset( + GhosttySelectionGesture gesture, + GhosttyTerminal terminal); + +/** + * Read data from a selection gesture. + * + * The type of value depends on data and is documented by + * GhosttySelectionGestureData. For GHOSTTY_SELECTION_GESTURE_DATA_ANCHOR, + * the returned GhosttyGridRef is an untracked snapshot with normal grid-ref + * lifetime rules. + * + * @param gesture Selection gesture handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param terminal Terminal used to validate terminal-backed gesture state + * @param data Data field to read + * @param value Output pointer whose type depends on data + * @return GHOSTTY_SUCCESS on success, GHOSTTY_NO_VALUE if the requested data + * has no value, or GHOSTTY_INVALID_VALUE if gesture, terminal, data, or + * value is invalid + * + * @ingroup selection + */ +GHOSTTY_API GhosttyResult ghostty_selection_gesture_get( + GhosttySelectionGesture gesture, + GhosttyTerminal terminal, + GhosttySelectionGestureData data, + void* value); + +/** + * Read multiple data fields from a selection gesture in a single call. + * + * This is an optimization over calling ghostty_selection_gesture_get() multiple + * times. Each entry in values must point to storage of the type documented by + * the corresponding GhosttySelectionGestureData key. + * + * If any individual read fails, the function returns that error and writes the + * index of the failing key to out_written when out_written is non-NULL. On + * success, out_written receives count when non-NULL. + * + * @param gesture Selection gesture handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param terminal Terminal used to validate terminal-backed gesture state + * @param count Number of data fields to read + * @param keys Data fields to read (must not be NULL) + * @param values Output pointers corresponding to keys (must not be NULL) + * @param out_written Optional number of fields read, or failing index on error + * @return GHOSTTY_SUCCESS on success, GHOSTTY_NO_VALUE if a requested data + * field has no value, or GHOSTTY_INVALID_VALUE if gesture, terminal, + * keys, values, or a value pointer is invalid + * + * @ingroup selection + */ +GHOSTTY_API GhosttyResult ghostty_selection_gesture_get_multi( + GhosttySelectionGesture gesture, + GhosttyTerminal terminal, + size_t count, + const GhosttySelectionGestureData* keys, + void** values, + size_t* out_written); + +/** + * Derive a word selection snapshot from a terminal grid reference. + * + * The returned selection is not installed as the terminal's current + * selection. It is a snapshot with the same lifetime rules as GhosttySelection. + * + * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param options Word-selection options + * @param[out] out_selection On success, receives the derived selection + * @return GHOSTTY_SUCCESS on success, GHOSTTY_NO_VALUE if the valid ref has + * no selectable word content, or GHOSTTY_INVALID_VALUE if the + * terminal, options, ref, codepoint pointer, or output pointer are + * invalid. + * + * @ingroup selection + */ +GHOSTTY_API GhosttyResult ghostty_terminal_select_word( + GhosttyTerminal terminal, + const GhosttyTerminalSelectWordOptions* options, + GhosttySelection* out_selection); + +/** + * Derive the nearest word selection snapshot between two terminal grid refs. + * + * Starting at options->start, this searches toward options->end (inclusive) + * and returns the first selectable word found using Ghostty's word-selection + * rules. + * + * This is useful for implementing double-click-and-drag selection in a UI. If + * a user double-clicks one word and drags across spaces or punctuation toward + * another word, selecting only the word directly under the current pointer can + * flicker or collapse when the pointer is between words. Instead, ask for the + * nearest word between the original click and the drag point, ask again in the + * reverse direction, and combine the two word bounds into the drag selection. + * + * @snippet c-vt-selection/src/main.c selection-word-between + * + * The returned selection is not installed as the terminal's current + * selection. It is a snapshot with the same lifetime rules as GhosttySelection. + * + * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param options Word-between-selection options + * @param[out] out_selection On success, receives the derived selection + * @return GHOSTTY_SUCCESS on success, GHOSTTY_NO_VALUE if there is no + * selectable word content between the valid refs, or + * GHOSTTY_INVALID_VALUE if the terminal, options, refs, codepoint + * pointer, or output pointer are invalid. + * + * @ingroup selection + */ +GHOSTTY_API GhosttyResult ghostty_terminal_select_word_between( + GhosttyTerminal terminal, + const GhosttyTerminalSelectWordBetweenOptions* options, + GhosttySelection* out_selection); + +/** + * Derive a line selection snapshot from a terminal grid reference. + * + * The returned selection is not installed as the terminal's current + * selection. It is a snapshot with the same lifetime rules as GhosttySelection. + * + * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param options Line-selection options + * @param[out] out_selection On success, receives the derived selection + * @return GHOSTTY_SUCCESS on success, GHOSTTY_NO_VALUE if the valid ref has + * no selectable line content, or GHOSTTY_INVALID_VALUE if the + * terminal, options, ref, codepoint pointer, or output pointer are + * invalid. + * + * @ingroup selection + */ +GHOSTTY_API GhosttyResult ghostty_terminal_select_line( + GhosttyTerminal terminal, + const GhosttyTerminalSelectLineOptions* options, + GhosttySelection* out_selection); + +/** + * Derive a selection snapshot covering all selectable terminal content. + * + * The returned selection is not installed as the terminal's current + * selection. It is a snapshot with the same lifetime rules as GhosttySelection. + * + * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param[out] out_selection On success, receives the derived selection + * @return GHOSTTY_SUCCESS on success, GHOSTTY_NO_VALUE if there is no + * selectable content, or GHOSTTY_INVALID_VALUE if the terminal or + * output pointer is invalid. + * + * @ingroup selection + */ +GHOSTTY_API GhosttyResult ghostty_terminal_select_all( + GhosttyTerminal terminal, + GhosttySelection* out_selection); + +/** + * Derive a command-output selection snapshot from a terminal grid reference. + * + * The returned selection is not installed as the terminal's current + * selection. It is a snapshot with the same lifetime rules as GhosttySelection. + * + * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param ref Grid reference within command output to select + * @param[out] out_selection On success, receives the derived selection + * @return GHOSTTY_SUCCESS on success, GHOSTTY_NO_VALUE if the valid ref is + * not selectable command output, or GHOSTTY_INVALID_VALUE if the + * terminal, ref, or output pointer is invalid. + * + * @ingroup selection + */ +GHOSTTY_API GhosttyResult ghostty_terminal_select_output( + GhosttyTerminal terminal, + GhosttyGridRef ref, + GhosttySelection* out_selection); + +/** + * Format a terminal selection into a caller-provided buffer. + * + * This is a one-shot convenience API for formatting either the terminal's + * active selection or a caller-provided GhosttySelection without explicitly + * creating a GhosttyFormatter. + * + * Pass NULL for buf to query the required output size. In that case, + * out_written receives the required size and the function returns + * GHOSTTY_OUT_OF_SPACE. + * + * If buf is too small, the function returns GHOSTTY_OUT_OF_SPACE and writes + * the required size to out_written. The caller can then retry with a larger + * buffer. + * + * If options.selection is NULL and the terminal has no active selection, the + * function returns GHOSTTY_NO_VALUE. + * + * @param terminal The terminal to read from (must not be NULL) + * @param options Selection formatting options + * @param buf Output buffer, or NULL to query required size + * @param buf_len Length of buf in bytes + * @param out_written Number of bytes written, or required size on + * GHOSTTY_OUT_OF_SPACE (must not be NULL) + * @return GHOSTTY_SUCCESS on success, or an error code on failure + * + * @ingroup selection + */ +GHOSTTY_API GhosttyResult ghostty_terminal_selection_format_buf( + GhosttyTerminal terminal, + GhosttyTerminalSelectionFormatOptions options, + uint8_t* buf, + size_t buf_len, + size_t* out_written); + +/** + * Format a terminal selection into an allocated buffer. + * + * This is a one-shot convenience API for formatting either the terminal's + * active selection or a caller-provided GhosttySelection without explicitly + * creating a GhosttyFormatter. + * + * The returned buffer is allocated using allocator, or the default allocator + * if NULL is passed. The caller owns the returned buffer and must free it with + * ghostty_free(), passing the same allocator and returned length. + * + * The returned bytes are not NUL-terminated. This supports plain text, VT, and + * HTML uniformly as byte output. + * + * If options.selection is NULL and the terminal has no active selection, the + * function returns GHOSTTY_NO_VALUE and leaves out_ptr as NULL and out_len as 0. + * + * @param terminal The terminal to read from (must not be NULL) + * @param allocator Allocator used for the returned buffer, or NULL for the default allocator + * @param options Selection formatting options + * @param out_ptr Receives the allocated output buffer (must not be NULL) + * @param out_len Receives the output length in bytes (must not be NULL) + * @return GHOSTTY_SUCCESS on success, or an error code on failure + * + * @ingroup selection + */ +GHOSTTY_API GhosttyResult ghostty_terminal_selection_format_alloc( + GhosttyTerminal terminal, + const GhosttyAllocator* allocator, + GhosttyTerminalSelectionFormatOptions options, + uint8_t** out_ptr, + size_t* out_len); + +/** + * Adjust a selection snapshot using terminal selection semantics. + * + * This mutates the caller-provided GhosttySelection in place. The logical end + * endpoint is always moved, regardless of whether the selection is forward or + * reversed visually. The input selection remains a snapshot: after adjustment, + * call ghostty_terminal_set() with GHOSTTY_TERMINAL_OPT_SELECTION to install it + * as the terminal-owned selection if desired. + * + * The selection's start and end grid refs must both be valid untracked + * snapshots for the given terminal's currently active screen. In practice, + * they must come from that terminal and screen, and no mutating terminal call + * may have occurred since the refs were produced or reconstructed from + * tracked refs. Passing refs from another terminal, another screen, or stale + * refs violates this precondition. + * + * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param selection Selection snapshot to adjust in place + * @param adjustment The adjustment operation to apply + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal, + * selection, or adjustment are invalid. Selection reference validity + * is a precondition and is not checked. + * + * @ingroup selection + */ +GHOSTTY_API GhosttyResult ghostty_terminal_selection_adjust( + GhosttyTerminal terminal, + GhosttySelection* selection, + GhosttySelectionAdjust adjustment); + +/** + * Get the current endpoint ordering of a selection snapshot. + * + * The selection's start and end grid refs must both be valid untracked + * snapshots for the given terminal's currently active screen. In practice, + * they must come from that terminal and screen, and no mutating terminal call + * may have occurred since the refs were produced or reconstructed from + * tracked refs. Passing refs from another terminal, another screen, or stale + * refs violates this precondition. + * + * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param selection Selection snapshot to inspect + * @param[out] out_order On success, receives the selection order + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal, + * selection, or output pointer are invalid. Selection reference + * validity is a precondition and is not checked. + * + * @ingroup selection + */ +GHOSTTY_API GhosttyResult ghostty_terminal_selection_order( + GhosttyTerminal terminal, + const GhosttySelection* selection, + GhosttySelectionOrder* out_order); + +/** + * Return a selection snapshot with endpoints ordered as requested. + * + * Use GHOSTTY_SELECTION_ORDER_FORWARD to get top-left to bottom-right bounds, + * and GHOSTTY_SELECTION_ORDER_REVERSE to get bottom-right to top-left bounds. + * Mirrored desired orders are accepted but normalized the same as forward. + * The output selection is a fresh untracked snapshot and is not installed as + * the terminal's current selection. + * + * The selection's start and end grid refs must both be valid untracked + * snapshots for the given terminal's currently active screen. In practice, + * they must come from that terminal and screen, and no mutating terminal call + * may have occurred since the refs were produced or reconstructed from + * tracked refs. Passing refs from another terminal, another screen, or stale + * refs violates this precondition. + * + * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param selection Selection snapshot to order + * @param desired Desired endpoint order + * @param[out] out_selection On success, receives the ordered selection + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal, + * selection, desired order, or output pointer are invalid. Selection + * reference validity is a precondition and is not checked. + * + * @ingroup selection + */ +GHOSTTY_API GhosttyResult ghostty_terminal_selection_ordered( + GhosttyTerminal terminal, + const GhosttySelection* selection, + GhosttySelectionOrder desired, + GhosttySelection* out_selection); + +/** + * Test whether a terminal point is inside a selection snapshot. + * + * This uses the same selection semantics as the terminal, including + * rectangular/block selections and linear selections spanning multiple rows. + * + * The selection's start and end grid refs must both be valid untracked + * snapshots for the given terminal's currently active screen. In practice, + * they must come from that terminal and screen, and no mutating terminal call + * may have occurred since the refs were produced or reconstructed from + * tracked refs. Passing refs from another terminal, another screen, or stale + * refs violates this precondition. + * + * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param selection Selection snapshot to inspect + * @param point Point to test for containment + * @param[out] out_contains On success, receives whether point is inside selection + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal, + * selection, point, or output pointer are invalid. Selection reference + * validity is a precondition and is not checked. + * + * @ingroup selection + */ +GHOSTTY_API GhosttyResult ghostty_terminal_selection_contains( + GhosttyTerminal terminal, + const GhosttySelection* selection, + GhosttyPoint point, + bool* out_contains); + +/** + * Test whether two selection snapshots are equal. + * + * Equality uses the terminal's internal selection semantics: both endpoint + * pins must match and both selections must have the same rectangular/block + * state. This avoids requiring callers to compare raw GhosttyGridRef internals. + * + * Both selections' start and end grid refs must be valid untracked snapshots + * for the given terminal's currently active screen. In practice, they must + * come from that terminal and screen, and no mutating terminal call may have + * occurred since the refs were produced or reconstructed from tracked refs. + * Passing refs from another terminal, another screen, or stale refs violates + * this precondition. + * + * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param a First selection snapshot to compare + * @param b Second selection snapshot to compare + * @param[out] out_equal On success, receives whether the selections are equal + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal, + * selections, or output pointer are invalid. Selection reference + * validity is a precondition and is not checked. + * + * @ingroup selection + */ +GHOSTTY_API GhosttyResult ghostty_terminal_selection_equal( + GhosttyTerminal terminal, + const GhosttySelection* a, + const GhosttySelection* b, + bool* out_equal); + +/** @} */ + +#ifdef __cplusplus +} +#endif + +#endif /* GHOSTTY_VT_SELECTION_H */ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/sgr.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/sgr.h new file mode 100644 index 00000000000..8eec11dc970 --- /dev/null +++ b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/sgr.h @@ -0,0 +1,350 @@ +/** + * @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 + * + * @snippet c-vt-sgr/src/main.c sgr-basic + * + * @{ + */ + +#include +#include +#include +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * 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_ENUM_TYPED { + 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_UNDERLINE_COLOR = 8, + GHOSTTY_SGR_ATTR_UNDERLINE_COLOR_256 = 9, + GHOSTTY_SGR_ATTR_RESET_UNDERLINE_COLOR = 10, + GHOSTTY_SGR_ATTR_OVERLINE = 11, + GHOSTTY_SGR_ATTR_RESET_OVERLINE = 12, + GHOSTTY_SGR_ATTR_BLINK = 13, + GHOSTTY_SGR_ATTR_RESET_BLINK = 14, + GHOSTTY_SGR_ATTR_INVERSE = 15, + GHOSTTY_SGR_ATTR_RESET_INVERSE = 16, + GHOSTTY_SGR_ATTR_INVISIBLE = 17, + GHOSTTY_SGR_ATTR_RESET_INVISIBLE = 18, + GHOSTTY_SGR_ATTR_STRIKETHROUGH = 19, + GHOSTTY_SGR_ATTR_RESET_STRIKETHROUGH = 20, + GHOSTTY_SGR_ATTR_DIRECT_COLOR_FG = 21, + GHOSTTY_SGR_ATTR_DIRECT_COLOR_BG = 22, + GHOSTTY_SGR_ATTR_BG_8 = 23, + GHOSTTY_SGR_ATTR_FG_8 = 24, + GHOSTTY_SGR_ATTR_RESET_FG = 25, + GHOSTTY_SGR_ATTR_RESET_BG = 26, + GHOSTTY_SGR_ATTR_BRIGHT_BG_8 = 27, + GHOSTTY_SGR_ATTR_BRIGHT_FG_8 = 28, + GHOSTTY_SGR_ATTR_BG_256 = 29, + GHOSTTY_SGR_ATTR_FG_256 = 30, + GHOSTTY_SGR_ATTR_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttySgrAttributeTag; + +/** + * Underline style types. + * + * @ingroup sgr + */ +typedef enum GHOSTTY_ENUM_TYPED { + 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, + GHOSTTY_SGR_UNDERLINE_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} 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 + */ +GHOSTTY_API 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 + */ +GHOSTTY_API 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 + */ +GHOSTTY_API 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 + */ +GHOSTTY_API 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 + */ +GHOSTTY_API 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 + */ +GHOSTTY_API 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 + */ +GHOSTTY_API 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 + */ +GHOSTTY_API 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 + */ +GHOSTTY_API 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 + */ +GHOSTTY_API 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 + */ +GHOSTTY_API 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-vt/include/ghostty/vt/size_report.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/size_report.h new file mode 100644 index 00000000000..da33e5e5593 --- /dev/null +++ b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/size_report.h @@ -0,0 +1,101 @@ +/** + * @file size_report.h + * + * Size report encoding - encode terminal size reports into escape sequences. + */ + +#ifndef GHOSTTY_VT_SIZE_REPORT_H +#define GHOSTTY_VT_SIZE_REPORT_H + +/** @defgroup size_report Size Report Encoding + * + * Utilities for encoding terminal size reports into escape sequences, + * supporting in-band size reports (mode 2048) and XTWINOPS responses + * (CSI 14 t, CSI 16 t, CSI 18 t). + * + * ## Basic Usage + * + * Use ghostty_size_report_encode() to encode a size report into a + * caller-provided buffer. If the buffer is too small, the function + * returns GHOSTTY_OUT_OF_SPACE and sets the required size in the + * output parameter. + * + * ## Example + * + * @snippet c-vt-size-report/src/main.c size-report-encode + * + * @{ + */ + +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * Size report style. + * + * Determines the output format for the terminal size report. + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** In-band size report (mode 2048): ESC [ 48 ; rows ; cols ; height ; width t */ + GHOSTTY_SIZE_REPORT_MODE_2048 = 0, + /** XTWINOPS text area size in pixels: ESC [ 4 ; height ; width t */ + GHOSTTY_SIZE_REPORT_CSI_14_T = 1, + /** XTWINOPS cell size in pixels: ESC [ 6 ; height ; width t */ + GHOSTTY_SIZE_REPORT_CSI_16_T = 2, + /** XTWINOPS text area size in characters: ESC [ 8 ; rows ; cols t */ + GHOSTTY_SIZE_REPORT_CSI_18_T = 3, + GHOSTTY_SIZE_REPORT_STYLE_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttySizeReportStyle; + +/** + * Terminal size information for encoding size reports. + */ +typedef struct { + /** Terminal row count in cells. */ + uint16_t rows; + /** Terminal column count in cells. */ + uint16_t columns; + /** Width of a single terminal cell in pixels. */ + uint32_t cell_width; + /** Height of a single terminal cell in pixels. */ + uint32_t cell_height; +} GhosttySizeReportSize; + +/** + * Encode a terminal size report into an escape sequence. + * + * Encodes a size report in the format specified by @p style into the + * provided buffer. + * + * If the buffer is too small, the function returns GHOSTTY_OUT_OF_SPACE + * and writes the required buffer size to @p out_written. The caller can + * then retry with a sufficiently sized buffer. + * + * @param style The size report format to encode + * @param size Terminal size information + * @param buf Output buffer to write the encoded sequence into (may be NULL) + * @param buf_len Size of the output buffer in bytes + * @param[out] out_written On success, the number of bytes written. On + * GHOSTTY_OUT_OF_SPACE, the required buffer size. + * @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_SPACE if the buffer + * is too small + */ +GHOSTTY_API GhosttyResult ghostty_size_report_encode( + GhosttySizeReportStyle style, + GhosttySizeReportSize size, + char* buf, + size_t buf_len, + size_t* out_written); + +#ifdef __cplusplus +} +#endif + +/** @} */ + +#endif /* GHOSTTY_VT_SIZE_REPORT_H */ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/style.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/style.h new file mode 100644 index 00000000000..b6bf860ebe7 --- /dev/null +++ b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/style.h @@ -0,0 +1,139 @@ +/** + * @file style.h + * + * Terminal cell style types. + */ + +#ifndef GHOSTTY_VT_STYLE_H +#define GHOSTTY_VT_STYLE_H + +#include +#include +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** @defgroup style Style + * + * Terminal cell style attributes. + * + * A style describes the visual attributes of a terminal cell, including + * foreground, background, and underline colors, as well as flags for + * bold, italic, underline, and other text decorations. + * + * @{ + */ + +/** + * Style identifier type. + * + * Used to look up the full style from a grid reference. + * Obtain this from a cell via GHOSTTY_CELL_DATA_STYLE_ID. + * + * @ingroup style + */ +typedef uint16_t GhosttyStyleId; + +/** + * Style color tags. + * + * These values identify the type of color in a style color. + * Use the tag to determine which field in the color value union to access. + * + * @ingroup style + */ +typedef enum GHOSTTY_ENUM_TYPED { + GHOSTTY_STYLE_COLOR_NONE = 0, + GHOSTTY_STYLE_COLOR_PALETTE = 1, + GHOSTTY_STYLE_COLOR_RGB = 2, + GHOSTTY_STYLE_COLOR_TAG_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, + } GhosttyStyleColorTag; + +/** + * Style color value union. + * + * Use the tag to determine which field is active. + * + * @ingroup style + */ +typedef union { + GhosttyColorPaletteIndex palette; + GhosttyColorRgb rgb; + uint64_t _padding; +} GhosttyStyleColorValue; + +/** + * Style color (tagged union). + * + * A color used in a style attribute. Can be unset (none), a palette + * index, or a direct RGB value. + * + * @ingroup style + */ +typedef struct { + GhosttyStyleColorTag tag; + GhosttyStyleColorValue value; +} GhosttyStyleColor; + +/** + * Terminal cell style. + * + * Describes the complete visual style for a terminal cell, including + * foreground, background, and underline colors, as well as text + * decoration flags. The underline field uses the same values as + * GhosttySgrUnderline. + * + * This is a sized struct. Use GHOSTTY_INIT_SIZED() to initialize it. + * + * @ingroup style + */ +typedef struct { + size_t size; + GhosttyStyleColor fg_color; + GhosttyStyleColor bg_color; + GhosttyStyleColor underline_color; + bool bold; + bool italic; + bool faint; + bool blink; + bool inverse; + bool invisible; + bool strikethrough; + bool overline; + int underline; /**< One of GHOSTTY_SGR_UNDERLINE_* values */ +} GhosttyStyle; + +/** + * Get the default style. + * + * Initializes the style to the default values (no colors, no flags). + * + * @param style Pointer to the style to initialize + * + * @ingroup style + */ +GHOSTTY_API void ghostty_style_default(GhosttyStyle* style); + +/** + * Check if a style is the default style. + * + * Returns true if all colors are unset and all flags are off. + * + * @param style Pointer to the style to check + * @return true if the style is the default style + * + * @ingroup style + */ +GHOSTTY_API bool ghostty_style_is_default(const GhosttyStyle* style); + +#ifdef __cplusplus +} +#endif + +/** @} */ + +#endif /* GHOSTTY_VT_STYLE_H */ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/sys.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/sys.h new file mode 100644 index 00000000000..ae90596927d --- /dev/null +++ b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/sys.h @@ -0,0 +1,210 @@ +/** + * @file sys.h + * + * System interface - runtime-swappable implementations for external dependencies. + */ + +#ifndef GHOSTTY_VT_SYS_H +#define GHOSTTY_VT_SYS_H + +#include +#include +#include +#include +#include + +/** @defgroup sys System Interface + * + * Runtime-swappable function pointers for operations that depend on + * external implementations (e.g. image decoding). + * + * These are process-global settings that must be configured at startup + * before any terminal functionality that depends on them is used. + * Setting these enables various optional features of the terminal. For + * example, setting a PNG decoder enables PNG image support in the Kitty + * Graphics Protocol. + * + * Use ghostty_sys_set() with a `GhosttySysOption` to install or clear + * an implementation. Passing NULL as the value clears the implementation + * and disables the corresponding feature. + * + * ## Example + * + * ### Defining a PNG decode callback + * @snippet c-vt-kitty-graphics/src/main.c kitty-graphics-decode-png + * + * ### Installing the callback and sending a PNG image + * @snippet c-vt-kitty-graphics/src/main.c kitty-graphics-main + * + * @{ + */ + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * Result of decoding an image. + * + * The `data` buffer must be allocated through the allocator provided to + * the decode callback. The library takes ownership and will free it + * with the same allocator. + */ +typedef struct { + /** Image width in pixels. */ + uint32_t width; + + /** Image height in pixels. */ + uint32_t height; + + /** Pointer to the decoded RGBA pixel data. */ + uint8_t* data; + + /** Length of the pixel data in bytes. */ + size_t data_len; +} GhosttySysImage; + +/** + * Log severity levels for the log callback. + */ +typedef enum GHOSTTY_ENUM_TYPED { + GHOSTTY_SYS_LOG_LEVEL_ERROR = 0, + GHOSTTY_SYS_LOG_LEVEL_WARNING = 1, + GHOSTTY_SYS_LOG_LEVEL_INFO = 2, + GHOSTTY_SYS_LOG_LEVEL_DEBUG = 3, + GHOSTTY_SYS_LOG_LEVEL_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttySysLogLevel; + +/** + * Callback type for logging. + * + * When installed, internal library log messages are delivered through + * this callback instead of being discarded. The embedder is responsible + * for formatting and routing log output. + * + * @p scope is the log scope name as UTF-8 bytes (e.g. "osc", "kitty"). + * When the log is unscoped (default scope), @p scope_len is 0. + * + * All pointer arguments are only valid for the duration of the callback. + * The callback must be safe to call from any thread. + * + * @param userdata The userdata pointer set via GHOSTTY_SYS_OPT_USERDATA + * @param level The severity level of the log message + * @param scope Pointer to the scope name bytes + * @param scope_len Length of the scope name in bytes + * @param message Pointer to the log message bytes + * @param message_len Length of the log message in bytes + */ +typedef void (*GhosttySysLogFn)( + void* userdata, + GhosttySysLogLevel level, + const uint8_t* scope, + size_t scope_len, + const uint8_t* message, + size_t message_len); + +/** + * Callback type for PNG decoding. + * + * Decodes raw PNG data into RGBA pixels. The output pixel data must be + * allocated through the provided allocator. The library takes ownership + * of the buffer and will free it with the same allocator. + * + * @param userdata The userdata pointer set via GHOSTTY_SYS_OPT_USERDATA + * @param allocator The allocator to use for the output pixel buffer + * @param data Pointer to the raw PNG data + * @param data_len Length of the raw PNG data in bytes + * @param[out] out On success, filled with the decoded image + * @return true on success, false on failure + */ +typedef bool (*GhosttySysDecodePngFn)( + void* userdata, + const GhosttyAllocator* allocator, + const uint8_t* data, + size_t data_len, + GhosttySysImage* out); + +/** + * System option identifiers for ghostty_sys_set(). + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** + * Set the userdata pointer passed to all sys callbacks. + * + * Input type: void* (or NULL) + */ + GHOSTTY_SYS_OPT_USERDATA = 0, + + /** + * Set the PNG decode function. + * + * When set, the terminal can accept PNG images via the Kitty + * Graphics Protocol. When cleared (NULL value), PNG decoding is + * unsupported and PNG image data will be rejected. + * + * Input type: GhosttySysDecodePngFn (function pointer, or NULL) + */ + GHOSTTY_SYS_OPT_DECODE_PNG = 1, + + /** + * Set the log callback. + * + * When set, internal library log messages are delivered to this + * callback. When cleared (NULL value), log messages are silently + * discarded. + * + * Use ghostty_sys_log_stderr as a convenience callback that + * writes formatted messages to stderr. + * + * Which log levels are emitted depends on the build mode of the + * library and is not configurable at runtime. Debug builds emit + * all levels (debug and above). Release builds emit info and + * above; debug-level messages are compiled out entirely and will + * never reach the callback. + * + * Input type: GhosttySysLogFn (function pointer, or NULL) + */ + GHOSTTY_SYS_OPT_LOG = 2, + GHOSTTY_SYS_OPT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttySysOption; + +/** + * Set a system-level option. + * + * Configures a process-global implementation function. These should be + * set once at startup before using any terminal functionality that + * depends on them. + * + * @param option The option to set + * @param value Pointer to the value (type depends on the option), + * or NULL to clear it + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the + * option is not recognized + */ +GHOSTTY_API GhosttyResult ghostty_sys_set(GhosttySysOption option, + const void* value); + +/** + * Built-in log callback that writes to stderr. + * + * Formats each message as "[level](scope): message\n". + * Can be passed directly to ghostty_sys_set(): + * + * @code + * ghostty_sys_set(GHOSTTY_SYS_OPT_LOG, &ghostty_sys_log_stderr); + * @endcode + */ +GHOSTTY_API void ghostty_sys_log_stderr(void* userdata, + GhosttySysLogLevel level, + const uint8_t* scope, + size_t scope_len, + const uint8_t* message, + size_t message_len); + +#ifdef __cplusplus +} +#endif + +/** @} */ + +#endif /* GHOSTTY_VT_SYS_H */ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/terminal.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/terminal.h new file mode 100644 index 00000000000..b22e8aedc89 --- /dev/null +++ b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/terminal.h @@ -0,0 +1,1322 @@ +/** + * @file terminal.h + * + * Complete terminal emulator state and rendering. + */ + +#ifndef GHOSTTY_VT_TERMINAL_H +#define GHOSTTY_VT_TERMINAL_H + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** @defgroup terminal Terminal + * + * Complete terminal emulator state and rendering. + * + * A terminal instance manages the full emulator state including the screen, + * scrollback, cursor, styles, modes, and VT stream processing. + * + * Once a terminal session is up and running, you can configure a key encoder + * to write keyboard input via ghostty_key_encoder_setopt_from_terminal(). + * + * ### Example: VT stream processing + * @snippet c-vt-stream/src/main.c vt-stream-init + * @snippet c-vt-stream/src/main.c vt-stream-write + * + * ## Effects + * + * By default, the terminal sequence processing with ghostty_terminal_vt_write() + * only process sequences that directly affect terminal state and + * ignores sequences that have side effect behavior or require responses. + * These sequences include things like bell characters, title changes, device + * attributes queries, and more. To handle these sequences, the embedder + * must configure "effects." + * + * Effects are callbacks that the terminal invokes in response to VT + * sequences processed during ghostty_terminal_vt_write(). They let the + * embedding application react to terminal-initiated events such as bell + * characters, title changes, device status report responses, and more. + * + * Each effect is registered with ghostty_terminal_set() using the + * corresponding `GhosttyTerminalOption` identifier. A `NULL` value + * pointer clears the callback and disables the effect. + * + * A userdata pointer can be attached via `GHOSTTY_TERMINAL_OPT_USERDATA` + * and is passed to every callback, allowing callers to route events + * back to their own application state without global variables. + * You cannot specify different userdata for different callbacks. + * + * All callbacks are invoked synchronously during + * ghostty_terminal_vt_write(). Callbacks **must not** call + * ghostty_terminal_vt_write() on the same terminal (no reentrancy). + * And callbacks must be very careful to not block for too long or perform + * expensive operations, since they are blocking further IO processing. + * + * The available effects are: + * + * | Option | Callback Type | Trigger | + * |-----------------------------------------|-----------------------------------|-------------------------------------------| + * | `GHOSTTY_TERMINAL_OPT_WRITE_PTY` | `GhosttyTerminalWritePtyFn` | Query responses written back to the pty | + * | `GHOSTTY_TERMINAL_OPT_BELL` | `GhosttyTerminalBellFn` | BEL character (0x07) | + * | `GHOSTTY_TERMINAL_OPT_TITLE_CHANGED` | `GhosttyTerminalTitleChangedFn` | Title change via OSC 0 / OSC 2 | + * | `GHOSTTY_TERMINAL_OPT_PWD_CHANGED` | `GhosttyTerminalPwdChangedFn` | Pwd change via OSC 7 / OSC 9 / OSC 1337 | + * | `GHOSTTY_TERMINAL_OPT_ENQUIRY` | `GhosttyTerminalEnquiryFn` | ENQ character (0x05) | + * | `GHOSTTY_TERMINAL_OPT_XTVERSION` | `GhosttyTerminalXtversionFn` | XTVERSION query (CSI > q) | + * | `GHOSTTY_TERMINAL_OPT_SIZE` | `GhosttyTerminalSizeFn` | XTWINOPS size query (CSI 14/16/18 t) | + * | `GHOSTTY_TERMINAL_OPT_COLOR_SCHEME` | `GhosttyTerminalColorSchemeFn` | Color scheme query (CSI ? 996 n) | + * | `GHOSTTY_TERMINAL_OPT_DEVICE_ATTRIBUTES`| `GhosttyTerminalDeviceAttributesFn`| Device attributes query (CSI c / > c / = c)| + * + * ### Defining a write_pty callback + * @snippet c-vt-effects/src/main.c effects-write-pty + * + * ### Defining a bell callback + * @snippet c-vt-effects/src/main.c effects-bell + * + * ### Defining a title_changed callback + * @snippet c-vt-effects/src/main.c effects-title-changed + * + * ### Registering effects and processing VT data + * @snippet c-vt-effects/src/main.c effects-register + * + * ## Color Theme + * + * The terminal maintains a set of colors used for rendering: a foreground + * color, a background color, a cursor color, and a 256-color palette. Each + * of these has two layers: a **default** value set by the embedder, and an + * **override** value that programs running in the terminal can set via OSC + * escape sequences (e.g. OSC 10/11/12 for foreground/background/cursor, + * OSC 4 for individual palette entries). + * + * ### Default Colors + * + * Use ghostty_terminal_set() with the color options to configure the + * default colors. These represent the theme or configuration chosen by + * the embedder. Passing `NULL` clears the default, leaving the color + * unset. + * + * | Option | Input Type | Description | + * |-----------------------------------------|-------------------------|--------------------------------------| + * | `GHOSTTY_TERMINAL_OPT_COLOR_FOREGROUND` | `GhosttyColorRgb*` | Default foreground color | + * | `GHOSTTY_TERMINAL_OPT_COLOR_BACKGROUND` | `GhosttyColorRgb*` | Default background color | + * | `GHOSTTY_TERMINAL_OPT_COLOR_CURSOR` | `GhosttyColorRgb*` | Default cursor color | + * | `GHOSTTY_TERMINAL_OPT_COLOR_PALETTE` | `GhosttyColorRgb[256]*` | Default 256-color palette | + * + * For the palette, passing `NULL` resets to the built-in default palette. + * The palette set operation preserves any per-index OSC overrides that + * programs have applied; only unmodified indices are updated. + * + * ### Reading colors + * + * Use ghostty_terminal_get() to read colors. There are two variants for + * each color: the **effective** value (which returns the OSC override if + * one is active, otherwise the default) and the **default** value (which + * ignores any OSC overrides). + * + * | Data | Output Type | Description | + * |---------------------------------------------------|-------------------------|------------------------------------------------| + * | `GHOSTTY_TERMINAL_DATA_COLOR_FOREGROUND` | `GhosttyColorRgb*` | Effective foreground (override or default) | + * | `GHOSTTY_TERMINAL_DATA_COLOR_BACKGROUND` | `GhosttyColorRgb*` | Effective background (override or default) | + * | `GHOSTTY_TERMINAL_DATA_COLOR_CURSOR` | `GhosttyColorRgb*` | Effective cursor (override or default) | + * | `GHOSTTY_TERMINAL_DATA_COLOR_PALETTE` | `GhosttyColorRgb[256]*` | Current palette (with any OSC overrides) | + * | `GHOSTTY_TERMINAL_DATA_COLOR_FOREGROUND_DEFAULT` | `GhosttyColorRgb*` | Default foreground only (ignores OSC override) | + * | `GHOSTTY_TERMINAL_DATA_COLOR_BACKGROUND_DEFAULT` | `GhosttyColorRgb*` | Default background only (ignores OSC override) | + * | `GHOSTTY_TERMINAL_DATA_COLOR_CURSOR_DEFAULT` | `GhosttyColorRgb*` | Default cursor only (ignores OSC override) | + * | `GHOSTTY_TERMINAL_DATA_COLOR_PALETTE_DEFAULT` | `GhosttyColorRgb[256]*` | Default palette only (ignores OSC overrides) | + * + * For foreground, background, and cursor colors, the getters return + * `GHOSTTY_NO_VALUE` if no color is configured (neither a default nor an + * OSC override). The palette getters always succeed since the palette + * always has a value (the built-in default if nothing else is set). + * + * ### Setting a color theme + * @snippet c-vt-colors/src/main.c colors-set-defaults + * + * ### Reading effective and default colors + * @snippet c-vt-colors/src/main.c colors-read + * + * ### Full example with OSC overrides + * @snippet c-vt-colors/src/main.c colors-main + * + * @{ + */ + +/** + * Terminal initialization options. + * + * @ingroup terminal + */ +typedef struct { + /** Terminal width in cells. Must be greater than zero. */ + uint16_t cols; + + /** Terminal height in cells. Must be greater than zero. */ + uint16_t rows; + + /** Maximum number of lines to keep in scrollback history. */ + size_t max_scrollback; + + // TODO: Consider ABI compatibility implications of this struct. + // We may want to artificially pad it significantly to support + // future options. +} GhosttyTerminalOptions; + +/** + * Scroll viewport behavior tag. + * + * @ingroup terminal + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Scroll to the top of the scrollback. */ + GHOSTTY_SCROLL_VIEWPORT_TOP, + + /** Scroll to the bottom (active area). */ + GHOSTTY_SCROLL_VIEWPORT_BOTTOM, + + /** Scroll by a delta amount (up is negative). */ + GHOSTTY_SCROLL_VIEWPORT_DELTA, + GHOSTTY_SCROLL_VIEWPORT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyTerminalScrollViewportTag; + +/** + * Scroll viewport value. + * + * @ingroup terminal + */ +typedef union { + /** Scroll delta (only used with GHOSTTY_SCROLL_VIEWPORT_DELTA). Up is negative. */ + intptr_t delta; + + /** Padding for ABI compatibility. Do not use. */ + uint64_t _padding[2]; +} GhosttyTerminalScrollViewportValue; + +/** + * Tagged union for scroll viewport behavior. + * + * @ingroup terminal + */ +typedef struct { + GhosttyTerminalScrollViewportTag tag; + GhosttyTerminalScrollViewportValue value; +} GhosttyTerminalScrollViewport; + +/** + * Terminal screen identifier. + * + * Identifies which screen buffer is active in the terminal. + * + * @ingroup terminal + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** The primary (normal) screen. */ + GHOSTTY_TERMINAL_SCREEN_PRIMARY = 0, + + /** The alternate screen. */ + GHOSTTY_TERMINAL_SCREEN_ALTERNATE = 1, + GHOSTTY_TERMINAL_SCREEN_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyTerminalScreen; + +/** + * Visual style of the terminal cursor. + * + * @ingroup terminal + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Bar cursor (DECSCUSR 5, 6). */ + GHOSTTY_TERMINAL_CURSOR_STYLE_BAR = 0, + + /** Block cursor (DECSCUSR 1, 2). */ + GHOSTTY_TERMINAL_CURSOR_STYLE_BLOCK = 1, + + /** Underline cursor (DECSCUSR 3, 4). */ + GHOSTTY_TERMINAL_CURSOR_STYLE_UNDERLINE = 2, + + /** Hollow block cursor. */ + GHOSTTY_TERMINAL_CURSOR_STYLE_BLOCK_HOLLOW = 3, + GHOSTTY_TERMINAL_CURSOR_STYLE_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyTerminalCursorStyle; + +/** + * Scrollbar state for the terminal viewport. + * + * Represents the scrollable area dimensions needed to render a scrollbar. + * + * @ingroup terminal + */ +typedef struct { + /** Total size of the scrollable area in rows. */ + uint64_t total; + + /** Offset into the total area that the viewport is at. */ + uint64_t offset; + + /** Length of the visible area in rows. */ + uint64_t len; +} GhosttyTerminalScrollbar; + +/** + * Callback function type for bell. + * + * Called when the terminal receives a BEL character (0x07). + * + * @param terminal The terminal handle + * @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA + * + * @ingroup terminal + */ +typedef void (*GhosttyTerminalBellFn)(GhosttyTerminal terminal, + void* userdata); + +/** + * Callback function type for color scheme queries (CSI ? 996 n). + * + * Called when the terminal receives a color scheme device status report + * query. Return true and fill *out_scheme with the current color scheme, + * or return false to silently ignore the query. + * + * @param terminal The terminal handle + * @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA + * @param[out] out_scheme Pointer to store the current color scheme + * @return true if the color scheme was filled, false to ignore the query + * + * @ingroup terminal + */ +typedef bool (*GhosttyTerminalColorSchemeFn)(GhosttyTerminal terminal, + void* userdata, + GhosttyColorScheme* out_scheme); + +/** + * Callback function type for device attributes queries (DA1/DA2/DA3). + * + * Called when the terminal receives a device attributes query (CSI c, + * CSI > c, or CSI = c). Return true and fill *out_attrs with the + * response data, or return false to silently ignore the query. + * + * The terminal uses whichever sub-struct (primary, secondary, tertiary) + * matches the request type, but all three should be filled for simplicity. + * + * @param terminal The terminal handle + * @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA + * @param[out] out_attrs Pointer to store the device attributes response + * @return true if attributes were filled, false to ignore the query + * + * @ingroup terminal + */ +typedef bool (*GhosttyTerminalDeviceAttributesFn)(GhosttyTerminal terminal, + void* userdata, + GhosttyDeviceAttributes* out_attrs); + +/** + * Callback function type for enquiry (ENQ, 0x05). + * + * Called when the terminal receives an ENQ character. Return the + * response bytes as a GhosttyString. The memory must remain valid + * until the callback returns. Return a zero-length string to send + * no response. + * + * @param terminal The terminal handle + * @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA + * @return The response bytes to write back to the pty + * + * @ingroup terminal + */ +typedef GhosttyString (*GhosttyTerminalEnquiryFn)(GhosttyTerminal terminal, + void* userdata); + +/** + * Callback function type for size queries (XTWINOPS). + * + * Called in response to XTWINOPS size queries (CSI 14/16/18 t). + * Return true and fill *out_size with the current terminal geometry, + * or return false to silently ignore the query. + * + * @param terminal The terminal handle + * @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA + * @param[out] out_size Pointer to store the terminal size information + * @return true if size was filled, false to ignore the query + * + * @ingroup terminal + */ +typedef bool (*GhosttyTerminalSizeFn)(GhosttyTerminal terminal, + void* userdata, + GhosttySizeReportSize* out_size); + +/** + * Callback function type for title_changed. + * + * Called when the terminal title changes via escape sequences + * (e.g. OSC 0 or OSC 2). The new title can be queried from the + * terminal after the callback returns. + * + * @param terminal The terminal handle + * @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA + * + * @ingroup terminal + */ +typedef void (*GhosttyTerminalTitleChangedFn)(GhosttyTerminal terminal, + void* userdata); + +/** + * Callback function type for pwd_changed. + * + * Called when the terminal pwd (current working directory) changes via + * escape sequences: OSC 7 (file:// URI), OSC 9 (ConEmu CurrentDir), or + * OSC 1337 CurrentDir (iTerm2). Use ghostty_terminal_get() with + * GHOSTTY_TERMINAL_DATA_PWD inside the callback to read the new value. + * + * The terminal stores whatever bytes the shell emitted, without parsing. + * That means for OSC 7 the value is the raw URI (typically file://...); + * for OSC 9/OSC 1337 it is typically a bare path. The embedder is + * responsible for decoding any URI scheme or host if it cares about them. + * + * The callback also fires when the shell clears the pwd (e.g. an empty + * OSC 7). In that case GHOSTTY_TERMINAL_DATA_PWD returns a zero-length + * string. + * + * @param terminal The terminal handle + * @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA + * + * @ingroup terminal + */ +typedef void (*GhosttyTerminalPwdChangedFn)(GhosttyTerminal terminal, + void* userdata); + +/** + * Callback function type for write_pty. + * + * Called when the terminal needs to write data back to the pty, for + * example in response to a device status report or mode query. The + * data is only valid for the duration of the call; callers must copy + * it if it needs to persist. + * + * @param terminal The terminal handle + * @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA + * @param data Pointer to the response bytes + * @param len Length of the response in bytes + * + * @ingroup terminal + */ +typedef void (*GhosttyTerminalWritePtyFn)(GhosttyTerminal terminal, + void* userdata, + const uint8_t* data, + size_t len); + +/** + * Callback function type for XTVERSION. + * + * Called when the terminal receives an XTVERSION query (CSI > q). + * Return the version string (e.g. "myterm 1.0") as a GhosttyString. + * The memory must remain valid until the callback returns. Return a + * zero-length string to report the default "libghostty" version. + * + * @param terminal The terminal handle + * @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA + * @return The version string to report + * + * @ingroup terminal + */ +typedef GhosttyString (*GhosttyTerminalXtversionFn)(GhosttyTerminal terminal, + void* userdata); + +/** + * Terminal option identifiers. + * + * These values are used with ghostty_terminal_set() to configure + * terminal callbacks and associated state. + * + * @ingroup terminal + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** + * Opaque userdata pointer passed to all callbacks. + * + * Input type: void* + */ + GHOSTTY_TERMINAL_OPT_USERDATA = 0, + + /** + * Callback invoked when the terminal needs to write data back + * to the pty (e.g. in response to a DECRQM query or device + * status report). Set to NULL to ignore such sequences. + * + * Input type: GhosttyTerminalWritePtyFn + */ + GHOSTTY_TERMINAL_OPT_WRITE_PTY = 1, + + /** + * Callback invoked when the terminal receives a BEL character + * (0x07). Set to NULL to ignore bell events. + * + * Input type: GhosttyTerminalBellFn + */ + GHOSTTY_TERMINAL_OPT_BELL = 2, + + /** + * Callback invoked when the terminal receives an ENQ character + * (0x05). Set to NULL to send no response. + * + * Input type: GhosttyTerminalEnquiryFn + */ + GHOSTTY_TERMINAL_OPT_ENQUIRY = 3, + + /** + * Callback invoked when the terminal receives an XTVERSION query + * (CSI > q). Set to NULL to report the default "libghostty" string. + * + * Input type: GhosttyTerminalXtversionFn + */ + GHOSTTY_TERMINAL_OPT_XTVERSION = 4, + + /** + * Callback invoked when the terminal title changes via escape + * sequences (e.g. OSC 0 or OSC 2). Set to NULL to ignore title + * change events. + * + * Input type: GhosttyTerminalTitleChangedFn + */ + GHOSTTY_TERMINAL_OPT_TITLE_CHANGED = 5, + + /** + * Callback invoked in response to XTWINOPS size queries + * (CSI 14/16/18 t). Set to NULL to silently ignore size queries. + * + * Input type: GhosttyTerminalSizeFn + */ + GHOSTTY_TERMINAL_OPT_SIZE = 6, + + /** + * Callback invoked in response to a color scheme device status + * report query (CSI ? 996 n). Return true and fill the out pointer + * to report the current scheme, or return false to silently ignore. + * Set to NULL to ignore color scheme queries. + * + * Input type: GhosttyTerminalColorSchemeFn + */ + GHOSTTY_TERMINAL_OPT_COLOR_SCHEME = 7, + + /** + * Callback invoked in response to a device attributes query + * (CSI c, CSI > c, or CSI = c). Return true and fill the out + * pointer with response data, or return false to silently ignore. + * Set to NULL to ignore device attributes queries. + * + * Input type: GhosttyTerminalDeviceAttributesFn + */ + GHOSTTY_TERMINAL_OPT_DEVICE_ATTRIBUTES = 8, + + /** + * Set the terminal title manually. + * + * The string data is copied into the terminal. A NULL value pointer + * clears the title (equivalent to setting an empty string). + * + * Input type: GhosttyString* + */ + GHOSTTY_TERMINAL_OPT_TITLE = 9, + + /** + * Set the terminal working directory manually. + * + * The string data is copied into the terminal. A NULL value pointer + * clears the pwd (equivalent to setting an empty string). + * + * Input type: GhosttyString* + */ + GHOSTTY_TERMINAL_OPT_PWD = 10, + + /** + * Set the default foreground color. + * + * A NULL value pointer clears the default (unset). + * + * Input type: GhosttyColorRgb* + */ + GHOSTTY_TERMINAL_OPT_COLOR_FOREGROUND = 11, + + /** + * Set the default background color. + * + * A NULL value pointer clears the default (unset). + * + * Input type: GhosttyColorRgb* + */ + GHOSTTY_TERMINAL_OPT_COLOR_BACKGROUND = 12, + + /** + * Set the default cursor color. + * + * A NULL value pointer clears the default (unset). + * + * Input type: GhosttyColorRgb* + */ + GHOSTTY_TERMINAL_OPT_COLOR_CURSOR = 13, + + /** + * Set the default 256-color palette. + * + * The value must point to an array of exactly 256 GhosttyColorRgb values. + * A NULL value pointer resets to the built-in default palette. + * + * Input type: GhosttyColorRgb[256]* + */ + GHOSTTY_TERMINAL_OPT_COLOR_PALETTE = 14, + + /** + * Set the Kitty image storage limit in bytes. + * + * Applied to all initialized screens (primary and alternate). + * A value of zero disables the Kitty graphics protocol entirely, + * deleting all stored images and placements. A NULL value pointer + * is equivalent to zero (disables). Has no effect when Kitty graphics + * are disabled at build time. + * + * Input type: uint64_t* + */ + GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_STORAGE_LIMIT = 15, + + /** + * Enable or disable Kitty image loading via the file medium. + * + * A NULL value pointer is a no-op. Has no effect when Kitty graphics + * are disabled at build time. + * + * Input type: bool* + */ + GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_MEDIUM_FILE = 16, + + /** + * Enable or disable Kitty image loading via the temporary file medium. + * + * A NULL value pointer is a no-op. Has no effect when Kitty graphics + * are disabled at build time. + * + * Input type: bool* + */ + GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_MEDIUM_TEMP_FILE = 17, + + /** + * Enable or disable Kitty image loading via the shared memory medium. + * + * A NULL value pointer is a no-op. Has no effect when Kitty graphics + * are disabled at build time. + * + * Input type: bool* + */ + GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_MEDIUM_SHARED_MEM = 18, + + /** + * Set the maximum bytes the APC handler will buffer for all protocols. + * This prevents malicious input from causing unbounded memory allocation. + * A NULL value pointer removes all overrides, reverting to the built-in + * defaults. + * + * Input type: size_t* + */ + GHOSTTY_TERMINAL_OPT_APC_MAX_BYTES = 19, + + /** + * Set the maximum bytes the APC handler will buffer for Kitty graphics + * protocol data. A NULL value pointer removes the override, reverting + * to the built-in default. + * + * Input type: size_t* + */ + GHOSTTY_TERMINAL_OPT_APC_MAX_BYTES_KITTY = 20, + + /** + * Set the active screen selection. + * + * The value must point to a GhosttySelection whose grid references are + * valid for this terminal's active screen at the time of the call. The + * terminal copies the selection immediately and converts it to + * terminal-owned tracked state, so the GhosttySelection struct and its + * untracked grid references do not need to outlive this call. + * + * Passing NULL clears the active screen selection. + * + * Input type: GhosttySelection* + */ + GHOSTTY_TERMINAL_OPT_SELECTION = 21, + + /** + * Set the default cursor style used by DECSCUSR reset (CSI 0 q). + * + * A NULL value pointer resets to the built-in default block cursor. + * + * Input type: GhosttyTerminalCursorStyle* + */ + GHOSTTY_TERMINAL_OPT_DEFAULT_CURSOR_STYLE = 22, + + /** + * Set whether the default cursor should blink when reset by DECSCUSR + * (CSI 0 q). + * + * A NULL value pointer resets to the built-in default of not blinking. + * + * Input type: bool* + */ + GHOSTTY_TERMINAL_OPT_DEFAULT_CURSOR_BLINK = 23, + + /** + * Enable or disable Glyph Protocol APC handling. + * + * When disabled, Glyph Protocol APC sequences are ignored and no + * support/query/register/clear responses are emitted. Disabling also clears + * the terminal session's glyph glossary. A NULL value pointer is a no-op. + * + * Input type: bool* + */ + GHOSTTY_TERMINAL_OPT_GLYPH_PROTOCOL = 24, + + /** + * Callback invoked when the terminal pwd changes via escape + * sequences (OSC 7, OSC 9, or OSC 1337 CurrentDir). Set to NULL + * to ignore pwd change events. + * + * Input type: GhosttyTerminalPwdChangedFn + */ + GHOSTTY_TERMINAL_OPT_PWD_CHANGED = 25, + GHOSTTY_TERMINAL_OPT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyTerminalOption; + +/** + * Terminal data types. + * + * These values specify what type of data to extract from a terminal + * using `ghostty_terminal_get`. + * + * @ingroup terminal + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Invalid data type. Never results in any data extraction. */ + GHOSTTY_TERMINAL_DATA_INVALID = 0, + + /** + * Terminal width in cells. + * + * Output type: uint16_t * + */ + GHOSTTY_TERMINAL_DATA_COLS = 1, + + /** + * Terminal height in cells. + * + * Output type: uint16_t * + */ + GHOSTTY_TERMINAL_DATA_ROWS = 2, + + /** + * Cursor column position (0-indexed). + * + * Output type: uint16_t * + */ + GHOSTTY_TERMINAL_DATA_CURSOR_X = 3, + + /** + * Cursor row position within the active area (0-indexed). + * + * Output type: uint16_t * + */ + GHOSTTY_TERMINAL_DATA_CURSOR_Y = 4, + + /** + * Whether the cursor has a pending wrap (next print will soft-wrap). + * + * Output type: bool * + */ + GHOSTTY_TERMINAL_DATA_CURSOR_PENDING_WRAP = 5, + + /** + * The currently active screen. + * + * Output type: GhosttyTerminalScreen * + */ + GHOSTTY_TERMINAL_DATA_ACTIVE_SCREEN = 6, + + /** + * Whether the cursor is visible (DEC mode 25). + * + * Output type: bool * + */ + GHOSTTY_TERMINAL_DATA_CURSOR_VISIBLE = 7, + + /** + * Current Kitty keyboard protocol flags. + * + * Output type: GhosttyKittyKeyFlags * (uint8_t *) + */ + GHOSTTY_TERMINAL_DATA_KITTY_KEYBOARD_FLAGS = 8, + + /** + * Scrollbar state for the terminal viewport. + * + * This may be expensive to calculate depending on where the viewport + * is (arbitrary pins are expensive). The caller should take care to only + * call this as needed and not too frequently. + * + * Output type: GhosttyTerminalScrollbar * + */ + GHOSTTY_TERMINAL_DATA_SCROLLBAR = 9, + + /** + * The current SGR style of the cursor. + * + * This is the style that will be applied to newly printed characters. + * + * Output type: GhosttyStyle * + */ + GHOSTTY_TERMINAL_DATA_CURSOR_STYLE = 10, + + /** + * Whether any mouse tracking mode is active. + * + * Returns true if any of the mouse tracking modes (X10, normal, button, + * or any-event) are enabled. + * + * Output type: bool * + */ + GHOSTTY_TERMINAL_DATA_MOUSE_TRACKING = 11, + + /** + * The terminal title as set by escape sequences (e.g. OSC 0/2). + * + * Returns a borrowed string. The pointer is valid until the next call + * to ghostty_terminal_vt_write() or ghostty_terminal_reset(). An empty + * string (len=0) is returned when no title has been set. + * + * Output type: GhosttyString * + */ + GHOSTTY_TERMINAL_DATA_TITLE = 12, + + /** + * The terminal's current working directory as set by escape sequences + * (e.g. OSC 7). + * + * Returns a borrowed string. The pointer is valid until the next call + * to ghostty_terminal_vt_write() or ghostty_terminal_reset(). An empty + * string (len=0) is returned when no pwd has been set. + * + * Output type: GhosttyString * + */ + GHOSTTY_TERMINAL_DATA_PWD = 13, + + /** + * The total number of rows in the active screen including scrollback. + * + * Output type: size_t * + */ + GHOSTTY_TERMINAL_DATA_TOTAL_ROWS = 14, + + /** + * The number of scrollback rows (total rows minus viewport rows). + * + * Output type: size_t * + */ + GHOSTTY_TERMINAL_DATA_SCROLLBACK_ROWS = 15, + + /** + * The total width of the terminal in pixels. + * + * This is cols * cell_width_px as set by ghostty_terminal_resize(). + * + * Output type: uint32_t * + */ + GHOSTTY_TERMINAL_DATA_WIDTH_PX = 16, + + /** + * The total height of the terminal in pixels. + * + * This is rows * cell_height_px as set by ghostty_terminal_resize(). + * + * Output type: uint32_t * + */ + GHOSTTY_TERMINAL_DATA_HEIGHT_PX = 17, + + /** + * The effective foreground color (override or default). + * + * Returns GHOSTTY_NO_VALUE if no foreground color is set. + * + * Output type: GhosttyColorRgb * + */ + GHOSTTY_TERMINAL_DATA_COLOR_FOREGROUND = 18, + + /** + * The effective background color (override or default). + * + * Returns GHOSTTY_NO_VALUE if no background color is set. + * + * Output type: GhosttyColorRgb * + */ + GHOSTTY_TERMINAL_DATA_COLOR_BACKGROUND = 19, + + /** + * The effective cursor color (override or default). + * + * Returns GHOSTTY_NO_VALUE if no cursor color is set. + * + * Output type: GhosttyColorRgb * + */ + GHOSTTY_TERMINAL_DATA_COLOR_CURSOR = 20, + + /** + * The current 256-color palette. + * + * Output type: GhosttyColorRgb[256] * + */ + GHOSTTY_TERMINAL_DATA_COLOR_PALETTE = 21, + + /** + * The default foreground color (ignoring any OSC override). + * + * Returns GHOSTTY_NO_VALUE if no default foreground color is set. + * + * Output type: GhosttyColorRgb * + */ + GHOSTTY_TERMINAL_DATA_COLOR_FOREGROUND_DEFAULT = 22, + + /** + * The default background color (ignoring any OSC override). + * + * Returns GHOSTTY_NO_VALUE if no default background color is set. + * + * Output type: GhosttyColorRgb * + */ + GHOSTTY_TERMINAL_DATA_COLOR_BACKGROUND_DEFAULT = 23, + + /** + * The default cursor color (ignoring any OSC override). + * + * Returns GHOSTTY_NO_VALUE if no default cursor color is set. + * + * Output type: GhosttyColorRgb * + */ + GHOSTTY_TERMINAL_DATA_COLOR_CURSOR_DEFAULT = 24, + + /** + * The default 256-color palette (ignoring any OSC overrides). + * + * Output type: GhosttyColorRgb[256] * + */ + GHOSTTY_TERMINAL_DATA_COLOR_PALETTE_DEFAULT = 25, + + /** + * The Kitty image storage limit in bytes for the active screen. + * + * A value of zero means the Kitty graphics protocol is disabled. + * Returns GHOSTTY_NO_VALUE when Kitty graphics are disabled at build time. + * + * Output type: uint64_t * + */ + GHOSTTY_TERMINAL_DATA_KITTY_IMAGE_STORAGE_LIMIT = 26, + + /** + * Whether the file medium is enabled for Kitty image loading on the + * active screen. + * + * Returns GHOSTTY_NO_VALUE when Kitty graphics are disabled at build time. + * + * Output type: bool * + */ + GHOSTTY_TERMINAL_DATA_KITTY_IMAGE_MEDIUM_FILE = 27, + + /** + * Whether the temporary file medium is enabled for Kitty image loading + * on the active screen. + * + * Returns GHOSTTY_NO_VALUE when Kitty graphics are disabled at build time. + * + * Output type: bool * + */ + GHOSTTY_TERMINAL_DATA_KITTY_IMAGE_MEDIUM_TEMP_FILE = 28, + + /** + * Whether the shared memory medium is enabled for Kitty image loading + * on the active screen. + * + * Returns GHOSTTY_NO_VALUE when Kitty graphics are disabled at build time. + * + * Output type: bool * + */ + GHOSTTY_TERMINAL_DATA_KITTY_IMAGE_MEDIUM_SHARED_MEM = 29, + + /** + * The Kitty graphics image storage for the active screen. + * + * Returns a borrowed pointer to the image storage. The pointer is valid + * until the next mutating terminal call (e.g. ghostty_terminal_vt_write() + * or ghostty_terminal_reset()). + * + * Returns GHOSTTY_NO_VALUE when Kitty graphics are disabled at build time. + * + * Output type: GhosttyKittyGraphics * + */ + GHOSTTY_TERMINAL_DATA_KITTY_GRAPHICS = 30, + + /** + * The active screen's current selection. + * + * On success, writes an untracked snapshot of the terminal-owned selection + * to the caller-provided GhosttySelection. The GhosttySelection struct is + * caller-owned and may be kept, but the grid references inside it are + * untracked borrowed references into the active screen. They are only valid + * until the next mutating terminal call, such as ghostty_terminal_set(), + * ghostty_terminal_vt_write(), ghostty_terminal_resize(), or + * ghostty_terminal_reset(). + * + * Returns GHOSTTY_NO_VALUE when there is no active selection. + * + * Output type: GhosttySelection * + */ + GHOSTTY_TERMINAL_DATA_SELECTION = 31, + + /** + * Whether the viewport is currently pinned to the active area. + * + * This is true when the viewport is following the active terminal area, + * and false when the user has scrolled into history. + * + * Output type: bool * + */ + GHOSTTY_TERMINAL_DATA_VIEWPORT_ACTIVE = 32, + GHOSTTY_TERMINAL_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyTerminalData; + +/** + * Create a new terminal instance. + * + * @param allocator Pointer to allocator, or NULL to use the default allocator + * @param terminal Pointer to store the created terminal handle + * @param options Terminal initialization options + * @return GHOSTTY_SUCCESS on success, or an error code on failure + * + * @ingroup terminal + */ +GHOSTTY_API GhosttyResult ghostty_terminal_new(const GhosttyAllocator* allocator, + GhosttyTerminal* terminal, + GhosttyTerminalOptions options); + +/** + * Free a terminal instance. + * + * Releases all resources associated with the terminal. After this call, + * the terminal handle becomes invalid and must not be used. + * + * @param terminal The terminal handle to free (may be NULL) + * + * @ingroup terminal + */ +GHOSTTY_API void ghostty_terminal_free(GhosttyTerminal terminal); + +/** + * Perform a full reset of the terminal (RIS). + * + * Resets all terminal state back to its initial configuration, including + * modes, scrollback, scrolling region, and screen contents. The terminal + * dimensions are preserved. + * + * @param terminal The terminal handle (may be NULL, in which case this is a no-op) + * + * @ingroup terminal + */ +GHOSTTY_API void ghostty_terminal_reset(GhosttyTerminal terminal); + +/** + * Resize the terminal to the given dimensions. + * + * Changes the number of columns and rows in the terminal. The primary + * screen will reflow content if wraparound mode is enabled; the alternate + * screen does not reflow. If the dimensions are unchanged, this is a no-op. + * + * This also updates the terminal's pixel dimensions (used for image + * protocols and size reports), disables synchronized output mode (allowed + * by the spec so that resize results are shown immediately), and sends an + * in-band size report if mode 2048 is enabled. + * + * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param cols New width in cells (must be greater than zero) + * @param rows New height in cells (must be greater than zero) + * @param cell_width_px Width of a single cell in pixels + * @param cell_height_px Height of a single cell in pixels + * @return GHOSTTY_SUCCESS on success, or an error code on failure + * + * @ingroup terminal + */ +GHOSTTY_API GhosttyResult ghostty_terminal_resize(GhosttyTerminal terminal, + uint16_t cols, + uint16_t rows, + uint32_t cell_width_px, + uint32_t cell_height_px); + +/** + * Set an option on the terminal. + * + * Configures terminal callbacks and associated state such as the + * write_pty callback and userdata pointer. The value is passed + * directly for pointer types (callbacks, userdata) or as a pointer + * to the value for non-pointer types (e.g. GhosttyString*). + * NULL clears the option to its default. + * + * Callbacks are invoked synchronously during ghostty_terminal_vt_write(). + * Callbacks must not call ghostty_terminal_vt_write() on the same + * terminal (no reentrancy). + * + * @param terminal The terminal handle (may be NULL, in which case this is a no-op) + * @param option The option to set + * @param value Pointer to the value to set (type depends on the option), + * or NULL to clear the option + * + * @ingroup terminal + */ +GHOSTTY_API GhosttyResult ghostty_terminal_set(GhosttyTerminal terminal, + GhosttyTerminalOption option, + const void* value); + +/** + * Write VT-encoded data to the terminal for processing. + * + * Feeds raw bytes through the terminal's VT stream parser, updating + * terminal state accordingly. By default, sequences that require output + * (queries, device status reports) are silently ignored. Use + * ghostty_terminal_set() with GHOSTTY_TERMINAL_OPT_WRITE_PTY to install + * a callback that receives response data. + * + * This never fails. Any erroneous input or errors in processing the + * input are logged internally but do not cause this function to fail + * because this input is assumed to be untrusted and from an external + * source; so the primary goal is to keep the terminal state consistent and + * not allow malformed input to corrupt or crash. + * + * @param terminal The terminal handle + * @param data Pointer to the data to write + * @param len Length of the data in bytes + * + * @ingroup terminal + */ +GHOSTTY_API void ghostty_terminal_vt_write(GhosttyTerminal terminal, + const uint8_t* data, + size_t len); + +/** + * Scroll the terminal viewport. + * + * Scrolls the terminal's viewport according to the given behavior. + * When using GHOSTTY_SCROLL_VIEWPORT_DELTA, set the delta field in + * the value union to specify the number of rows to scroll (negative + * for up, positive for down). For other behaviors, the value is ignored. + * + * @param terminal The terminal handle (may be NULL, in which case this is a no-op) + * @param behavior The scroll behavior as a tagged union + * + * @ingroup terminal + */ +GHOSTTY_API void ghostty_terminal_scroll_viewport(GhosttyTerminal terminal, + GhosttyTerminalScrollViewport behavior); + +/** + * Get the current value of a terminal mode. + * + * Returns the value of the mode identified by the given mode. + * + * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param mode The mode identifying the mode to query + * @param[out] out_value On success, set to true if the mode is set, false + * if it is reset + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal + * is NULL or the mode does not correspond to a known mode + * + * @ingroup terminal + */ +GHOSTTY_API GhosttyResult ghostty_terminal_mode_get(GhosttyTerminal terminal, + GhosttyMode mode, + bool* out_value); + +/** + * Set the value of a terminal mode. + * + * Sets the mode identified by the given mode to the specified value. + * + * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param mode The mode identifying the mode to set + * @param value true to set the mode, false to reset it + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal + * is NULL or the mode does not correspond to a known mode + * + * @ingroup terminal + */ +GHOSTTY_API GhosttyResult ghostty_terminal_mode_set(GhosttyTerminal terminal, + GhosttyMode mode, + bool value); + +/** + * Get data from a terminal instance. + * + * Extracts typed data from the given terminal based on the specified + * data type. The output pointer must be of the appropriate type for the + * requested data kind. Valid data types and output types are documented + * in the `GhosttyTerminalData` enum. + * + * @param terminal The terminal handle (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 GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal + * is NULL or the data type is invalid + * + * @ingroup terminal + */ +GHOSTTY_API GhosttyResult ghostty_terminal_get(GhosttyTerminal terminal, + GhosttyTerminalData data, + void *out); + +/** + * Get multiple data fields from a terminal in a single call. + * + * This is an optimization over calling ghostty_terminal_get() + * repeatedly, particularly useful in environments with high per-call + * overhead such as FFI or Cgo. + * + * Each element in the keys array specifies a data kind, and the + * corresponding element in the values array receives the result. + * The type of each values[i] pointer must match the output type + * documented for keys[i]. + * + * Processing stops at the first error; on success out_written + * is set to count, on error it is set to the index of the + * failing key (i.e. the number of values successfully written). + * + * @param terminal The terminal handle (may be NULL) + * @param count Number of key/value pairs + * @param keys Array of data kinds to query + * @param values Array of output pointers (types must match each key's + * documented output type) + * @param[out] out_written On return, receives the number of values + * successfully written (may be NULL) + * @return GHOSTTY_SUCCESS if all queries succeed + * + * @ingroup terminal + */ +GHOSTTY_API GhosttyResult ghostty_terminal_get_multi(GhosttyTerminal terminal, + size_t count, + const GhosttyTerminalData* keys, + void** values, + size_t* out_written); + +/** + * Resolve a point in the terminal grid to a grid reference. + * + * Resolves the given point (which can be in active, viewport, screen, + * or history coordinates) to a grid reference for that location. Use + * ghostty_grid_ref_cell() and ghostty_grid_ref_row() to extract the cell + * and row. + * + * Lookups using the `active` and `viewport` tags are fast. The `screen` + * and `history` tags may require traversing the full scrollback page list + * to resolve the y coordinate, so they can be expensive for large + * scrollback buffers. + * + * This function isn't meant to be used as the core of render loop. It + * isn't built to sustain the framerates needed for rendering large screens. + * Use the render state API for that. This API is instead meant for less + * strictly performance-sensitive use cases. + * + * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param point The point specifying which cell to look up + * @param[out] out_ref On success, set to the grid reference at the given point (may be NULL) + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal + * is NULL or the point is out of bounds + * + * @ingroup terminal + */ +GHOSTTY_API GhosttyResult ghostty_terminal_grid_ref(GhosttyTerminal terminal, + GhosttyPoint point, + GhosttyGridRef *out_ref); + +/** + * Create an owned tracked grid reference for a terminal point. + * + * This is the tracked variant of ghostty_terminal_grid_ref(). The returned + * handle follows the referenced cell as the terminal's page list is modified: + * scrolling, pruning, resize/reflow, and other page-list operations update the + * tracked reference automatically. + * + * The reference is attached to the terminal screen/page-list that is active at + * creation time. + * + * If the point is outside the requested coordinate space, this returns + * GHOSTTY_INVALID_VALUE and writes NULL to out_ref. + * + * The returned handle must be freed with ghostty_tracked_grid_ref_free(). If + * the terminal is freed first, the handle remains valid only for + * tracked-grid-ref APIs: it reports no value and can still be freed. + * + * @param terminal Terminal instance. + * @param point Point to track. + * @param[out] out_ref On success, receives the tracked reference handle. + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if terminal, + * point, or out_ref is invalid, or GHOSTTY_OUT_OF_MEMORY if allocation + * fails. + * + * @ingroup terminal + */ +GHOSTTY_API GhosttyResult ghostty_terminal_grid_ref_track( + GhosttyTerminal terminal, + GhosttyPoint point, + GhosttyTrackedGridRef *out_ref); + +/** + * Convert a grid reference back to a point in the given coordinate system. + * + * This is the inverse of ghostty_terminal_grid_ref(): given a grid reference, + * it returns the x/y coordinates in the requested coordinate system (active, + * viewport, screen, or history). + * + * The grid reference must have been obtained from the same terminal instance. + * Like all grid references, it is only valid until the next mutating terminal + * call. + * + * Not every grid reference is representable in every coordinate system. For + * example, a cell in scrollback history cannot be expressed in active + * coordinates, and a cell that has scrolled off the visible area cannot be + * expressed in viewport coordinates. In these cases, the function returns + * GHOSTTY_NO_VALUE. + * + * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param ref Pointer to the grid reference to convert + * @param tag The target coordinate system + * @param[out] out On success, set to the coordinate in the requested system (may be NULL) + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal + * or ref is NULL/invalid, GHOSTTY_NO_VALUE if the ref falls outside + * the requested coordinate system + * + * @ingroup terminal + */ +GHOSTTY_API GhosttyResult ghostty_terminal_point_from_grid_ref( + GhosttyTerminal terminal, + const GhosttyGridRef *ref, + GhosttyPointTag tag, + GhosttyPointCoordinate *out); + +/** @} */ + +#ifdef __cplusplus +} +#endif + +#endif /* GHOSTTY_VT_TERMINAL_H */ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/types.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/types.h new file mode 100644 index 00000000000..214d282296c --- /dev/null +++ b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/types.h @@ -0,0 +1,335 @@ +/** + * @file types.h + * + * Common types, macros, and utilities for libghostty-vt. + */ + +#ifndef GHOSTTY_VT_TYPES_H +#define GHOSTTY_VT_TYPES_H + +#include +#include +#include + +// 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 + +/** + * Enum int-sizing helpers. + * + * The Zig side backs all C enums with c_int, so the C declarations + * must use int as their underlying type to maintain ABI compatibility. + * + * C23 (detected via __STDC_VERSION__ >= 202311L) supports explicit + * enum underlying types with `enum : int { ... }`. For pre-C23 + * compilers, which are free to choose any type that can represent + * all values (C11 §6.7.2.2), we add an INT_MAX sentinel as the last + * entry to force the compiler to use int. + * + * INT_MAX is used rather than a fixed constant like 0xFFFFFFFF + * because enum constants must have type int (which is signed). + * Values above INT_MAX overflow signed int and are a constraint + * violation in standard C; compilers that accept them interpret them + * as negative values via two's complement, which can collide with + * legitimate negative enum values. + * + * Usage: + * @code + * typedef enum GHOSTTY_ENUM_TYPED { + * FOO_A = 0, + * FOO_B = 1, + * FOO_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, + * } Foo; + * @endcode + */ +#if defined(__STDC_VERSION__) && __STDC_VERSION__ >= 202311L +#define GHOSTTY_ENUM_TYPED : int +#else +#define GHOSTTY_ENUM_TYPED +#endif +#define GHOSTTY_ENUM_MAX_VALUE INT_MAX + +/** + * Result codes for libghostty-vt operations. + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** 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, + /** Operation failed because the provided buffer was too small */ + GHOSTTY_OUT_OF_SPACE = -3, + /** The requested value has no value */ + GHOSTTY_NO_VALUE = -4, + GHOSTTY_RESULT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyResult; + +/* ---- Opaque handles ---- */ + +/** + * Opaque handle to a terminal instance. + * + * @ingroup terminal + */ +typedef struct GhosttyTerminalImpl* GhosttyTerminal; + +/** + * Opaque handle to a tracked grid reference. + * + * A tracked grid reference is owned by the caller and must be freed with + * ghostty_tracked_grid_ref_free(). If the terminal that created it is freed + * first, the handle remains valid only for tracked-grid-ref APIs: it reports no + * value and can still be freed. + * + * @ingroup grid_ref + */ +typedef struct GhosttyTrackedGridRefImpl* GhosttyTrackedGridRef; + +/** + * Opaque handle to a Kitty graphics image storage. + * + * Obtained via ghostty_terminal_get() with + * GHOSTTY_TERMINAL_DATA_KITTY_GRAPHICS. The pointer is borrowed from + * the terminal and remains valid until the next mutating terminal call + * (e.g. ghostty_terminal_vt_write() or ghostty_terminal_reset()). + * + * @ingroup kitty_graphics + */ +typedef struct GhosttyKittyGraphicsImpl* GhosttyKittyGraphics; + +/** + * Opaque handle to a Kitty graphics image. + * + * Obtained via ghostty_kitty_graphics_image() with an image ID. The + * pointer is borrowed from the storage and remains valid until the next + * mutating terminal call. + * + * @ingroup kitty_graphics + */ +typedef const struct GhosttyKittyGraphicsImageImpl* GhosttyKittyGraphicsImage; + +/** + * Opaque handle to a Kitty graphics placement iterator. + * + * @ingroup kitty_graphics + */ +typedef struct GhosttyKittyGraphicsPlacementIteratorImpl* GhosttyKittyGraphicsPlacementIterator; + +/** + * Opaque handle to a render state instance. + * + * @ingroup render + */ +typedef struct GhosttyRenderStateImpl* GhosttyRenderState; + +/** + * Opaque handle to a render-state row iterator. + * + * @ingroup render + */ +typedef struct GhosttyRenderStateRowIteratorImpl* GhosttyRenderStateRowIterator; + +/** + * Opaque handle to render-state row cells. + * + * @ingroup render + */ +typedef struct GhosttyRenderStateRowCellsImpl* GhosttyRenderStateRowCells; + +/** + * 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 GhosttySgrParserImpl* GhosttySgrParser; + +/** + * Opaque handle to a formatter instance. + * + * @ingroup formatter + */ +typedef struct GhosttyFormatterImpl* GhosttyFormatter; + +/** + * 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 GhosttyOscParserImpl* 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 GhosttyOscCommandImpl* GhosttyOscCommand; + +/* ---- Common value types ---- */ + +/** + * Terminal content output format. + * + * @ingroup formatter + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Plain text (no escape sequences). */ + GHOSTTY_FORMATTER_FORMAT_PLAIN, + + /** VT sequences preserving colors, styles, URLs, etc. */ + GHOSTTY_FORMATTER_FORMAT_VT, + + /** HTML with inline styles. */ + GHOSTTY_FORMATTER_FORMAT_HTML, + GHOSTTY_FORMATTER_FORMAT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyFormatterFormat; + +/** + * A borrowed byte string (pointer + length). + * + * The memory is not owned by this struct. The pointer is only valid + * for the lifetime documented by the API that produces or consumes it. + */ +typedef struct { + /** Pointer to the string bytes. */ + const uint8_t* ptr; + + /** Length of the string in bytes. */ + size_t len; +} GhosttyString; + +/** + * A caller-provided byte buffer. + * + * APIs that write to this type use `len` for the number of bytes written on + * GHOSTTY_SUCCESS and the required byte capacity on GHOSTTY_OUT_OF_SPACE. + */ +typedef struct { + /** Destination buffer for bytes. May be NULL when cap is 0 to query required size. */ + uint8_t* ptr; + + /** Capacity of ptr in bytes. */ + size_t cap; + + /** Bytes written on success, or required byte capacity on GHOSTTY_OUT_OF_SPACE. */ + size_t len; +} GhosttyBuffer; + +/** + * A surface-space position in pixels. + * + * This is not a terminal grid coordinate. It represents an x/y position in the + * rendered surface coordinate space, with (0, 0) at the top-left of the + * surface. + */ +typedef struct { + /** X position in surface pixels. */ + double x; + + /** Y position in surface pixels. */ + double y; +} GhosttySurfacePosition; + +/** + * A borrowed list of Unicode scalar values. + * + * Values are encoded as uint32_t scalar values. The memory is not owned by this + * struct. The pointer is only valid for the lifetime documented by the API that + * consumes or produces it. + * + * APIs may document special handling for NULL + len 0, such as “use defaults”. + */ +typedef struct { + /** Pointer to Unicode scalar values. */ + const uint32_t* ptr; + + /** Number of entries in ptr. */ + size_t len; +} GhosttyCodepoints; + +/** + * Initialize a sized struct to zero and set its size field. + * + * Sized structs use a `size` field as the first member for ABI + * compatibility. This macro zero-initializes the struct and sets the + * size field to `sizeof(type)`, which allows the library to detect + * which version of the struct the caller was compiled against. + * + * @param type The struct type to initialize + * @return A zero-initialized struct with the size field set + * + * Example: + * @code + * GhosttyFormatterTerminalOptions opts = GHOSTTY_INIT_SIZED(GhosttyFormatterTerminalOptions); + * opts.emit = GHOSTTY_FORMATTER_FORMAT_PLAIN; + * opts.trim = true; + * @endcode + */ +#define GHOSTTY_INIT_SIZED(type) \ + ((type){ .size = sizeof(type) }) + +/** + * Return a pointer to a null-terminated JSON string describing the + * layout of every C API struct for the current target. + * + * This is primarily useful for language bindings that can't easily + * set C struct fields and need to do so via byte offsets. For example, + * WebAssembly modules can't share struct definitions with the host. + * + * Example (abbreviated): + * @code{.json} + * { + * "GhosttyMouseEncoderSize": { + * "size": 40, + * "align": 8, + * "fields": { + * "size": { "offset": 0, "size": 8, "type": "u64" }, + * "screen_width": { "offset": 8, "size": 4, "type": "u32" }, + * "screen_height": { "offset": 12, "size": 4, "type": "u32" }, + * "cell_width": { "offset": 16, "size": 4, "type": "u32" }, + * "cell_height": { "offset": 20, "size": 4, "type": "u32" }, + * "padding_top": { "offset": 24, "size": 4, "type": "u32" }, + * "padding_bottom": { "offset": 28, "size": 4, "type": "u32" }, + * "padding_right": { "offset": 32, "size": 4, "type": "u32" }, + * "padding_left": { "offset": 36, "size": 4, "type": "u32" } + * } + * } + * } + * @endcode + * + * The returned pointer is valid for the lifetime of the process. + * + * @return Pointer to the null-terminated JSON string. + */ +GHOSTTY_API const char *ghostty_type_json(void); + +#endif /* GHOSTTY_VT_TYPES_H */ diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/wasm.h b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/wasm.h new file mode 100644 index 00000000000..e2b63e2c601 --- /dev/null +++ b/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/wasm.h @@ -0,0 +1,160 @@ +/** + * @file wasm.h + * + * WebAssembly utility functions for libghostty-vt. + */ + +#ifndef GHOSTTY_VT_WASM_H +#define GHOSTTY_VT_WASM_H + +#ifdef __wasm__ + +#include +#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 + */ +GHOSTTY_API 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 + */ +GHOSTTY_API 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 + */ +GHOSTTY_API 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 + */ +GHOSTTY_API 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 + */ +GHOSTTY_API 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 + */ +GHOSTTY_API 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 + */ +GHOSTTY_API 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 + */ +GHOSTTY_API 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 + */ +GHOSTTY_API 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 + */ +GHOSTTY_API void ghostty_wasm_free_usize(size_t *ptr); + +/** @} */ + +#endif /* __wasm__ */ + +#endif /* GHOSTTY_VT_WASM_H */ diff --git a/apps/mobile/modules/t3-terminal/android/.gitignore b/apps/mobile/modules/t3-terminal/android/.gitignore new file mode 100644 index 00000000000..3166b90b042 --- /dev/null +++ b/apps/mobile/modules/t3-terminal/android/.gitignore @@ -0,0 +1 @@ +.cxx/ diff --git a/apps/mobile/modules/t3-terminal/android/build.gradle b/apps/mobile/modules/t3-terminal/android/build.gradle index 90c0d4fc21e..0da0777cf4f 100644 --- a/apps/mobile/modules/t3-terminal/android/build.gradle +++ b/apps/mobile/modules/t3-terminal/android/build.gradle @@ -6,11 +6,25 @@ version = '0.0.0' android { namespace 'expo.modules.t3terminal' + ndkVersion rootProject.ext.ndkVersion compileSdk rootProject.ext.compileSdkVersion defaultConfig { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion + + externalNativeBuild { + cmake { + cppFlags '-std=c++17 -Wall -Wextra -Werror' + } + } + } + + externalNativeBuild { + cmake { + path 'src/main/cpp/CMakeLists.txt' + version '3.22.1' + } } } diff --git a/apps/mobile/modules/t3-terminal/android/src/main/assets/fonts/MesloLGS-NF-Bold.ttf b/apps/mobile/modules/t3-terminal/android/src/main/assets/fonts/MesloLGS-NF-Bold.ttf new file mode 100644 index 00000000000..e0e39544b02 Binary files /dev/null and b/apps/mobile/modules/t3-terminal/android/src/main/assets/fonts/MesloLGS-NF-Bold.ttf differ diff --git a/apps/mobile/modules/t3-terminal/android/src/main/assets/fonts/MesloLGS-NF-Regular.ttf b/apps/mobile/modules/t3-terminal/android/src/main/assets/fonts/MesloLGS-NF-Regular.ttf new file mode 100644 index 00000000000..88b81490cd7 Binary files /dev/null and b/apps/mobile/modules/t3-terminal/android/src/main/assets/fonts/MesloLGS-NF-Regular.ttf differ diff --git a/apps/mobile/modules/t3-terminal/android/src/main/cpp/CMakeLists.txt b/apps/mobile/modules/t3-terminal/android/src/main/cpp/CMakeLists.txt new file mode 100644 index 00000000000..0273ff6c864 --- /dev/null +++ b/apps/mobile/modules/t3-terminal/android/src/main/cpp/CMakeLists.txt @@ -0,0 +1,23 @@ +cmake_minimum_required(VERSION 3.22.1) + +project(t3terminal LANGUAGES CXX) + +add_library(ghostty-vt SHARED IMPORTED) +set_target_properties( + ghostty-vt + PROPERTIES IMPORTED_LOCATION + "${CMAKE_CURRENT_SOURCE_DIR}/../jniLibs/${ANDROID_ABI}/libghostty-vt.so" +) + +add_library(t3terminal SHARED t3_terminal_jni.cpp) + +target_include_directories( + t3terminal + PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/../../../../Vendor/libghostty-vt/include" +) + +target_link_options( + t3terminal PRIVATE "-Wl,-z,common-page-size=16384" "-Wl,-z,max-page-size=16384" +) + +target_link_libraries(t3terminal PRIVATE ghostty-vt log) diff --git a/apps/mobile/modules/t3-terminal/android/src/main/cpp/t3_terminal_jni.cpp b/apps/mobile/modules/t3-terminal/android/src/main/cpp/t3_terminal_jni.cpp new file mode 100644 index 00000000000..95760e8ead9 --- /dev/null +++ b/apps/mobile/modules/t3-terminal/android/src/main/cpp/t3_terminal_jni.cpp @@ -0,0 +1,516 @@ +#include + +#include +#include +#include +#include +#include + +#include + +namespace { + +constexpr uint32_t kSnapshotMagic = 0x54563354; // "T3VT" in little endian. +constexpr uint16_t kSnapshotVersion = 1; +constexpr size_t kMaxScrollbackRows = 10000; + +enum CellFlag : uint16_t { + kBold = 1 << 0, + kItalic = 1 << 1, + kFaint = 1 << 2, + kInverse = 1 << 3, + kInvisible = 1 << 4, + kStrikethrough = 1 << 5, + kOverline = 1 << 6, + kUnderline = 1 << 7, + kSelected = 1 << 8, +}; + +struct Session { + GhosttyTerminal terminal = nullptr; + GhosttyRenderState render_state = nullptr; + GhosttyRenderStateRowIterator row_iterator = nullptr; + GhosttyRenderStateRowCells row_cells = nullptr; + std::vector responses; + std::mutex mutex; +}; + +class ByteWriter { + public: + explicit ByteWriter(size_t capacity) { bytes_.reserve(capacity); } + + void U8(uint8_t value) { bytes_.push_back(value); } + + void U16(uint16_t value) { + U8(static_cast(value)); + U8(static_cast(value >> 8)); + } + + void U32(uint32_t value) { + U16(static_cast(value)); + U16(static_cast(value >> 16)); + } + + void Bytes(const std::vector& value) { + bytes_.insert(bytes_.end(), value.begin(), value.end()); + } + + std::vector Take() { return std::move(bytes_); } + + private: + std::vector bytes_; +}; + +Session* FromHandle(jlong handle) { + return reinterpret_cast(static_cast(handle)); +} + +jbyteArray ToJavaBytes(JNIEnv* env, const std::vector& bytes) { + auto result = env->NewByteArray(static_cast(bytes.size())); + if (result != nullptr && !bytes.empty()) { + env->SetByteArrayRegion(result, 0, static_cast(bytes.size()), + reinterpret_cast(bytes.data())); + } + return result; +} + +GhosttyColorRgb RgbFromArgb(jint color) { + const auto value = static_cast(color); + return { + .r = static_cast(value >> 16), + .g = static_cast(value >> 8), + .b = static_cast(value), + }; +} + +uint32_t ArgbFromRgb(GhosttyColorRgb color) { + return 0xFF000000U | (static_cast(color.r) << 16U) | + (static_cast(color.g) << 8U) | color.b; +} + +GhosttyColorRgb Blend(GhosttyColorRgb foreground, GhosttyColorRgb background, + uint8_t foreground_weight) { + const auto blend = [foreground_weight](uint8_t front, uint8_t back) { + const uint16_t back_weight = 255 - foreground_weight; + return static_cast((front * foreground_weight + back * back_weight) / 255); + }; + return { + .r = blend(foreground.r, background.r), + .g = blend(foreground.g, background.g), + .b = blend(foreground.b, background.b), + }; +} + +void AppendUtf8(std::vector* output, uint32_t codepoint) { + if (codepoint <= 0x7F) { + output->push_back(static_cast(codepoint)); + } else if (codepoint <= 0x7FF) { + output->push_back(static_cast(0xC0 | (codepoint >> 6))); + output->push_back(static_cast(0x80 | (codepoint & 0x3F))); + } else if (codepoint <= 0xFFFF && !(codepoint >= 0xD800 && codepoint <= 0xDFFF)) { + output->push_back(static_cast(0xE0 | (codepoint >> 12))); + output->push_back(static_cast(0x80 | ((codepoint >> 6) & 0x3F))); + output->push_back(static_cast(0x80 | (codepoint & 0x3F))); + } else if (codepoint <= 0x10FFFF) { + output->push_back(static_cast(0xF0 | (codepoint >> 18))); + output->push_back(static_cast(0x80 | ((codepoint >> 12) & 0x3F))); + output->push_back(static_cast(0x80 | ((codepoint >> 6) & 0x3F))); + output->push_back(static_cast(0x80 | (codepoint & 0x3F))); + } +} + +void OnWritePty(GhosttyTerminal, void* userdata, const uint8_t* data, size_t len) { + auto* session = static_cast(userdata); + if (session == nullptr || data == nullptr || len == 0) return; + session->responses.insert(session->responses.end(), data, data + len); +} + +void ApplyTheme(Session* session, jint foreground, jint background, jint cursor, + JNIEnv* env, jintArray palette_array) { + auto foreground_rgb = RgbFromArgb(foreground); + auto background_rgb = RgbFromArgb(background); + auto cursor_rgb = RgbFromArgb(cursor); + ghostty_terminal_set(session->terminal, GHOSTTY_TERMINAL_OPT_COLOR_FOREGROUND, + &foreground_rgb); + ghostty_terminal_set(session->terminal, GHOSTTY_TERMINAL_OPT_COLOR_BACKGROUND, + &background_rgb); + ghostty_terminal_set(session->terminal, GHOSTTY_TERMINAL_OPT_COLOR_CURSOR, &cursor_rgb); + + if (palette_array == nullptr) return; + const auto palette_length = env->GetArrayLength(palette_array); + if (palette_length <= 0) return; + + GhosttyColorRgb palette[256]; + if (ghostty_terminal_get(session->terminal, + GHOSTTY_TERMINAL_DATA_COLOR_PALETTE_DEFAULT, + palette) != GHOSTTY_SUCCESS) { + return; + } + + const auto copied_length = std::min(palette_length, 256); + std::vector colors(static_cast(copied_length)); + env->GetIntArrayRegion(palette_array, 0, copied_length, colors.data()); + for (jsize index = 0; index < copied_length; ++index) { + palette[index] = RgbFromArgb(colors[index]); + } + ghostty_terminal_set(session->terminal, GHOSTTY_TERMINAL_OPT_COLOR_PALETTE, palette); +} + +void FreeSession(Session* session) { + if (session == nullptr) return; + ghostty_render_state_row_cells_free(session->row_cells); + ghostty_render_state_row_iterator_free(session->row_iterator); + ghostty_render_state_free(session->render_state); + ghostty_terminal_free(session->terminal); + delete session; +} + +std::vector DrainResponses(Session* session) { + std::vector responses; + responses.swap(session->responses); + return responses; +} + +bool ViewportGridRef(Session* session, jint x, jint y, GhosttyGridRef* out) { + *out = GhosttyGridRef{}; + out->size = sizeof(*out); + GhosttyPoint point{}; + point.tag = GHOSTTY_POINT_TAG_VIEWPORT; + point.value.coordinate.x = static_cast(std::max(x, 0)); + point.value.coordinate.y = static_cast(std::max(y, 0)); + return ghostty_terminal_grid_ref(session->terminal, point, out) == GHOSTTY_SUCCESS; +} + +uint16_t StyleFlags(const GhosttyStyle& style, bool selected) { + uint16_t flags = 0; + if (style.bold) flags |= kBold; + if (style.italic) flags |= kItalic; + if (style.faint) flags |= kFaint; + if (style.inverse) flags |= kInverse; + if (style.invisible) flags |= kInvisible; + if (style.strikethrough) flags |= kStrikethrough; + if (style.overline) flags |= kOverline; + if (style.underline != 0) flags |= kUnderline; + if (selected) flags |= kSelected; + return flags; +} + +} // namespace + +extern "C" JNIEXPORT jlong JNICALL +Java_expo_modules_t3terminal_GhosttyBridge_nativeCreate( + JNIEnv* env, jclass, jint cols, jint rows, jint cell_width, jint cell_height, + jint foreground, jint background, jint cursor, jintArray palette) { + auto* session = new Session(); + GhosttyTerminalOptions options = { + .cols = static_cast(std::clamp(cols, 1, 65535)), + .rows = static_cast(std::clamp(rows, 1, 65535)), + .max_scrollback = kMaxScrollbackRows, + }; + if (ghostty_terminal_new(nullptr, &session->terminal, options) != GHOSTTY_SUCCESS || + ghostty_render_state_new(nullptr, &session->render_state) != GHOSTTY_SUCCESS || + ghostty_render_state_row_iterator_new(nullptr, &session->row_iterator) != GHOSTTY_SUCCESS || + ghostty_render_state_row_cells_new(nullptr, &session->row_cells) != GHOSTTY_SUCCESS) { + FreeSession(session); + return 0; + } + + ghostty_terminal_set(session->terminal, GHOSTTY_TERMINAL_OPT_USERDATA, session); + ghostty_terminal_set(session->terminal, GHOSTTY_TERMINAL_OPT_WRITE_PTY, + reinterpret_cast(OnWritePty)); + ApplyTheme(session, foreground, background, cursor, env, palette); + ghostty_terminal_resize(session->terminal, options.cols, options.rows, + static_cast(std::max(cell_width, 1)), + static_cast(std::max(cell_height, 1))); + return static_cast(reinterpret_cast(session)); +} + +extern "C" JNIEXPORT void JNICALL +Java_expo_modules_t3terminal_GhosttyBridge_nativeDestroy(JNIEnv*, jclass, jlong handle) { + FreeSession(FromHandle(handle)); +} + +extern "C" JNIEXPORT jbyteArray JNICALL +Java_expo_modules_t3terminal_GhosttyBridge_nativeFeed(JNIEnv* env, jclass, jlong handle, + jbyteArray data) { + auto* session = FromHandle(handle); + if (session == nullptr || data == nullptr) return env->NewByteArray(0); + std::lock_guard lock(session->mutex); + const auto length = env->GetArrayLength(data); + std::vector bytes(static_cast(length)); + if (length > 0) { + env->GetByteArrayRegion(data, 0, length, reinterpret_cast(bytes.data())); + ghostty_terminal_vt_write(session->terminal, bytes.data(), bytes.size()); + } + return ToJavaBytes(env, DrainResponses(session)); +} + +extern "C" JNIEXPORT jbyteArray JNICALL +Java_expo_modules_t3terminal_GhosttyBridge_nativeResize( + JNIEnv* env, jclass, jlong handle, jint cols, jint rows, jint cell_width, jint cell_height) { + auto* session = FromHandle(handle); + if (session == nullptr) return env->NewByteArray(0); + std::lock_guard lock(session->mutex); + ghostty_terminal_resize(session->terminal, + static_cast(std::clamp(cols, 1, 65535)), + static_cast(std::clamp(rows, 1, 65535)), + static_cast(std::max(cell_width, 1)), + static_cast(std::max(cell_height, 1))); + return ToJavaBytes(env, DrainResponses(session)); +} + +extern "C" JNIEXPORT void JNICALL +Java_expo_modules_t3terminal_GhosttyBridge_nativeScroll(JNIEnv*, jclass, jlong handle, + jint rows) { + auto* session = FromHandle(handle); + if (session == nullptr || rows == 0) return; + std::lock_guard lock(session->mutex); + GhosttyTerminalScrollViewport scroll = { + .tag = GHOSTTY_SCROLL_VIEWPORT_DELTA, + .value = {.delta = rows}, + }; + ghostty_terminal_scroll_viewport(session->terminal, scroll); +} + +extern "C" JNIEXPORT void JNICALL +Java_expo_modules_t3terminal_GhosttyBridge_nativeSetTheme( + JNIEnv* env, jclass, jlong handle, jint foreground, jint background, jint cursor, + jintArray palette) { + auto* session = FromHandle(handle); + if (session == nullptr) return; + std::lock_guard lock(session->mutex); + ApplyTheme(session, foreground, background, cursor, env, palette); +} + +extern "C" JNIEXPORT jboolean JNICALL +Java_expo_modules_t3terminal_GhosttyBridge_nativeSelectWordAt(JNIEnv*, jclass, + jlong handle, jint x, + jint y) { + auto* session = FromHandle(handle); + if (session == nullptr) return JNI_FALSE; + std::lock_guard lock(session->mutex); + GhosttyTerminalSelectWordOptions options{}; + options.size = sizeof(options); + if (!ViewportGridRef(session, x, y, &options.ref)) return JNI_FALSE; + GhosttySelection selection{}; + selection.size = sizeof(selection); + if (ghostty_terminal_select_word(session->terminal, &options, &selection) != + GHOSTTY_SUCCESS) { + return JNI_FALSE; + } + return ghostty_terminal_set(session->terminal, GHOSTTY_TERMINAL_OPT_SELECTION, + &selection) == GHOSTTY_SUCCESS + ? JNI_TRUE + : JNI_FALSE; +} + +extern "C" JNIEXPORT void JNICALL +Java_expo_modules_t3terminal_GhosttyBridge_nativeExtendSelection( + JNIEnv*, jclass, jlong handle, jint anchor_x, jint anchor_y, jint x, jint y) { + auto* session = FromHandle(handle); + if (session == nullptr) return; + std::lock_guard lock(session->mutex); + GhosttySelection selection{}; + selection.size = sizeof(selection); + if (!ViewportGridRef(session, anchor_x, anchor_y, &selection.start)) return; + if (!ViewportGridRef(session, x, y, &selection.end)) return; + ghostty_terminal_set(session->terminal, GHOSTTY_TERMINAL_OPT_SELECTION, &selection); +} + +extern "C" JNIEXPORT jboolean JNICALL +Java_expo_modules_t3terminal_GhosttyBridge_nativeSelectAll(JNIEnv*, jclass, + jlong handle) { + auto* session = FromHandle(handle); + if (session == nullptr) return JNI_FALSE; + std::lock_guard lock(session->mutex); + GhosttySelection selection{}; + selection.size = sizeof(selection); + if (ghostty_terminal_select_all(session->terminal, &selection) != GHOSTTY_SUCCESS) { + return JNI_FALSE; + } + return ghostty_terminal_set(session->terminal, GHOSTTY_TERMINAL_OPT_SELECTION, + &selection) == GHOSTTY_SUCCESS + ? JNI_TRUE + : JNI_FALSE; +} + +extern "C" JNIEXPORT void JNICALL +Java_expo_modules_t3terminal_GhosttyBridge_nativeClearSelection(JNIEnv*, jclass, + jlong handle) { + auto* session = FromHandle(handle); + if (session == nullptr) return; + std::lock_guard lock(session->mutex); + ghostty_terminal_set(session->terminal, GHOSTTY_TERMINAL_OPT_SELECTION, nullptr); +} + +// Returns the active selection as UTF-8 bytes (soft-wrapped lines unwrapped, +// trailing whitespace trimmed), or null when there is no selection. +extern "C" JNIEXPORT jbyteArray JNICALL +Java_expo_modules_t3terminal_GhosttyBridge_nativeGetSelectionText(JNIEnv* env, jclass, + jlong handle) { + auto* session = FromHandle(handle); + if (session == nullptr) return nullptr; + std::lock_guard lock(session->mutex); + GhosttyTerminalSelectionFormatOptions options{}; + options.size = sizeof(options); + options.emit = GHOSTTY_FORMATTER_FORMAT_PLAIN; + options.unwrap = true; + options.trim = true; + uint8_t* bytes = nullptr; + size_t len = 0; + if (ghostty_terminal_selection_format_alloc(session->terminal, nullptr, options, + &bytes, &len) != GHOSTTY_SUCCESS || + bytes == nullptr) { + return nullptr; + } + auto result = env->NewByteArray(static_cast(len)); + if (result != nullptr && len > 0) { + env->SetByteArrayRegion(result, 0, static_cast(len), + reinterpret_cast(bytes)); + } + ghostty_free(nullptr, bytes, len); + return result; +} + +extern "C" JNIEXPORT jbyteArray JNICALL +Java_expo_modules_t3terminal_GhosttyBridge_nativeSnapshot(JNIEnv* env, jclass, + jlong handle) { + auto* session = FromHandle(handle); + if (session == nullptr) return env->NewByteArray(0); + std::lock_guard lock(session->mutex); + + if (ghostty_render_state_update(session->render_state, session->terminal) != GHOSTTY_SUCCESS) { + return env->NewByteArray(0); + } + + uint16_t cols = 0; + uint16_t rows = 0; + bool cursor_visible = false; + bool cursor_in_viewport = false; + bool cursor_blinking = false; + uint16_t cursor_x = 0xFFFF; + uint16_t cursor_y = 0xFFFF; + GhosttyRenderStateCursorVisualStyle cursor_style = + GHOSTTY_RENDER_STATE_CURSOR_VISUAL_STYLE_BLOCK; + ghostty_render_state_get(session->render_state, GHOSTTY_RENDER_STATE_DATA_COLS, &cols); + ghostty_render_state_get(session->render_state, GHOSTTY_RENDER_STATE_DATA_ROWS, &rows); + ghostty_render_state_get(session->render_state, GHOSTTY_RENDER_STATE_DATA_CURSOR_VISIBLE, + &cursor_visible); + ghostty_render_state_get(session->render_state, + GHOSTTY_RENDER_STATE_DATA_CURSOR_VIEWPORT_HAS_VALUE, + &cursor_in_viewport); + ghostty_render_state_get(session->render_state, GHOSTTY_RENDER_STATE_DATA_CURSOR_BLINKING, + &cursor_blinking); + ghostty_render_state_get(session->render_state, + GHOSTTY_RENDER_STATE_DATA_CURSOR_VISUAL_STYLE, &cursor_style); + if (cursor_in_viewport) { + ghostty_render_state_get(session->render_state, + GHOSTTY_RENDER_STATE_DATA_CURSOR_VIEWPORT_X, &cursor_x); + ghostty_render_state_get(session->render_state, + GHOSTTY_RENDER_STATE_DATA_CURSOR_VIEWPORT_Y, &cursor_y); + } + + GhosttyRenderStateColors colors{}; + colors.size = sizeof(colors); + if (ghostty_render_state_colors_get(session->render_state, &colors) != GHOSTTY_SUCCESS) { + return env->NewByteArray(0); + } + const auto cursor_color = colors.cursor_has_value ? colors.cursor : colors.foreground; + + ByteWriter writer(32 + static_cast(cols) * rows * 14); + writer.U32(kSnapshotMagic); + writer.U16(kSnapshotVersion); + writer.U16(cols); + writer.U16(rows); + writer.U16(cursor_x); + writer.U16(cursor_y); + writer.U8(cursor_visible && cursor_in_viewport ? 1 : 0); + writer.U8(static_cast(cursor_style)); + writer.U8(cursor_blinking ? 1 : 0); + writer.U8(0); + writer.U32(ArgbFromRgb(colors.foreground)); + writer.U32(ArgbFromRgb(colors.background)); + writer.U32(ArgbFromRgb(cursor_color)); + + if (ghostty_render_state_get(session->render_state, + GHOSTTY_RENDER_STATE_DATA_ROW_ITERATOR, + &session->row_iterator) != GHOSTTY_SUCCESS) { + return env->NewByteArray(0); + } + + uint16_t written_rows = 0; + while (written_rows < rows && + ghostty_render_state_row_iterator_next(session->row_iterator)) { + if (ghostty_render_state_row_get(session->row_iterator, + GHOSTTY_RENDER_STATE_ROW_DATA_CELLS, + &session->row_cells) != GHOSTTY_SUCCESS) { + break; + } + + uint16_t written_cols = 0; + while (written_cols < cols && ghostty_render_state_row_cells_next(session->row_cells)) { + GhosttyStyle style{}; + style.size = sizeof(style); + bool selected = false; + GhosttyColorRgb foreground = colors.foreground; + GhosttyColorRgb background = colors.background; + ghostty_render_state_row_cells_get(session->row_cells, + GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_STYLE, + &style); + ghostty_render_state_row_cells_get(session->row_cells, + GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_SELECTED, + &selected); + ghostty_render_state_row_cells_get(session->row_cells, + GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_FG_COLOR, + &foreground); + ghostty_render_state_row_cells_get(session->row_cells, + GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_BG_COLOR, + &background); + if (style.inverse) std::swap(foreground, background); + if (style.faint) foreground = Blend(foreground, background, 155); + + uint32_t grapheme_count = 0; + ghostty_render_state_row_cells_get( + session->row_cells, GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_GRAPHEMES_LEN, + &grapheme_count); + std::vector utf8; + if (grapheme_count > 0) { + std::vector codepoints(grapheme_count); + if (ghostty_render_state_row_cells_get( + session->row_cells, GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_GRAPHEMES_BUF, + codepoints.data()) == GHOSTTY_SUCCESS) { + utf8.reserve(grapheme_count * 4); + for (const auto codepoint : codepoints) AppendUtf8(&utf8, codepoint); + } + } + + const auto text_length = static_cast(std::min(utf8.size(), 65535)); + writer.U32(ArgbFromRgb(foreground)); + writer.U32(ArgbFromRgb(background)); + writer.U16(StyleFlags(style, selected)); + writer.U16(text_length); + if (text_length != utf8.size()) utf8.resize(text_length); + writer.Bytes(utf8); + ++written_cols; + } + + while (written_cols++ < cols) { + writer.U32(ArgbFromRgb(colors.foreground)); + writer.U32(ArgbFromRgb(colors.background)); + writer.U16(0); + writer.U16(0); + } + ++written_rows; + } + + while (written_rows++ < rows) { + for (uint16_t column = 0; column < cols; ++column) { + writer.U32(ArgbFromRgb(colors.foreground)); + writer.U32(ArgbFromRgb(colors.background)); + writer.U16(0); + writer.U16(0); + } + } + + return ToJavaBytes(env, writer.Take()); +} diff --git a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/GhosttyBridge.kt b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/GhosttyBridge.kt new file mode 100644 index 00000000000..06c13a2824a --- /dev/null +++ b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/GhosttyBridge.kt @@ -0,0 +1,64 @@ +package expo.modules.t3terminal + +internal object GhosttyBridge { + init { + System.loadLibrary("ghostty-vt") + System.loadLibrary("t3terminal") + } + + @JvmStatic + @Suppress("LongParameterList") + external fun nativeCreate( + cols: Int, + rows: Int, + cellWidth: Int, + cellHeight: Int, + foreground: Int, + background: Int, + cursor: Int, + palette: IntArray + ): Long + + @JvmStatic external fun nativeDestroy(handle: Long) + + @JvmStatic external fun nativeFeed(handle: Long, data: ByteArray): ByteArray + + @JvmStatic + external fun nativeResize( + handle: Long, + cols: Int, + rows: Int, + cellWidth: Int, + cellHeight: Int + ): ByteArray + + @JvmStatic external fun nativeScroll(handle: Long, rows: Int) + + @JvmStatic + external fun nativeSetTheme( + handle: Long, + foreground: Int, + background: Int, + cursor: Int, + palette: IntArray + ) + + @JvmStatic external fun nativeSnapshot(handle: Long): ByteArray + + @JvmStatic external fun nativeSelectWordAt(handle: Long, col: Int, row: Int): Boolean + + @JvmStatic + external fun nativeExtendSelection( + handle: Long, + anchorCol: Int, + anchorRow: Int, + col: Int, + row: Int + ) + + @JvmStatic external fun nativeSelectAll(handle: Long): Boolean + + @JvmStatic external fun nativeClearSelection(handle: Long) + + @JvmStatic external fun nativeGetSelectionText(handle: Long): ByteArray? +} 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 index 20e1bab41e5..44840eb570e 100644 --- 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 @@ -51,6 +51,10 @@ class T3TerminalModule : Module() { } Events("onInput", "onResize") + + OnViewDestroys { view: T3TerminalView -> + view.cleanup() + } } } } 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 index cebe86272fd..fcc6092dbee 100644 --- 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 @@ -3,65 +3,70 @@ package expo.modules.t3terminal import android.content.Context import android.graphics.Color import android.graphics.Typeface -import android.view.View +import android.text.Editable +import android.text.InputType +import android.text.TextWatcher +import android.view.KeyEvent import android.view.ViewGroup -import android.view.inputmethod.InputMethodManager import android.view.inputmethod.EditorInfo +import android.view.inputmethod.InputMethodManager import android.widget.EditText -import android.widget.LinearLayout -import android.widget.ScrollView -import android.widget.TextView -import androidx.core.widget.doAfterTextChanged +import android.widget.FrameLayout import expo.modules.kotlin.AppContext -import expo.modules.kotlin.views.ExpoView import expo.modules.kotlin.viewevent.EventDispatcher +import expo.modules.kotlin.views.ExpoView 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 container = FrameLayout(context) + private val terminalCanvas = TerminalCanvasView(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 terminalHandle = 0L + private var fedBuffer = "" + private var cols = 0 + private var rows = 0 private var clearingInput = false + private var isCleanedUp = false private var backgroundColorValue = Color.parseColor("#24292E") private var foregroundColorValue = Color.parseColor("#D1D5DA") private var mutedForegroundColorValue = Color.parseColor("#959DA5") + private var cursorColorValue = Color.parseColor("#009FFF") + private var paletteColors = IntArray(0) var terminalKey: String = "" set(value) { + if (field == value) return field = value contentDescription = "t3-terminal-$value" + recreateTerminal() } var initialBuffer: String = "" set(value) { + if (field == value) return field = value - textView.text = value.ifEmpty { "$ " } - scrollView.post { - scrollView.fullScroll(View.FOCUS_DOWN) - } + feedPendingBuffer() } var fontSize: Float = 10f set(value) { field = value - textView.textSize = value + terminalCanvas.fontSizeSp = value inputView.textSize = max(value, 13f) emitResize() } var appearanceScheme: String = "dark" + + var themeConfig: String = "" set(value) { field = value + parseThemeConfig(value) + applyTheme() } - var themeConfig: String = "" - var focusRequest: Double = 0.0 set(value) { val previous = field @@ -89,129 +94,320 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex set(value) { field = value mutedForegroundColorValue = parseColor(value, mutedForegroundColorValue) - applyTheme() } init { + terminalCanvas.fontSizeSp = fontSize + terminalCanvas.onRequestKeyboard = { requestKeyboardFocus() } + terminalCanvas.onScrollRows = { delta -> + if (terminalHandle != 0L) { + GhosttyBridge.nativeScroll(terminalHandle, delta) + renderSnapshot() + } + } + terminalCanvas.onCellMetricsChanged = { emitResize() } + terminalCanvas.selectionDelegate = object : TerminalSelectionDelegate { + override fun selectWordAt(col: Int, row: Int): Boolean { + if (terminalHandle == 0L) return false + val selected = GhosttyBridge.nativeSelectWordAt(terminalHandle, col, row) + if (selected) renderSnapshot() + return selected + } + + override fun extendSelection(anchorCol: Int, anchorRow: Int, col: Int, row: Int) { + if (terminalHandle == 0L) return + GhosttyBridge.nativeExtendSelection(terminalHandle, anchorCol, anchorRow, col, row) + renderSnapshot() + } + + override fun selectAll(): Boolean { + if (terminalHandle == 0L) return false + val selected = GhosttyBridge.nativeSelectAll(terminalHandle) + if (selected) renderSnapshot() + return selected + } + + override fun clearSelection() { + if (terminalHandle == 0L) return + GhosttyBridge.nativeClearSelection(terminalHandle) + renderSnapshot() + } + + override fun selectionText(): String? = + if (terminalHandle == 0L) { + null + } else { + GhosttyBridge.nativeGetSelectionText(terminalHandle)?.let { String(it, Charsets.UTF_8) } + } + } + + configureInputView() + container.addView( + terminalCanvas, + FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ), + ) + container.addView(inputView, FrameLayout.LayoutParams(1, 1)) + addView( + container, + LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT), + ) applyTheme() - container.orientation = LinearLayout.VERTICAL - textView.typeface = Typeface.MONOSPACE - textView.textSize = fontSize - textView.setPadding(8, 8, 8, 8) - textView.text = "$ " + } + override fun onSizeChanged(width: Int, height: Int, oldWidth: Int, oldHeight: Int) { + super.onSizeChanged(width, height, oldWidth, oldHeight) + if (width != oldWidth || height != oldHeight) emitResize() + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + val childWidthSpec = MeasureSpec.makeMeasureSpec(measuredWidth, MeasureSpec.EXACTLY) + val childHeightSpec = MeasureSpec.makeMeasureSpec(measuredHeight, MeasureSpec.EXACTLY) + container.measure(childWidthSpec, childHeightSpec) + } + + override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { + container.layout(0, 0, right - left, bottom - top) + if (changed) emitResize() + } + + fun cleanup() { + if (isCleanedUp) return + isCleanedUp = true + inputView.setOnEditorActionListener(null) + terminalCanvas.onScrollRows = null + terminalCanvas.onRequestKeyboard = null + terminalCanvas.onCellMetricsChanged = null + terminalCanvas.selectionDelegate = null + destroyTerminal() + } + + private fun configureInputView() { 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.alpha = 0.01f + inputView.isFocusableInTouchMode = true + inputView.imeOptions = EditorInfo.IME_ACTION_SEND or + EditorInfo.IME_FLAG_NO_EXTRACT_UI or + EditorInfo.IME_FLAG_NO_FULLSCREEN or + EditorInfo.IME_FLAG_NO_PERSONALIZED_LEARNING + inputView.inputType = InputType.TYPE_CLASS_TEXT or + InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD or + InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS inputView.setPadding(0, 0, 0, 0) - inputView.setOnFocusChangeListener { _, hasFocus -> - if (hasFocus) { - showKeyboard() + inputView.setOnEditorActionListener { _, actionId, event -> + val isKeyUp = event?.action == KeyEvent.ACTION_UP + val isImeSend = actionId == EditorInfo.IME_ACTION_SEND && !isKeyUp + val isHardwareEnter = event?.keyCode == KeyEvent.KEYCODE_ENTER && + event.action == KeyEvent.ACTION_DOWN + val isEnter = isImeSend || isHardwareEnter + if (isEnter) { + // Enter must send CR: raw-mode TUIs treat LF as Ctrl+J (insert newline). + onInput(mapOf("data" to "\r")) + true + } else { + false } } - 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 + if (event.action != KeyEvent.ACTION_DOWN) return@setOnKeyListener false when { - keyCode == android.view.KeyEvent.KEYCODE_DEL -> { + keyCode == KeyEvent.KEYCODE_DEL -> { onInput(mapOf("data" to "\u007F")) true } // Hardware keyboard Ctrl+A..Z -> control bytes 0x01..0x1A (Ctrl+C, Ctrl+Z, ...). - event.isCtrlPressed && - keyCode in android.view.KeyEvent.KEYCODE_A..android.view.KeyEvent.KEYCODE_Z -> { + event.isCtrlPressed && keyCode in KeyEvent.KEYCODE_A..KeyEvent.KEYCODE_Z -> { onInput( - mapOf("data" to (keyCode - android.view.KeyEvent.KEYCODE_A + 1).toChar().toString()), + mapOf("data" to (keyCode - KeyEvent.KEYCODE_A + 1).toChar().toString()), ) 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), + inputView.addTextChangedListener( + object : TextWatcher { + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit + + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { + if (clearingInput || s == null || count <= 0) return + val end = (start + count).coerceAtMost(s.length) + if (start >= end) return + val insertedText = s.subSequence(start, end).toString() + if (insertedText.isNotEmpty()) { + onInput(mapOf("data" to insertedText)) + } + } + + override fun afterTextChanged(editable: Editable?) { + if (clearingInput || editable.isNullOrEmpty()) return + clearingInput = true + editable.clear() + clearingInput = false + } + }, ) - addView( - container, - LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT), + } + + @Suppress("ComplexCondition") + private fun emitResize() { + if ( + width <= 0 || + height <= 0 || + terminalCanvas.width <= 0 || + terminalCanvas.height <= 0 || + isCleanedUp + ) { + return + } + val nextCols = (terminalCanvas.usableWidth() / terminalCanvas.cellWidthPx) + .toInt() + .coerceIn(2, 400) + val nextRows = (terminalCanvas.usableHeight() / terminalCanvas.cellHeightPx) + .toInt() + .coerceIn(2, 200) + if (nextCols == cols && nextRows == rows && terminalHandle != 0L) return + cols = nextCols + rows = nextRows + val response = if (terminalHandle == 0L) { + createTerminal() + ByteArray(0) + } else { + GhosttyBridge.nativeResize( + terminalHandle, + cols, + rows, + terminalCanvas.cellWidthPx.toInt(), + terminalCanvas.cellHeightPx.toInt(), + ) + } + emitResponse(response) + onResize(mapOf("cols" to cols, "rows" to rows)) + feedPendingBuffer() + renderSnapshot() + } + + @Suppress("ComplexCondition") + private fun createTerminal() { + if (terminalHandle != 0L || cols <= 0 || rows <= 0 || isCleanedUp) return + terminalHandle = GhosttyBridge.nativeCreate( + cols, + rows, + terminalCanvas.cellWidthPx.toInt(), + terminalCanvas.cellHeightPx.toInt(), + foregroundColorValue, + backgroundColorValue, + cursorColorValue, + paletteColors, ) + fedBuffer = "" + } - post { - requestKeyboardFocus() + private fun recreateTerminal() { + if (terminalHandle == 0L) return + destroyTerminal() + createTerminal() + feedPendingBuffer() + renderSnapshot() + } + + private fun destroyTerminal() { + if (terminalHandle == 0L) return + GhosttyBridge.nativeDestroy(terminalHandle) + terminalHandle = 0L + fedBuffer = "" + terminalCanvas.resetSelectionState() + } + + private fun feedPendingBuffer() { + if (terminalHandle == 0L || initialBuffer == fedBuffer) return + if (!initialBuffer.startsWith(fedBuffer)) { + recreateTerminal() + if (terminalHandle == 0L) return } + val suffix = initialBuffer.substring(fedBuffer.length) + if (suffix.isNotEmpty()) { + emitResponse(GhosttyBridge.nativeFeed(terminalHandle, suffix.toByteArray(Charsets.UTF_8))) + // New output invalidates an active selection (matches the web drawer); + // otherwise the copy toolbar drifts out of sync with the grid. + if (terminalCanvas.hasActiveSelection()) { + GhosttyBridge.nativeClearSelection(terminalHandle) + terminalCanvas.resetSelectionState() + } + } + fedBuffer = initialBuffer + renderSnapshot() } - 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 renderSnapshot() { + if (terminalHandle == 0L) return + TerminalFrame.decode( + GhosttyBridge.nativeSnapshot(terminalHandle) + )?.let(terminalCanvas::setFrame) } - 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 emitResponse(response: ByteArray) { + if (response.isNotEmpty()) { + onInput(mapOf("data" to String(response, Charsets.UTF_8))) + } } private fun requestKeyboardFocus() { inputView.requestFocus() - showKeyboard() + val inputMethodManager = context.getSystemService( + Context.INPUT_METHOD_SERVICE + ) as? InputMethodManager + inputMethodManager?.showSoftInput(inputView, InputMethodManager.SHOW_IMPLICIT) } 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) + terminalCanvas.setBackgroundColor(backgroundColorValue) + if (terminalHandle != 0L) { + GhosttyBridge.nativeSetTheme( + terminalHandle, + foregroundColorValue, + backgroundColorValue, + cursorColorValue, + paletteColors, + ) + renderSnapshot() + } + } + + @Suppress("LoopWithTooManyJumpStatements") + private fun parseThemeConfig(config: String) { + val palette = sortedMapOf() + for (line in config.lineSequence()) { + val parts = line.split('=', limit = 2) + if (parts.size != 2) continue + val key = parts[0].trim() + val value = parts[1].trim() + when (key) { + "cursor-color" -> cursorColorValue = parseColor(value, cursorColorValue) + "palette" -> { + val paletteParts = value.split('=', limit = 2) + val index = paletteParts.firstOrNull()?.trim()?.toIntOrNull() ?: continue + val color = paletteParts.getOrNull(1)?.trim() ?: continue + if (index in 0..255) palette[index] = parseColor(color, foregroundColorValue) + } + } + } + if (palette.isNotEmpty()) { + val lastIndex = palette.lastKey() + paletteColors = IntArray(lastIndex + 1) { index -> + palette[index] ?: foregroundColorValue + } + } } private fun parseColor(value: String, fallback: Int): Int = @@ -220,9 +416,4 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex } 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/android/src/main/java/expo/modules/t3terminal/TerminalCanvasView.kt b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/TerminalCanvasView.kt new file mode 100644 index 00000000000..f713bdb4ff0 --- /dev/null +++ b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/TerminalCanvasView.kt @@ -0,0 +1,628 @@ +package expo.modules.t3terminal + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.Rect +import android.graphics.Typeface +import android.util.Log +import android.view.ActionMode +import android.view.GestureDetector +import android.view.HapticFeedbackConstants +import android.view.Menu +import android.view.MenuItem +import android.view.MotionEvent +import android.view.View +import android.widget.OverScroller +import kotlin.math.ceil +import kotlin.math.max +import kotlin.math.min + +/** + * Bundled terminal font with Nerd Font glyphs (powerline, file icons). + * MesloLGS NF is the powerlevel10k-tuned Meslo Nerd Font patch. + */ +internal object TerminalTypefaces { + private var loaded = false + var regular: Typeface = Typeface.MONOSPACE + private set + var bold: Typeface = Typeface.create(Typeface.MONOSPACE, Typeface.BOLD) + private set + + @Suppress("TooGenericExceptionCaught") // Typeface.createFromAsset exposes RuntimeException. + fun ensureLoaded(context: Context) { + if (loaded) return + loaded = true + try { + regular = Typeface.createFromAsset(context.assets, "fonts/MesloLGS-NF-Regular.ttf") + bold = Typeface.createFromAsset(context.assets, "fonts/MesloLGS-NF-Bold.ttf") + } catch (error: RuntimeException) { + Log.w("TerminalCanvasView", "bundled terminal font unavailable, using monospace", error) + } + } +} + +/** + * Selection operations backed by the native terminal. The terminal owns the + * selection state; the canvas only drives gestures and renders the result. + */ +internal interface TerminalSelectionDelegate { + fun selectWordAt(col: Int, row: Int): Boolean + + fun extendSelection(anchorCol: Int, anchorRow: Int, col: Int, row: Int) + + fun selectAll(): Boolean + + fun clearSelection() + + fun selectionText(): String? +} + +internal class TerminalCanvasView(context: Context) : View(context) { + companion object { + const val FLAG_BOLD = 1 shl 0 + const val FLAG_ITALIC = 1 shl 1 + const val FLAG_INVISIBLE = 1 shl 4 + const val FLAG_STRIKETHROUGH = 1 shl 5 + const val FLAG_OVERLINE = 1 shl 6 + const val FLAG_UNDERLINE = 1 shl 7 + const val FLAG_SELECTED = 1 shl 8 + + private const val MENU_COPY = 1 + private const val MENU_SELECT_ALL = 2 + private const val HANDLE_COLOR = 0xFF7AA2F7.toInt() + } + + private val density = resources.displayMetrics.density + private val scaledDensity = density * resources.configuration.fontScale + private val paint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.SUBPIXEL_TEXT_FLAG) + + init { + TerminalTypefaces.ensureLoaded(context) + } + + private val regularTypeface = TerminalTypefaces.regular + private val boldTypeface = TerminalTypefaces.bold + private val italicTypeface = Typeface.create(TerminalTypefaces.regular, Typeface.ITALIC) + private val boldItalicTypeface = Typeface.create(TerminalTypefaces.bold, Typeface.ITALIC) + private val gestureDetector = GestureDetector(context, TerminalGestureListener()) + private val contentPadding = 8f * density + private var frame: TerminalFrame? = null + private var scrollRemainder = 0f + private val scroller = OverScroller(context) + private var flingLastY = 0 + private val flingRunnable = object : Runnable { + override fun run() { + if (!scroller.computeScrollOffset()) return + val currentY = scroller.currY + val deltaPx = (currentY - flingLastY).toFloat() + flingLastY = currentY + scrollRemainder += -deltaPx / cellHeightPx + val rows = scrollRemainder.toInt() + if (rows != 0) { + scrollRemainder -= rows + onScrollRows?.invoke(rows) + } + postOnAnimation(this) + } + } + private var cursorOn = true + private val cursorBlink = object : Runnable { + override fun run() { + val currentFrame = frame ?: return + if (!currentFrame.cursorBlinking || !currentFrame.cursorVisible) return + cursorOn = !cursorOn + invalidate() + postDelayed(this, 500) + } + } + + var onScrollRows: ((Int) -> Unit)? = null + var onRequestKeyboard: (() -> Unit)? = null + var onCellMetricsChanged: (() -> Unit)? = null + var selectionDelegate: TerminalSelectionDelegate? = null + + private val handlePaint = Paint(Paint.ANTI_ALIAS_FLAG) + private var selectionActive = false + private var dragSelecting = false + private var draggingHandle = false + private var anchorCol = 0 + private var anchorRow = 0 + private var extentCol = 0 + private var extentRow = 0 + + // Word-snapped span from the initial long-press; extending anchors to the + // far word edge so the word never shrinks mid-drag. + private var wordStartCol = 0 + private var wordStartRow = 0 + private var wordEndCol = 0 + private var wordEndRow = 0 + private var actionMode: ActionMode? = null + + // Actual selection endpoints in viewport cells, derived from the decoded + // frame (word-snap can extend past the pressed cell). Drive handle + // placement and hit testing. + private var selectionEndpointsValid = false + private var selectionStartCol = 0 + private var selectionStartRow = 0 + private var selectionEndCol = 0 + private var selectionEndRow = 0 + + var fontSizeSp: Float = 10f + set(value) { + if (field == value) return + field = value + updateCellMetrics() + } + + var cellWidthPx: Float = 1f + private set + var cellHeightPx: Float = 1f + private set + private var baselineOffsetPx: Float = 1f + + init { + isClickable = true + isFocusable = true + isFocusableInTouchMode = true + paint.typeface = regularTypeface + updateCellMetrics() + } + + fun setFrame(value: TerminalFrame) { + frame = value + cursorOn = true + updateSelectionEndpoints() + removeCallbacks(cursorBlink) + if (value.cursorBlinking && value.cursorVisible) postDelayed(cursorBlink, 500) + invalidate() + } + + fun resetSelectionState() { + selectionActive = false + dragSelecting = false + draggingHandle = false + selectionEndpointsValid = false + actionMode?.finish() + } + + fun hasActiveSelection(): Boolean = selectionActive + + fun usableWidth(): Float = max(width - contentPadding * 2f, 1f) + fun usableHeight(): Float = max(height - contentPadding * 2f, 1f) + + @Suppress("NestedBlockDepth", "ComplexCondition") + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + val currentFrame = frame + if (currentFrame == null) { + canvas.drawColor(Color.TRANSPARENT) + return + } + canvas.drawColor(currentFrame.background) + canvas.save() + canvas.clipRect( + contentPadding, + contentPadding, + width - contentPadding, + height - contentPadding, + ) + + for (row in 0 until currentFrame.rows) { + val top = contentPadding + row * cellHeightPx + val bottom = top + cellHeightPx + for (column in 0 until currentFrame.cols) { + val index = row * currentFrame.cols + column + val left = contentPadding + column * cellWidthPx + val right = left + cellWidthPx + val background = currentFrame.cellBackgrounds[index] + val flags = currentFrame.cellFlags[index] + paint.style = Paint.Style.FILL + paint.color = if (flags and FLAG_SELECTED != 0) { + blend(currentFrame.cursorColor, background, 0.32f) + } else { + background + } + if (paint.color != currentFrame.background || flags and FLAG_SELECTED != 0) { + canvas.drawRect(left, top, right + 0.5f, bottom + 0.5f, paint) + } + + val text = currentFrame.cellText[index] + if (text.isNotEmpty() && flags and FLAG_INVISIBLE == 0) { + configureTextPaint(flags, currentFrame.cellForegrounds[index]) + canvas.drawText(text, left, top + baselineOffsetPx, paint) + if (flags and FLAG_OVERLINE != 0) { + canvas.drawRect(left, top + 1f, right, top + max(2f, density), paint) + } + } + } + } + + if (currentFrame.cursorVisible && cursorOn && + currentFrame.cursorX in 0 until currentFrame.cols && + currentFrame.cursorY in 0 until currentFrame.rows + ) { + drawCursor(canvas, currentFrame) + } + canvas.restore() + drawSelectionHandles(canvas) + } + + override fun onTouchEvent(event: MotionEvent): Boolean { + if (event.actionMasked == MotionEvent.ACTION_DOWN) { + parent?.requestDisallowInterceptTouchEvent(true) + // Touch-down always stops momentum, even when the event is consumed by + // a selection-handle grab and never reaches the gesture detector. + scroller.forceFinished(true) + removeCallbacks(flingRunnable) + } else if (event.actionMasked == MotionEvent.ACTION_UP || + event.actionMasked == MotionEvent.ACTION_CANCEL + ) { + parent?.requestDisallowInterceptTouchEvent(false) + } + return when { + dragSelecting -> { + when (event.actionMasked) { + MotionEvent.ACTION_MOVE -> extendSelectionTo(event.x, event.y) + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + dragSelecting = false + showSelectionActions() + } + } + true + } + event.actionMasked == MotionEvent.ACTION_DOWN && grabHandleAt(event.x, event.y) -> true + else -> gestureDetector.onTouchEvent(event) || super.onTouchEvent(event) + } + } + + override fun onDetachedFromWindow() { + removeCallbacks(cursorBlink) + removeCallbacks(flingRunnable) + actionMode?.finish() + super.onDetachedFromWindow() + } + + private fun updateCellMetrics() { + paint.textSize = fontSizeSp * scaledDensity + paint.typeface = regularTypeface + cellWidthPx = ceil(paint.measureText("M").toDouble()).toFloat().coerceAtLeast(1f) + val metrics = paint.fontMetrics + val glyphHeight = metrics.descent - metrics.ascent + cellHeightPx = ceil((glyphHeight * 1.12f).toDouble()).toFloat().coerceAtLeast(1f) + baselineOffsetPx = (cellHeightPx - glyphHeight) / 2f - metrics.ascent + onCellMetricsChanged?.invoke() + invalidate() + } + + private fun configureTextPaint(flags: Int, color: Int) { + val bold = flags and FLAG_BOLD != 0 + val italic = flags and FLAG_ITALIC != 0 + paint.typeface = when { + bold && italic -> boldItalicTypeface + bold -> boldTypeface + italic -> italicTypeface + else -> regularTypeface + } + paint.textSize = fontSizeSp * scaledDensity + paint.color = color + paint.style = Paint.Style.FILL + paint.isUnderlineText = flags and FLAG_UNDERLINE != 0 + paint.isStrikeThruText = flags and FLAG_STRIKETHROUGH != 0 + } + + private fun drawCursor(canvas: Canvas, currentFrame: TerminalFrame) { + val left = contentPadding + currentFrame.cursorX * cellWidthPx + val top = contentPadding + currentFrame.cursorY * cellHeightPx + val right = left + cellWidthPx + val bottom = top + cellHeightPx + paint.color = currentFrame.cursorColor + paint.isUnderlineText = false + paint.isStrikeThruText = false + when (currentFrame.cursorStyle) { + 0 -> canvas.drawRect(left, top, left + max(2f * density, 2f), bottom, paint) + 2 -> canvas.drawRect(left, bottom - max(2f * density, 2f), right, bottom, paint) + 3 -> { + paint.style = Paint.Style.STROKE + paint.strokeWidth = max(density, 1f) + canvas.drawRect(left, top, right, bottom, paint) + } + else -> { + paint.style = Paint.Style.FILL + canvas.drawRect(left, top, right, bottom, paint) + val index = currentFrame.cursorY * currentFrame.cols + currentFrame.cursorX + val text = currentFrame.cellText[index] + if (text.isNotEmpty()) { + configureTextPaint(currentFrame.cellFlags[index], currentFrame.background) + canvas.drawText(text, left, top + baselineOffsetPx, paint) + } + } + } + } + + private fun columnAt(px: Float): Int { + val cols = frame?.cols ?: return 0 + return ((px - contentPadding) / cellWidthPx).toInt().coerceIn(0, max(cols - 1, 0)) + } + + private fun rowAt(py: Float): Int { + val rows = frame?.rows ?: return 0 + return ((py - contentPadding) / cellHeightPx).toInt().coerceIn(0, max(rows - 1, 0)) + } + + private fun startWordSelection(px: Float, py: Float) { + val delegate = selectionDelegate ?: return + val col = columnAt(px) + val row = rowAt(py) + // Set before selectWordAt: the delegate re-renders synchronously and + // updateSelectionEndpoints only scans while a selection is active. + selectionActive = true + if (!delegate.selectWordAt(col, row)) { + selectionActive = false + return + } + dragSelecting = true + draggingHandle = false + if (selectionEndpointsValid) { + wordStartCol = selectionStartCol + wordStartRow = selectionStartRow + wordEndCol = selectionEndCol + wordEndRow = selectionEndRow + } else { + wordStartCol = col + wordStartRow = row + wordEndCol = col + wordEndRow = row + } + anchorCol = wordStartCol + anchorRow = wordStartRow + extentCol = col + extentRow = row + performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) + } + + private fun extendSelectionTo(px: Float, py: Float) { + if (!selectionActive) return + val col = columnAt(px) + val row = rowAt(py) + if (col == extentCol && row == extentRow) return + extentCol = col + extentRow = row + if (!draggingHandle) { + val beforeWord = row < wordStartRow || (row == wordStartRow && col < wordStartCol) + if (beforeWord) { + anchorCol = wordEndCol + anchorRow = wordEndRow + } else { + anchorCol = wordStartCol + anchorRow = wordStartRow + } + } + selectionDelegate?.extendSelection(anchorCol, anchorRow, col, row) + } + + private fun clearSelection() { + if (!selectionActive) return + selectionActive = false + dragSelecting = false + draggingHandle = false + selectionEndpointsValid = false + actionMode?.finish() + selectionDelegate?.clearSelection() + } + + private fun copySelection() { + val text = selectionDelegate?.selectionText() ?: return + if (text.isEmpty()) return + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager + clipboard?.setPrimaryClip(ClipData.newPlainText("Terminal", text)) + } + + private fun handleCenterX(col: Int, leadingEdge: Boolean): Float = + contentPadding + (col + if (leadingEdge) 0 else 1) * cellWidthPx + + private fun handleCenterY(row: Int): Float = + contentPadding + (row + 1) * cellHeightPx + handleRadius() + + private fun handleRadius(): Float = max(cellHeightPx * 0.45f, 12f) + + /** + * Begin dragging when the touch lands on a selection handle. The opposite + * endpoint becomes the drag anchor so the grabbed end follows the finger. + */ + private fun grabHandleAt(px: Float, py: Float): Boolean { + if (!selectionActive || !selectionEndpointsValid) return false + val slop = max(handleRadius() * 2f, 24 * density) + + fun near(cx: Float, cy: Float): Boolean { + val dx = px - cx + val dy = py - cy + return dx * dx + dy * dy <= slop * slop + } + + val startGrabbed = + near(handleCenterX(selectionStartCol, true), handleCenterY(selectionStartRow)) + val endGrabbed = + !startGrabbed && near(handleCenterX(selectionEndCol, false), handleCenterY(selectionEndRow)) + val handleGrabbed = startGrabbed || endGrabbed + if (handleGrabbed) { + if (startGrabbed) { + anchorCol = selectionEndCol + anchorRow = selectionEndRow + extentCol = selectionStartCol + extentRow = selectionStartRow + } else { + anchorCol = selectionStartCol + anchorRow = selectionStartRow + extentCol = selectionEndCol + extentRow = selectionEndRow + } + dragSelecting = true + draggingHandle = true + actionMode?.finish() + } + return handleGrabbed + } + + /** Scan the decoded frame for the first/last selected cells. */ + private fun updateSelectionEndpoints() { + selectionEndpointsValid = false + val currentFrame = frame + if (!selectionActive || currentFrame == null) return + val totalCells = currentFrame.cols * currentFrame.rows + var first = -1 + var last = -1 + for (index in 0 until totalCells) { + if (currentFrame.cellFlags[index] and FLAG_SELECTED != 0) { + if (first < 0) first = index + last = index + } + } + if (first >= 0 && currentFrame.cols > 0) { + selectionStartCol = first % currentFrame.cols + selectionStartRow = first / currentFrame.cols + selectionEndCol = last % currentFrame.cols + selectionEndRow = last / currentFrame.cols + selectionEndpointsValid = true + } + } + + // Anchor the toolbar to the actual word-snapped endpoints when known; + // gesture cells can lag behind what the terminal selected. + private fun selectionBounds(): Rect { + val startCol = if (selectionEndpointsValid) selectionStartCol else min(anchorCol, extentCol) + val endCol = if (selectionEndpointsValid) selectionEndCol else max(anchorCol, extentCol) + val startRow = if (selectionEndpointsValid) selectionStartRow else min(anchorRow, extentRow) + val endRow = if (selectionEndpointsValid) selectionEndRow else max(anchorRow, extentRow) + val left = contentPadding + min(startCol, endCol) * cellWidthPx + val right = contentPadding + (max(startCol, endCol) + 1) * cellWidthPx + val top = contentPadding + startRow * cellHeightPx + val bottom = contentPadding + (endRow + 1) * cellHeightPx + return Rect(left.toInt(), top.toInt(), right.toInt(), bottom.toInt()) + } + + private fun showSelectionActions() { + if (actionMode != null || !selectionActive) return + actionMode = startActionMode( + object : ActionMode.Callback2() { + override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { + menu.add(Menu.NONE, MENU_COPY, 0, android.R.string.copy) + menu.add(Menu.NONE, MENU_SELECT_ALL, 1, android.R.string.selectAll) + return true + } + + override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean = false + + override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean = + when (item.itemId) { + MENU_COPY -> { + copySelection() + clearSelection() + true + } + MENU_SELECT_ALL -> { + val currentFrame = frame + if (currentFrame != null && selectionDelegate?.selectAll() == true) { + anchorCol = 0 + anchorRow = 0 + extentCol = max(currentFrame.cols - 1, 0) + extentRow = max(currentFrame.rows - 1, 0) + } + true + } + else -> false + } + + override fun onDestroyActionMode(mode: ActionMode) { + actionMode = null + // Dismissing the toolbar (e.g. Back) drops the selection too — + // except mid-drag, where grabHandleAt finishes the mode on purpose. + if (selectionActive && !dragSelecting) clearSelection() + } + + override fun onGetContentRect(mode: ActionMode, view: View, outRect: Rect) { + outRect.set(selectionBounds()) + } + }, + ActionMode.TYPE_FLOATING, + ) + } + + private fun drawSelectionHandles(canvas: Canvas) { + if (!selectionActive || !selectionEndpointsValid) return + val radius = handleRadius() + handlePaint.color = HANDLE_COLOR + val stemWidth = max(radius / 4f, 2f) + + fun drawHandle(cx: Float, row: Int) { + val cornerY = contentPadding + (row + 1) * cellHeightPx + val cy = handleCenterY(row) + canvas.drawRect(cx - stemWidth / 2f, cornerY, cx + stemWidth / 2f, cy, handlePaint) + canvas.drawCircle(cx, cy, radius, handlePaint) + } + + drawHandle(handleCenterX(selectionStartCol, true), selectionStartRow) + drawHandle(handleCenterX(selectionEndCol, false), selectionEndRow) + } + + private fun blend(foreground: Int, background: Int, amount: Float): Int { + val inverseAmount = 1f - amount + return Color.rgb( + (Color.red(foreground) * amount + Color.red(background) * inverseAmount).toInt(), + (Color.green(foreground) * amount + Color.green(background) * inverseAmount).toInt(), + (Color.blue(foreground) * amount + Color.blue(background) * inverseAmount).toInt(), + ) + } + + private inner class TerminalGestureListener : GestureDetector.SimpleOnGestureListener() { + override fun onDown(event: MotionEvent): Boolean { + scroller.forceFinished(true) + removeCallbacks(flingRunnable) + onRequestKeyboard?.invoke() + return true + } + + override fun onSingleTapUp(event: MotionEvent): Boolean { + if (selectionActive) { + clearSelection() + } else { + performClick() + } + return true + } + + override fun onLongPress(event: MotionEvent) { + startWordSelection(event.x, event.y) + } + + override fun onScroll( + first: MotionEvent?, + current: MotionEvent, + distanceX: Float, + distanceY: Float + ): Boolean { + scrollRemainder += distanceY / cellHeightPx + val rows = scrollRemainder.toInt() + if (rows != 0) { + scrollRemainder -= rows + onScrollRows?.invoke(rows) + } + return true + } + + override fun onFling( + first: MotionEvent?, + current: MotionEvent, + velocityX: Float, + velocityY: Float + ): Boolean { + flingLastY = 0 + scroller.fling(0, 0, 0, velocityY.toInt(), 0, 0, Int.MIN_VALUE / 2, Int.MAX_VALUE / 2) + postOnAnimation(flingRunnable) + return true + } + } +} diff --git a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/TerminalFrame.kt b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/TerminalFrame.kt new file mode 100644 index 00000000000..2234d628e03 --- /dev/null +++ b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/TerminalFrame.kt @@ -0,0 +1,82 @@ +package expo.modules.t3terminal + +import java.nio.ByteBuffer +import java.nio.ByteOrder + +internal data class TerminalFrame( + val cols: Int, + val rows: Int, + val cursorX: Int, + val cursorY: Int, + val cursorVisible: Boolean, + val cursorStyle: Int, + val cursorBlinking: Boolean, + val foreground: Int, + val background: Int, + val cursorColor: Int, + val cellForegrounds: IntArray, + val cellBackgrounds: IntArray, + val cellFlags: IntArray, + val cellText: Array +) { + companion object { + private const val MAGIC = 0x54563354 + private const val VERSION = 1 + private const val HEADER_BYTES = 32 + private const val CELL_HEADER_BYTES = 12 + + @Suppress("ReturnCount") + fun decode(bytes: ByteArray): TerminalFrame? { + if (bytes.size < HEADER_BYTES) return null + val buffer = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN) + if (buffer.int != MAGIC || buffer.short.toInt() != VERSION) return null + + val cols = buffer.short.toInt() and 0xFFFF + val rows = buffer.short.toInt() and 0xFFFF + val cursorX = buffer.short.toInt() and 0xFFFF + val cursorY = buffer.short.toInt() and 0xFFFF + val cursorVisible = buffer.get().toInt() != 0 + val cursorStyle = buffer.get().toInt() and 0xFF + val cursorBlinking = buffer.get().toInt() != 0 + buffer.get() + val foreground = buffer.int + val background = buffer.int + val cursorColor = buffer.int + val cellCount = cols * rows + val foregrounds = IntArray(cellCount) + val backgrounds = IntArray(cellCount) + val flags = IntArray(cellCount) + val text = Array(cellCount) { "" } + + for (index in 0 until cellCount) { + if (buffer.remaining() < CELL_HEADER_BYTES) return null + foregrounds[index] = buffer.int + backgrounds[index] = buffer.int + flags[index] = buffer.short.toInt() and 0xFFFF + val textLength = buffer.short.toInt() and 0xFFFF + if (buffer.remaining() < textLength) return null + if (textLength > 0) { + text[index] = String(bytes, buffer.position(), textLength, Charsets.UTF_8) + buffer.position(buffer.position() + textLength) + } + } + + return TerminalFrame( + cols = cols, + rows = rows, + cursorX = cursorX, + cursorY = cursorY, + cursorVisible = cursorVisible, + cursorStyle = cursorStyle, + cursorBlinking = cursorBlinking, + foreground = foreground, + background = background, + cursorColor = cursorColor, + cellForegrounds = foregrounds, + cellBackgrounds = backgrounds, + cellFlags = flags, + cellText = text, + ) + } + } +} diff --git a/apps/mobile/modules/t3-terminal/android/src/main/jniLibs/arm64-v8a/libghostty-vt.so b/apps/mobile/modules/t3-terminal/android/src/main/jniLibs/arm64-v8a/libghostty-vt.so new file mode 100755 index 00000000000..e086ca83155 Binary files /dev/null and b/apps/mobile/modules/t3-terminal/android/src/main/jniLibs/arm64-v8a/libghostty-vt.so differ diff --git a/apps/mobile/modules/t3-terminal/android/src/main/jniLibs/armeabi-v7a/libghostty-vt.so b/apps/mobile/modules/t3-terminal/android/src/main/jniLibs/armeabi-v7a/libghostty-vt.so new file mode 100755 index 00000000000..5592142ffce Binary files /dev/null and b/apps/mobile/modules/t3-terminal/android/src/main/jniLibs/armeabi-v7a/libghostty-vt.so differ diff --git a/apps/mobile/modules/t3-terminal/android/src/main/jniLibs/x86/libghostty-vt.so b/apps/mobile/modules/t3-terminal/android/src/main/jniLibs/x86/libghostty-vt.so new file mode 100755 index 00000000000..bacab790833 Binary files /dev/null and b/apps/mobile/modules/t3-terminal/android/src/main/jniLibs/x86/libghostty-vt.so differ diff --git a/apps/mobile/modules/t3-terminal/android/src/main/jniLibs/x86_64/libghostty-vt.so b/apps/mobile/modules/t3-terminal/android/src/main/jniLibs/x86_64/libghostty-vt.so new file mode 100755 index 00000000000..f6e0b646a2f Binary files /dev/null and b/apps/mobile/modules/t3-terminal/android/src/main/jniLibs/x86_64/libghostty-vt.so differ diff --git a/apps/mobile/modules/t3-terminal/scripts/build-libghostty-android.sh b/apps/mobile/modules/t3-terminal/scripts/build-libghostty-android.sh new file mode 100755 index 00000000000..7b9aa9b6dc6 --- /dev/null +++ b/apps/mobile/modules/t3-terminal/scripts/build-libghostty-android.sh @@ -0,0 +1,152 @@ +#!/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-vt" +PATCH_DIR="${SCRIPT_DIR}/libghostty-android-patches" + +GHOSTTY_REVISION="${GHOSTTY_REVISION:-9f62873bf195e4d8a762d768a1405a5f2f7b1697}" +GHOSTTY_SOURCE_DIR="${GHOSTTY_SOURCE_DIR:-${HOME}/.cache/t3code/ghostty-${GHOSTTY_REVISION:0:8}}" +GHOSTTY_ZIG_VERSION="${GHOSTTY_ZIG_VERSION:-0.15.2}" +GHOSTTY_ZIG="${GHOSTTY_ZIG:-}" +ANDROID_NDK_HOME="${ANDROID_NDK_HOME:-}" + +log() { + printf '[libghostty-vt-android] %s\n' "$*" +} + +die() { + printf '[libghostty-vt-android] 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 host_os host_arch cache_dir + host_os="$(uname -s | tr '[:upper:]' '[:lower:]')" + host_arch="$(uname -m)" + case "${host_os}" in + darwin) host_os="macos" ;; + linux) ;; + *) die "unsupported host OS for Zig download: ${host_os}" ;; + esac + case "${host_arch}" in + arm64) host_arch="aarch64" ;; + aarch64 | x86_64) ;; + *) die "unsupported host architecture for Zig download: ${host_arch}" ;; + esac + + cache_dir="${HOME}/.cache/t3code/zig-${GHOSTTY_ZIG_VERSION}" + 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-${host_arch}-${host_os}-${GHOSTTY_ZIG_VERSION}.tar.xz" \ + | tar -xJ --strip-components=1 -C "${cache_dir}" +} + +ensure_ghostty_source() { + if [[ ! -d "${GHOSTTY_SOURCE_DIR}/.git" ]]; then + require_cmd git + log "cloning Ghostty ${GHOSTTY_REVISION}" + git clone --filter=blob:none --no-checkout https://github.com/ghostty-org/ghostty.git \ + "${GHOSTTY_SOURCE_DIR}" + git -C "${GHOSTTY_SOURCE_DIR}" fetch --depth=1 origin "${GHOSTTY_REVISION}" + git -C "${GHOSTTY_SOURCE_DIR}" checkout --detach "${GHOSTTY_REVISION}" + fi + + local actual_revision + actual_revision="$(git -C "${GHOSTTY_SOURCE_DIR}" rev-parse HEAD)" + [[ "${actual_revision}" == "${GHOSTTY_REVISION}" ]] || \ + die "expected Ghostty ${GHOSTTY_REVISION}, found ${actual_revision}" +} + +apply_ghostty_patches() { + [[ -d "${PATCH_DIR}" ]] || return + + local patch_file patch_name + for patch_file in "${PATCH_DIR}"/*.patch; do + [[ -e "${patch_file}" ]] || continue + patch_name="$(basename "${patch_file}")" + if git -C "${GHOSTTY_SOURCE_DIR}" apply --reverse --check "${patch_file}" >/dev/null 2>&1; then + log "patch already applied: ${patch_name}" + continue + fi + log "applying patch: ${patch_name}" + git -C "${GHOSTTY_SOURCE_DIR}" apply --check "${patch_file}" + git -C "${GHOSTTY_SOURCE_DIR}" apply "${patch_file}" + done +} + +if [[ -z "${ANDROID_NDK_HOME}" ]]; then + die "ANDROID_NDK_HOME must point to an installed Android NDK" +fi +[[ -d "${ANDROID_NDK_HOME}" ]] || die "Android NDK not found: ${ANDROID_NDK_HOME}" + +ensure_zig +ensure_ghostty_source +apply_ghostty_patches + +strip_tool="${ANDROID_NDK_HOME}/toolchains/llvm/prebuilt" +strip_tool="$(find "${strip_tool}" -path '*/bin/llvm-strip' -print -quit)" +[[ -x "${strip_tool}" ]] || die "llvm-strip not found under ${ANDROID_NDK_HOME}" + +targets=( + "arm64-v8a:aarch64-linux-android" + "armeabi-v7a:arm-linux-androideabi" + "x86:x86-linux-android" + "x86_64:x86_64-linux-android" +) + +build_root="$(mktemp -d)" +trap 'rm -rf "${build_root}"' EXIT +mkdir -p "${VENDOR_DIR}/include" + +for entry in "${targets[@]}"; do + abi="${entry%%:*}" + target="${entry#*:}" + prefix="${build_root}/${abi}" + log "building ${abi} (${target})" + ( + cd "${GHOSTTY_SOURCE_DIR}" + ANDROID_NDK_HOME="${ANDROID_NDK_HOME}" "${GHOSTTY_ZIG}" build \ + -Demit-lib-vt \ + -Dtarget="${target}" \ + -Doptimize=ReleaseFast \ + -Dstrip=true \ + -Dsimd=false \ + -p "${prefix}" + ) + + mkdir -p "${MODULE_DIR}/android/src/main/jniLibs/${abi}" + cp "${prefix}/lib/libghostty-vt.so.0.1.0" \ + "${MODULE_DIR}/android/src/main/jniLibs/${abi}/libghostty-vt.so" + "${strip_tool}" --strip-unneeded \ + "${MODULE_DIR}/android/src/main/jniLibs/${abi}/libghostty-vt.so" +done + +rm -rf "${VENDOR_DIR}/include/ghostty" +cp -R "${build_root}/arm64-v8a/include/ghostty" "${VENDOR_DIR}/include/ghostty" +cp "${GHOSTTY_SOURCE_DIR}/LICENSE" "${VENDOR_DIR}/LICENSE" +printf '%s\n' "${GHOSTTY_REVISION}" > "${VENDOR_DIR}/VERSION" +log "done" diff --git a/apps/mobile/modules/t3-terminal/scripts/libghostty-android-patches/0001-link-android-libvt-with-libc.patch b/apps/mobile/modules/t3-terminal/scripts/libghostty-android-patches/0001-link-android-libvt-with-libc.patch new file mode 100644 index 00000000000..2713eb739a0 --- /dev/null +++ b/apps/mobile/modules/t3-terminal/scripts/libghostty-android-patches/0001-link-android-libvt-with-libc.patch @@ -0,0 +1,25 @@ +diff --git a/src/build/GhosttyZig.zig b/src/build/GhosttyZig.zig +index 44c300e15..f8d4296a2 100644 +--- a/src/build/GhosttyZig.zig ++++ b/src/build/GhosttyZig.zig +@@ -123,7 +123,7 @@ fn initVt( + // no-libcxx mode (HWY_NO_LIBCXX / SIMDUTF_NO_LIBCXX) so we + // don't need libcpp. System-provided simdutf headers still + // use C++ stdlib headers, so we need libcpp in that case. +- .link_libc = if (cfg.simd) true else null, ++ .link_libc = if (cfg.simd or cfg.target.result.abi.isAndroid()) true else null, + .link_libcpp = if (cfg.simd and + b.systemIntegrationOption("simdutf", .{}) and + cfg.target.result.abi != .msvc) true else null, +diff --git a/src/terminal/c/sys.zig b/src/terminal/c/sys.zig +index c4b2b17f2..afb98ed4b 100644 +--- a/src/terminal/c/sys.zig ++++ b/src/terminal/c/sys.zig +@@ -227,6 +227,7 @@ pub fn logStderr( + message_len: usize, + ) callconv(lib.calling_conv) void { + if (comptime builtin.target.cpu.arch.isWasm()) return; ++ if (comptime builtin.target.abi.isAndroid()) return; + + const scope = scope_ptr[0..scope_len]; + const message = message_ptr[0..message_len]; diff --git a/apps/mobile/modules/t3-terminal/scripts/libghostty-android-patches/0002-align-android-libvt-to-16kb-pages.patch b/apps/mobile/modules/t3-terminal/scripts/libghostty-android-patches/0002-align-android-libvt-to-16kb-pages.patch new file mode 100644 index 00000000000..2fdffd2ccb4 --- /dev/null +++ b/apps/mobile/modules/t3-terminal/scripts/libghostty-android-patches/0002-align-android-libvt-to-16kb-pages.patch @@ -0,0 +1,11 @@ +diff --git a/src/build/GhosttyLibVt.zig b/src/build/GhosttyLibVt.zig +--- a/src/build/GhosttyLibVt.zig ++++ b/src/build/GhosttyLibVt.zig +@@ -236,6 +236,7 @@ fn initLib( + if (lib.rootModuleTarget().abi.isAndroid()) { + // Support 16kb page sizes, required for Android 15+. ++ lib.link_z_common_page_size = 16384; // 16kb + lib.link_z_max_page_size = 16384; // 16kb + + try @import("android_ndk").addPaths(b, lib); + } diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 7455f65b348..0bc0daecafc 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -5,13 +5,14 @@ "main": "index.ts", "scripts": { "dev": "expo start --clear", - "dev:client": "APP_VARIANT=development expo start --dev-client --scheme t3code-dev --clear", + "dev:client": "APP_VARIANT=development expo start --dev-client --scheme t3code-dev --clear --localhost", + "dev:client:preview": "eas env:exec preview 'EXPO_NO_DOTENV=1 APP_VARIANT=preview expo start --dev-client --scheme t3code-preview --clear --lan'", "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:dev": "APP_VARIANT=development EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform android && REACT_NATIVE_PACKAGER_HOSTNAME=localhost 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", @@ -22,6 +23,7 @@ "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", + "ios:release": "APP_VARIANT=production EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform ios && expo run:ios --configuration Release --no-bundler", "eas:ios:dev": "eas build --profile development -p ios", "eas:ios:preview": "eas build --profile preview -p ios", "eas:ios:preview:dev": "eas build --profile preview:dev -p ios", @@ -63,12 +65,14 @@ "@t3tools/mobile-review-diff-native": "file:./modules/t3-review-diff", "@t3tools/mobile-terminal-native": "file:./modules/t3-terminal", "@t3tools/shared": "workspace:*", + "@tabler/icons-react-native": "^3.44.0", "clsx": "^2.1.1", "diff": "8.0.3", "effect": "catalog:", "expo": "~56.0.12", "expo-asset": "~56.0.17", "expo-auth-session": "~56.0.14", + "expo-blur": "~56.0.3", "expo-build-properties": "~56.0.19", "expo-camera": "~56.0.8", "expo-clipboard": "~56.0.4", @@ -79,13 +83,16 @@ "expo-font": "~56.0.7", "expo-glass-effect": "~56.0.4", "expo-haptics": "~56.0.3", + "expo-image": "~56.0.11", "expo-image-picker": "~56.0.18", "expo-linking": "~56.0.14", "expo-network": "~56.0.5", "expo-notifications": "~56.0.18", "expo-paste-input": "^0.1.15", + "expo-quick-actions": "^6.0.2", "expo-secure-store": "~56.0.4", "expo-splash-screen": "~56.0.10", + "expo-sqlite": "~56.0.5", "expo-symbols": "~56.0.6", "expo-updates": "~56.0.19", "expo-web-browser": "~56.0.5", diff --git a/apps/mobile/plugins/withAndroidGradleHeap.cjs b/apps/mobile/plugins/withAndroidGradleHeap.cjs new file mode 100644 index 00000000000..0ec26507d5a --- /dev/null +++ b/apps/mobile/plugins/withAndroidGradleHeap.cjs @@ -0,0 +1,22 @@ +const { withGradleProperties } = require("expo/config-plugins"); + +// The Expo template's 2GB heap is too small for D8 dex merging in this app, +// causing OutOfMemoryError in :app:mergeExtDexDebug. +const JVM_ARGS = "-Xmx4096m -XX:MaxMetaspaceSize=1024m"; + +module.exports = function withAndroidGradleHeap(config) { + return withGradleProperties(config, (nextConfig) => { + const properties = nextConfig.modResults.filter( + (item) => !(item.type === "property" && item.key === "org.gradle.jvmargs"), + ); + + properties.push({ + type: "property", + key: "org.gradle.jvmargs", + value: JVM_ARGS, + }); + + nextConfig.modResults = properties; + return nextConfig; + }); +}; diff --git a/apps/mobile/plugins/withAndroidModernAlertDialog.cjs b/apps/mobile/plugins/withAndroidModernAlertDialog.cjs new file mode 100644 index 00000000000..8c8b2b80006 --- /dev/null +++ b/apps/mobile/plugins/withAndroidModernAlertDialog.cjs @@ -0,0 +1,188 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const { + AndroidConfig, + withAndroidColors, + withAndroidColorsNight, + withAndroidStyles, + withDangerousMod, +} = require("expo/config-plugins"); + +// React Native's Alert renders an AppCompat AlertDialog on Android, which +// inherits the dated framework dialog chrome (square gray panel, teal +// all-caps buttons) from the app theme. These resources restyle it with the +// app's uniwind tokens from global.css: --color-card panel, --color-foreground +// text, --color-primary buttons, DM Sans type. The @font resources referenced +// here are embedded by the expo-font plugin config in app.config.ts. + +// AppCompat's default dialog window background is an inset rounded rect, so +// the replacement keeps the same 16dp inset to preserve the dialog's margins. +const DIALOG_BACKGROUND_DRAWABLE = ` + + + + + + +`; + +const COLORS = { + light: { + background: "#FFFFFF", // --color-card + text: "#262626", // --color-foreground + secondaryText: "#525252", // --color-foreground-secondary + buttonText: "#262626", // --color-primary + }, + night: { + background: "#171717", + text: "#F5F5F5", + secondaryText: "#A3A3A3", + buttonText: "#F5F5F5", + }, +}; + +function assignStyleItem(style, name, value) { + style.item = style.item ?? []; + const existing = style.item.find((item) => item.$?.name === name); + if (existing) { + existing._ = value; + } else { + style.item.push({ _: value, $: { name } }); + } +} + +function withAlertDialogStyles(config) { + return withAndroidStyles(config, (config) => { + const resources = config.modResults.resources; + resources.style = resources.style ?? []; + + const appTheme = resources.style.find((style) => style.$?.name === "AppTheme"); + if (appTheme) { + // React Native's dialog module builds an androidx.appcompat AlertDialog, + // which resolves its theme from the AppCompat attr; the framework attr is + // set too for any native code that inflates a platform AlertDialog. + assignStyleItem(appTheme, "alertDialogTheme", "@style/AppAlertDialog"); + assignStyleItem(appTheme, "android:alertDialogTheme", "@style/AppAlertDialog"); + } + + resources.style = resources.style.filter( + (style) => + !["AppAlertDialog", "AppAlertDialog.Title", "AppAlertDialog.Button"].includes( + style.$?.name, + ), + ); + resources.style.push( + { + $: { name: "AppAlertDialog", parent: "ThemeOverlay.AppCompat.Dialog.Alert" }, + item: [ + { _: "@drawable/alert_dialog_background", $: { name: "android:windowBackground" } }, + // The message body resolves textColorPrimary in AppCompat's alert + // layout; pointing it at the secondary token dims the message + // relative to the title, which keeps full-strength text via the + // explicit color in AppAlertDialog.Title. + { _: "@color/alert_dialog_secondary_text", $: { name: "android:textColorPrimary" } }, + { _: "@color/alert_dialog_secondary_text", $: { name: "android:textColorSecondary" } }, + // Theme-level fontFamily is the lowest-priority fallback in attribute + // resolution, so it reaches every text view in the dialog that does + // not carry its own fontFamily (the message body in particular). + { _: "@font/xml_dm_sans_regular", $: { name: "android:fontFamily" } }, + // AppCompat's alert title view styles itself from the framework + // attr (?android:attr/windowTitleStyle); there is no unprefixed + // AppCompat equivalent. + { _: "@style/AppAlertDialog.Title", $: { name: "android:windowTitleStyle" } }, + { _: "@style/AppAlertDialog.Button", $: { name: "buttonBarPositiveButtonStyle" } }, + { _: "@style/AppAlertDialog.Button", $: { name: "buttonBarNegativeButtonStyle" } }, + { _: "@style/AppAlertDialog.Button", $: { name: "buttonBarNeutralButtonStyle" } }, + ], + }, + { + $: { name: "AppAlertDialog.Title", parent: "RtlOverlay.DialogWindowTitle.AppCompat" }, + item: [ + { _: "@font/dm_sans_500medium", $: { name: "android:fontFamily" } }, + { _: "18sp", $: { name: "android:textSize" } }, + { _: "@color/alert_dialog_text", $: { name: "android:textColor" } }, + ], + }, + { + $: { + name: "AppAlertDialog.Button", + parent: "Widget.AppCompat.Button.ButtonBar.AlertDialog", + }, + item: [ + // The AppCompat button appearance hardcodes sans-serif-medium, so + // the font must be set here rather than relying on the theme + // fallback. + { _: "@font/dm_sans_500medium", $: { name: "android:fontFamily" } }, + { _: "@color/alert_dialog_button_text", $: { name: "android:textColor" } }, + { _: "false", $: { name: "android:textAllCaps" } }, + { _: "0", $: { name: "android:letterSpacing" } }, + ], + }, + ); + + return config; + }); +} + +function assignColors(colorsResource, palette) { + let result = colorsResource; + result = AndroidConfig.Colors.assignColorValue(result, { + name: "alert_dialog_background", + value: palette.background, + }); + result = AndroidConfig.Colors.assignColorValue(result, { + name: "alert_dialog_text", + value: palette.text, + }); + result = AndroidConfig.Colors.assignColorValue(result, { + name: "alert_dialog_secondary_text", + value: palette.secondaryText, + }); + result = AndroidConfig.Colors.assignColorValue(result, { + name: "alert_dialog_button_text", + value: palette.buttonText, + }); + return result; +} + +function withAlertDialogColors(config) { + config = withAndroidColors(config, (config) => { + config.modResults = assignColors(config.modResults, COLORS.light); + return config; + }); + config = withAndroidColorsNight(config, (config) => { + config.modResults = assignColors(config.modResults, COLORS.night); + return config; + }); + return config; +} + +function withAlertDialogBackgroundDrawable(config) { + return withDangerousMod(config, [ + "android", + async (config) => { + const drawableDir = path.join( + config.modRequest.platformProjectRoot, + "app", + "src", + "main", + "res", + "drawable", + ); + fs.mkdirSync(drawableDir, { recursive: true }); + fs.writeFileSync( + path.join(drawableDir, "alert_dialog_background.xml"), + DIALOG_BACKGROUND_DRAWABLE, + ); + return config; + }, + ]); +} + +module.exports = function withAndroidModernAlertDialog(config) { + return withAlertDialogBackgroundDrawable(withAlertDialogColors(withAlertDialogStyles(config))); +}; diff --git a/apps/mobile/plugins/withAndroidModernPopupMenu.cjs b/apps/mobile/plugins/withAndroidModernPopupMenu.cjs new file mode 100644 index 00000000000..68409cbd0fd --- /dev/null +++ b/apps/mobile/plugins/withAndroidModernPopupMenu.cjs @@ -0,0 +1,250 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const { + AndroidConfig, + withAndroidColors, + withAndroidColorsNight, + withAndroidStyles, + withDangerousMod, +} = require("expo/config-plugins"); + +// @react-native-menu/menu renders an AppCompat PopupMenu on Android, which +// inherits the dated default popup chrome from the app theme. These resources +// restyle it to match the app palette (global.css --color-card / --color-foreground) +// with rounded corners, and anchor it below the button instead of overlapping it. + +const POPUP_BACKGROUND_DRAWABLE = ` + + + + +`; + +// Checkable menu rows insert a CheckBox at the row's right edge; the theme +// swaps its square-box button for this check glyph so the selected option +// shows a plain right-aligned check. +const CHECK_DRAWABLE = ` + + + +`; + +// CheckBox button drawable: check glyph when selected, nothing otherwise. +const CHECKBOX_BUTTON_SELECTOR = ` + + + + +`; + +// Replaces the default filled-triangle submenu indicator with a stroked ">" +// chevron. +const SUBMENU_ARROW_DRAWABLE = ` + + + +`; + +const COLORS = { + light: { background: "#F7F7F7", itemText: "#262626" }, + night: { background: "#161616", itemText: "#F5F5F5" }, +}; + +function assignStyleItem(style, name, value) { + style.item = style.item ?? []; + const existing = style.item.find((item) => item.$?.name === name); + if (existing) { + existing._ = value; + } else { + style.item.push({ _: value, $: { name } }); + } +} + +function withPopupMenuStyles(config) { + return withAndroidStyles(config, (config) => { + const resources = config.modResults.resources; + resources.style = resources.style ?? []; + + const appTheme = resources.style.find((style) => style.$?.name === "AppTheme"); + if (appTheme) { + assignStyleItem(appTheme, "popupMenuStyle", "@style/AppPopupMenu"); + assignStyleItem(appTheme, "android:popupMenuStyle", "@style/AppPopupMenu"); + assignStyleItem( + appTheme, + "textAppearanceLargePopupMenu", + "@style/AppPopupMenu.TextAppearance", + ); + assignStyleItem( + appTheme, + "textAppearanceSmallPopupMenu", + "@style/AppPopupMenu.TextAppearance", + ); + // Submenu popups show their parent item as a header row that reads a + // separate theme attribute, so it needs the same themed text color. + assignStyleItem( + appTheme, + "textAppearancePopupMenuHeader", + "@style/AppPopupMenu.HeaderTextAppearance", + ); + // Menu item views resolve their submenu arrow from this style + // (android:listMenuViewStyle / android:subMenuArrow are public attrs). + assignStyleItem(appTheme, "android:listMenuViewStyle", "@style/AppPopupMenuListMenuView"); + // Checkable rows inflate a plain CheckBox at the row end; restyle its + // button so the selected option shows a right-aligned check glyph + // instead of a square box. Both framework and AppCompat attrs are set + // since the popup may inflate either CheckBox flavor. App-wide for + // native checkboxes, which the app otherwise doesn't use. + assignStyleItem(appTheme, "android:checkboxStyle", "@style/AppPopupMenuCheckBox"); + assignStyleItem(appTheme, "checkboxStyle", "@style/AppPopupMenuCheckBoxCompat"); + } + + resources.style = resources.style.filter( + (style) => + ![ + "AppPopupMenu", + "AppPopupMenu.TextAppearance", + "AppPopupMenu.HeaderTextAppearance", + "AppPopupMenuListMenuView", + "AppPopupMenuCheckBox", + "AppPopupMenuCheckBoxCompat", + ].includes(style.$?.name), + ); + resources.style.push( + { + $: { name: "AppPopupMenu", parent: "Widget.AppCompat.PopupMenu" }, + item: [ + { _: "@drawable/popup_menu_background", $: { name: "android:popupBackground" } }, + { _: "false", $: { name: "android:overlapAnchor" } }, + { _: "4dp", $: { name: "android:dropDownVerticalOffset" } }, + ], + }, + { + $: { name: "AppPopupMenu.TextAppearance", parent: "TextAppearance.AppCompat.Menu" }, + item: [ + { _: "15sp", $: { name: "android:textSize" } }, + { _: "@color/popup_menu_item_text", $: { name: "android:textColor" } }, + // DM Sans (--font-sans); embedded by the expo-font plugin config in + // app.config.ts. + { _: "@font/xml_dm_sans_regular", $: { name: "android:fontFamily" } }, + ], + }, + { + $: { + name: "AppPopupMenu.HeaderTextAppearance", + parent: "TextAppearance.AppCompat.Widget.PopupMenu.Header", + }, + item: [ + { _: "15sp", $: { name: "android:textSize" } }, + { _: "@color/popup_menu_item_text", $: { name: "android:textColor" } }, + { _: "@font/xml_dm_sans_regular", $: { name: "android:fontFamily" } }, + ], + }, + // The framework default (Widget.Material.ListMenuView) only carries + // subMenuArrow, so replacing the style wholesale is safe. + { + $: { name: "AppPopupMenuListMenuView" }, + item: [{ _: "@drawable/popup_menu_submenu_arrow", $: { name: "android:subMenuArrow" } }], + }, + { + $: { + name: "AppPopupMenuCheckBox", + parent: "android:Widget.Material.CompoundButton.CheckBox", + }, + item: [{ _: "@drawable/popup_menu_check_button", $: { name: "android:button" } }], + }, + { + $: { + name: "AppPopupMenuCheckBoxCompat", + parent: "Widget.AppCompat.CompoundButton.CheckBox", + }, + item: [ + { _: "@drawable/popup_menu_check_button", $: { name: "android:button" } }, + // buttonCompat wins over android:button in AppCompatCheckBox. + { _: "@drawable/popup_menu_check_button", $: { name: "buttonCompat" } }, + ], + }, + ); + + return config; + }); +} + +function withPopupMenuColors(config) { + config = withAndroidColors(config, (config) => { + config.modResults = AndroidConfig.Colors.assignColorValue(config.modResults, { + name: "popup_menu_background", + value: COLORS.light.background, + }); + config.modResults = AndroidConfig.Colors.assignColorValue(config.modResults, { + name: "popup_menu_item_text", + value: COLORS.light.itemText, + }); + return config; + }); + config = withAndroidColorsNight(config, (config) => { + config.modResults = AndroidConfig.Colors.assignColorValue(config.modResults, { + name: "popup_menu_background", + value: COLORS.night.background, + }); + config.modResults = AndroidConfig.Colors.assignColorValue(config.modResults, { + name: "popup_menu_item_text", + value: COLORS.night.itemText, + }); + return config; + }); + return config; +} + +function withPopupMenuBackgroundDrawable(config) { + return withDangerousMod(config, [ + "android", + async (config) => { + const drawableDir = path.join( + config.modRequest.platformProjectRoot, + "app", + "src", + "main", + "res", + "drawable", + ); + fs.mkdirSync(drawableDir, { recursive: true }); + fs.writeFileSync( + path.join(drawableDir, "popup_menu_background.xml"), + POPUP_BACKGROUND_DRAWABLE, + ); + fs.writeFileSync(path.join(drawableDir, "ic_menu_check.xml"), CHECK_DRAWABLE); + fs.writeFileSync( + path.join(drawableDir, "popup_menu_check_button.xml"), + CHECKBOX_BUTTON_SELECTOR, + ); + fs.writeFileSync( + path.join(drawableDir, "popup_menu_submenu_arrow.xml"), + SUBMENU_ARROW_DRAWABLE, + ); + return config; + }, + ]); +} + +module.exports = function withAndroidModernPopupMenu(config) { + return withPopupMenuBackgroundDrawable(withPopupMenuColors(withPopupMenuStyles(config))); +}; diff --git a/apps/mobile/plugins/withAndroidPredictiveBackCompat.cjs b/apps/mobile/plugins/withAndroidPredictiveBackCompat.cjs new file mode 100644 index 00000000000..0014ca053f3 --- /dev/null +++ b/apps/mobile/plugins/withAndroidPredictiveBackCompat.cjs @@ -0,0 +1,104 @@ +const { withMainActivity } = require("expo/config-plugins"); + +// predictiveBackGestureEnabled writes android:enableOnBackInvokedCallback="true", +// which retires the legacy KEYCODE_BACK/onBackPressed() delivery on Android 13+. +// From then on back gestures only reach the app through OnBackPressedDispatcher +// callbacks. react-native 0.85 registers its own always-enabled callback, but +// only on Android 16 with targetSdk 36 (ReactActivity's enforced-predictive-back +// workaround) — on Android 13-15 nothing is registered, the system consumes +// every back gesture itself, and JS back handling (React Navigation pops, +// BackHandler listeners) silently dies: each gesture just backgrounds the app. +// This plugin mirrors react-native's shim on API 33-35 so back keeps flowing +// to JS there. See https://github.com/software-mansion/react-native-screens/discussions/2540. + +const CALLBACK_PROPERTY = ` + // Routes predictive-back gestures to JS on Android 13-15, where react-native + // registers no OnBackPressedDispatcher callback of its own (it only does on + // Android 16+). Registered in onCreate; added by withAndroidPredictiveBackCompat. + private val predictiveBackCompatCallback = object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + isEnabled = false + onBackPressed() + isEnabled = true + } + } +`; + +const CALLBACK_REGISTRATION = ` + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && Build.VERSION.SDK_INT < Build.VERSION_CODES.BAKLAVA) { + onBackPressedDispatcher.addCallback(this, predictiveBackCompatCallback) + }`; + +// Wraps the template's invokeDefaultOnBackPressed: the default action ends in +// ComponentActivity.onBackPressed(), which re-enters the dispatcher — with the +// compat callback still enabled that would bounce back into JS forever instead +// of backgrounding the app. +const INVOKE_DEFAULT_WRAPPER = `override fun invokeDefaultOnBackPressed() { + predictiveBackCompatCallback.isEnabled = false + try { + invokeDefaultOnBackPressedLegacy() + } finally { + predictiveBackCompatCallback.isEnabled = true + } + } + + private fun invokeDefaultOnBackPressedLegacy() {`; + +function insertAfter(contents, anchor, insertion, description) { + const index = contents.indexOf(anchor); + if (index === -1) { + throw new Error( + `withAndroidPredictiveBackCompat: could not find ${description} in MainActivity — the Expo template changed; update the plugin anchors.`, + ); + } + const end = index + anchor.length; + return contents.slice(0, end) + insertion + contents.slice(end); +} + +module.exports = function withAndroidPredictiveBackCompat(config) { + if (config.android?.predictiveBackGestureEnabled !== true) { + return config; + } + + return withMainActivity(config, (nextConfig) => { + let contents = nextConfig.modResults.contents; + if (nextConfig.modResults.language !== "kt") { + throw new Error("withAndroidPredictiveBackCompat: MainActivity must be Kotlin."); + } + if (contents.includes("predictiveBackCompatCallback")) { + return nextConfig; + } + + contents = insertAfter( + contents, + "import android.os.Bundle", + "\nimport androidx.activity.OnBackPressedCallback", + "the android.os.Bundle import", + ); + contents = insertAfter( + contents, + "class MainActivity : ReactActivity() {", + CALLBACK_PROPERTY, + "the MainActivity class declaration", + ); + contents = insertAfter( + contents, + "super.onCreate(null)", + CALLBACK_REGISTRATION, + "the super.onCreate call", + ); + + if (!contents.includes("override fun invokeDefaultOnBackPressed() {")) { + throw new Error( + "withAndroidPredictiveBackCompat: could not find invokeDefaultOnBackPressed in MainActivity — the Expo template changed; update the plugin anchors.", + ); + } + contents = contents.replace( + "override fun invokeDefaultOnBackPressed() {", + INVOKE_DEFAULT_WRAPPER, + ); + + nextConfig.modResults.contents = contents; + return nextConfig; + }); +}; diff --git a/apps/mobile/plugins/withIosCrashLog.cjs b/apps/mobile/plugins/withIosCrashLog.cjs new file mode 100644 index 00000000000..72afa09fd49 --- /dev/null +++ b/apps/mobile/plugins/withIosCrashLog.cjs @@ -0,0 +1,126 @@ +const { withAppDelegate } = require("expo/config-plugins"); + +const IMPORT_LINE = "internal import T3NativeControls"; +const INSTALL_CALL = "Self.installNativeFatalHandlersIfNeeded()"; +const METHOD_MARKER = "installNativeFatalHandlersIfNeeded"; +const FLAG_MARKER = "fatalHandlersInstalled"; + +const FATAL_HANDLER_METHOD = ` + private static var fatalHandlersInstalled = false + + /// Hooks RN's fatal path so reportFatal leaves last-crash.json even when the + /// JS ErrorUtils logger never flushes (the common Release Hermes failure mode). + private static func installNativeFatalHandlersIfNeeded() { + if fatalHandlersInstalled { + return + } + fatalHandlersInstalled = true + + let previousFatal = RCTGetFatalHandler() + let previousException = RCTGetFatalExceptionHandler() + + RCTSetFatalHandler { error in + if let error { + let nsError = error as NSError + T3CrashLog.persistNativeFatal( + message: nsError.localizedDescription, + name: "RCTFatal", + stack: T3CrashLog.formatJSStack(nsError.userInfo[RCTJSStackTraceKey]), + extra: T3CrashLog.stringValue(nsError.userInfo[RCTJSExtraDataKey]), + source: "rct-fatal" + ) + } + if let previousFatal { + previousFatal(error) + } else if let error { + let nsError = error as NSError + let description = nsError.localizedDescription + let name = "\\(RCTFatalExceptionName): \\(description)" + let stack = nsError.userInfo[RCTJSStackTraceKey] as? [[String: Any]] + let reason = RCTFormatError(description, stack, 175) + var userInfo = nsError.userInfo + userInfo["RCTUntruncatedMessageKey"] = RCTFormatError(description, stack, 0) + NSException(name: NSExceptionName(name), reason: reason, userInfo: userInfo).raise() + } + } + + RCTSetFatalExceptionHandler { exception in + if let exception { + T3CrashLog.persistNativeFatal( + message: exception.reason ?? exception.name.rawValue, + name: exception.name.rawValue, + stack: T3CrashLog.formatExceptionStack(exception), + extra: nil, + source: "rct-fatal-exception" + ) + } + if let previousException { + previousException(exception) + } else { + exception?.raise() + } + } + } +`; + +function ensureImport(contents) { + if (contents.includes("import T3NativeControls")) { + return contents; + } + if (contents.includes("import ReactAppDependencyProvider")) { + return contents.replace( + "import ReactAppDependencyProvider", + `import ReactAppDependencyProvider\n${IMPORT_LINE}`, + ); + } + if (contents.includes("import React\n")) { + return contents.replace("import React\n", `import React\n${IMPORT_LINE}\n`); + } + return `${IMPORT_LINE}\n${contents}`; +} + +function ensureInstallCall(contents) { + if (contents.includes(INSTALL_CALL)) { + return contents; + } + const replaced = contents.replace( + /(didFinishLaunchingWithOptions[^{]*\{\n)/, + `$1 // t3code: capture RCTFatal message/stack before JS can die.\n ${INSTALL_CALL}\n\n`, + ); + if (replaced === contents) { + throw new Error("withIosCrashLog: could not find didFinishLaunchingWithOptions body"); + } + return replaced; +} + +function ensureFatalHandlerMethod(contents) { + if (contents.includes(METHOD_MARKER) && contents.includes(FLAG_MARKER)) { + return contents; + } + if (contents.includes("// Linking API")) { + return contents.replace("// Linking API", `${FATAL_HANDLER_METHOD}\n // Linking API`); + } + // Fallback: insert before ReactNativeDelegate class. + if (contents.includes("class ReactNativeDelegate")) { + return contents.replace( + "class ReactNativeDelegate", + `${FATAL_HANDLER_METHOD}\n}\n\nclass ReactNativeDelegate`, + ); + } + throw new Error("withIosCrashLog: could not find insertion point for fatal handler method"); +} + +module.exports = function withIosCrashLog(config) { + return withAppDelegate(config, (nextConfig) => { + if (nextConfig.modResults.language !== "swift") { + throw new Error("The iOS crash log plugin requires a Swift AppDelegate."); + } + + let contents = nextConfig.modResults.contents; + contents = ensureImport(contents); + contents = ensureInstallCall(contents); + contents = ensureFatalHandlerMethod(contents); + nextConfig.modResults.contents = contents; + return nextConfig; + }); +}; diff --git a/apps/mobile/plugins/withoutIosPersonalTeamCapabilities.cjs b/apps/mobile/plugins/withoutIosPersonalTeamCapabilities.cjs new file mode 100644 index 00000000000..035c46b34c8 --- /dev/null +++ b/apps/mobile/plugins/withoutIosPersonalTeamCapabilities.cjs @@ -0,0 +1,10 @@ +const { withEntitlementsPlist } = require("expo/config-plugins"); + +module.exports = function withoutIosPersonalTeamCapabilities(config) { + return withEntitlementsPlist(config, (modConfig) => { + delete modConfig.modResults["aps-environment"]; + delete modConfig.modResults["com.apple.developer.applesignin"]; + delete modConfig.modResults["com.apple.security.application-groups"]; + return modConfig; + }); +}; diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index d0880a39798..3757839f085 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -1,51 +1,59 @@ -import { - DMSans_400Regular, - DMSans_500Medium, - DMSans_700Bold, - useFonts, -} from "@expo-google-fonts/dm-sans"; +import { BlurTargetView } from "expo-blur"; import * as Linking from "expo-linking"; import * as SplashScreen from "expo-splash-screen"; +import { useCallback, useEffect } from "react"; 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 { createStaticNavigation, DarkTheme, DefaultTheme } from "@react-navigation/native"; +import { + createStaticNavigation, + DarkTheme, + DefaultTheme, + type NavigationState, +} from "@react-navigation/native"; import { RegistryContext } from "@effect/atom-react"; -import { useEffect } from "react"; +import { ConfirmDialogHost } from "./components/ConfirmDialogHost"; import { CloudAuthProvider } from "./features/cloud/CloudAuthProvider"; import { AppearancePreferencesProvider } from "./features/settings/appearance/AppearancePreferencesProvider"; import { RootStack } from "./Stack"; import { appAtomRegistry } from "./state/atom-registry"; +import { OverlayPortalHost } from "./components/OverlayPortal"; +import { appBlurTargetRef } from "./lib/appBlurTarget"; +import { recordNavigationBreadcrumb } from "./lib/navigationBreadcrumb"; import { useThemeColor } from "./lib/useThemeColor"; import "../global.css"; const appLinking = { prefixes: [Linking.createURL("/"), "t3code://", "t3code-dev://", "t3code-preview://"], + // The Expo dev client launches the app via + // ://expo-development-client/?url= — that URL addresses + // the launcher, not app navigation. Without this filter it falls through + // to the NotFound wildcard route on every dev launch. + filter: (url: string) => !url.includes("expo-development-client"), }; const Navigation = createStaticNavigation(RootStack); export default function App() { - const [fontsLoaded] = useFonts({ - DMSans_400Regular, - DMSans_500Medium, - DMSans_700Bold, - }); const colorScheme = useColorScheme(); const statusBarBg = useThemeColor("--color-status-bar"); useEffect(() => { - if (fontsLoaded) SplashScreen.hide(); - }, [fontsLoaded]); + SplashScreen.hide(); + }, []); + + const onNavigationStateChange = useCallback((state: NavigationState | undefined) => { + recordNavigationBreadcrumb(state); + }, []); return ( - + + {/* Blur target for Android dropdown backdrops — see appBlurTarget.ts. */} + + + + + {/* Anchored-menu overlays render here — in-window, so the + keyboard stays up while a dropdown is open. */} + diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index d68d6f2b97b..3682da6dab1 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -41,10 +41,12 @@ import { NewTaskDraftRouteScreen } from "./features/threads/NewTaskDraftRouteScr import { NewTaskFlowProvider } from "./features/threads/new-task-flow-provider"; import { NewTaskRouteScreen } from "./features/threads/NewTaskRouteScreen"; import { SettingsAppearanceRouteScreen } from "./features/settings/SettingsAppearanceRouteScreen"; +import { SettingsClientStorageRouteScreen } from "./features/settings/SettingsClientStorageRouteScreen"; import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteScreen"; import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen"; import { SettingsRouteScreen } from "./features/settings/SettingsRouteScreen"; import { SettingsWaitlistRouteScreen } from "./features/settings/SettingsWaitlistRouteScreen"; +import { useAppShortcuts } from "./features/shortcuts/useAppShortcuts"; import { nativeHeaderScrollEdgeEffects } from "./native/StackHeader"; import { useThreadOutboxDrain } from "./state/use-thread-outbox-drain"; @@ -147,6 +149,13 @@ const SettingsSheetStack = createNativeStackNavigator({ title: "Appearance", }, }), + SettingsClientStorage: createNativeStackScreen({ + screen: SettingsClientStorageRouteScreen, + linking: "client-storage", + options: { + title: "Client Storage", + }, + }), SettingsAuth: createNativeStackScreen({ screen: SettingsAuthRouteScreen, linking: "auth", @@ -256,6 +265,8 @@ function RootStackLayout(props: { useThreadOutboxDrain(); // Presents the T3 Connect onboarding sheet after an in-session sign-in. useConnectOnboardingNavigation(); + // Launcher app shortcuts: routes shortcut taps and tracks opened threads. + useAppShortcuts(props.state); // Full pathname (sheets included) for keyboard-command scoping; the // workspace layout only reacts to the underlying non-overlay route. const path = getPathFromState(props.state, navigationPathConfig); @@ -348,9 +359,11 @@ export const RootStack = createNativeStackNavigator({ screen: ReviewCommentComposerSheet, linking: `${THREAD_LINKING_PREFIX}/review-comment`, options: { - presentation: "formSheet", - sheetAllowedDetents: [0.55, 0.92], - sheetGrabberVisible: true, + // Android cannot host the keyboard-driven comment composer inside a + // formSheet; use a full-screen modal there instead. + presentation: Platform.OS === "android" ? "fullScreenModal" : "formSheet", + sheetAllowedDetents: Platform.OS === "android" ? undefined : [0.55, 0.92], + sheetGrabberVisible: Platform.OS !== "android", }, }), ThreadFiles: createNativeStackScreen({ @@ -412,9 +425,15 @@ export const RootStack = createNativeStackNavigator({ options: { gestureEnabled: true, headerShown: false, - presentation: "formSheet", - sheetAllowedDetents: [0.7, 0.92], - sheetGrabberVisible: true, + // Android pushes settings as a regular full page with an in-screen + // back header; iOS keeps the detented form sheet. + ...(Platform.OS === "android" + ? { presentation: "card" as const } + : { + presentation: "formSheet" as const, + sheetAllowedDetents: [0.7, 0.92], + sheetGrabberVisible: true, + }), }, }), ConnectOnboarding: createNativeStackScreen({ @@ -436,9 +455,15 @@ export const RootStack = createNativeStackNavigator({ linking: "connections", options: { title: "Environments", - presentation: "formSheet", - sheetAllowedDetents: [0.55, 0.7], - sheetGrabberVisible: true, + // Android: full page; the screen renders its own AndroidScreenHeader, + // so the native bar stays hidden. iOS keeps the sheet. + ...(Platform.OS === "android" + ? { presentation: "card" as const, headerShown: false } + : { + presentation: "formSheet" as const, + sheetAllowedDetents: [0.55, 0.7], + sheetGrabberVisible: true, + }), }, }), ConnectionsNew: createNativeStackScreen({ @@ -460,9 +485,15 @@ export const RootStack = createNativeStackNavigator({ options: { gestureEnabled: true, headerShown: false, - presentation: "formSheet", - sheetAllowedDetents: [0.92], - sheetGrabberVisible: true, + // Android pushes the flow as a regular full page — the draft should + // read like a thread that just doesn't exist yet; iOS keeps the sheet. + ...(Platform.OS === "android" + ? { presentation: "card" as const } + : { + presentation: "formSheet" as const, + sheetAllowedDetents: [0.92], + sheetGrabberVisible: true, + }), }, }), NotFound: createNativeStackScreen({ diff --git a/apps/mobile/src/components/AndroidAnchoredMenu.tsx b/apps/mobile/src/components/AndroidAnchoredMenu.tsx new file mode 100644 index 00000000000..c4a0045eefb --- /dev/null +++ b/apps/mobile/src/components/AndroidAnchoredMenu.tsx @@ -0,0 +1,337 @@ +import type { MenuAction, MenuComponentProps } from "@react-native-menu/menu"; +import { BlurView } from "expo-blur"; +import type { ReactNode } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import type { StyleProp, ViewStyle } from "react-native"; +import { BackHandler, Pressable, ScrollView, useColorScheme, View } from "react-native"; +import { useKeyboardState } from "react-native-keyboard-controller"; +import Animated, { FadeIn } from "react-native-reanimated"; + +import { appBlurTargetRef } from "../lib/appBlurTarget"; +import { useThemeColor } from "../lib/useThemeColor"; +import { cn } from "../lib/cn"; +import { type AppSymbolName, SymbolView } from "./AppSymbol"; +import { AppText as Text } from "./AppText"; +import { OverlayPortal } from "./OverlayPortal"; + +const MENU_WIDTH = 250; +const SCREEN_MARGIN = 12; +const ANCHOR_GAP = 6; + +// Anchor position is snapshotted in window coordinates when the menu opens; +// the overlay root measures itself the same way, and the menu is placed from +// the delta. Both snapshots are taken at open time so later reflows (keyboard +// show/hide, screen transitions) can't flip an opens-up menu to opens-down +// mid-presentation. +type AnchorSnapshot = { + readonly x: number; + readonly y: number; + readonly width: number; + readonly height: number; +}; + +type OverlayFrame = { + readonly x: number; + readonly y: number; + readonly width: number; + readonly height: number; +}; + +export type AndroidAnchoredMenuProps = { + readonly actions: readonly MenuAction[]; + readonly title?: string; + readonly onPressAction?: MenuComponentProps["onPressAction"]; + /** Applied to the anchor wrapper — call sites flex these to fill toolbars. */ + readonly className?: string; + readonly style?: StyleProp; + /** + * Plain children open the menu on tap (the wrapper owns the press). A + * render function keeps the children interactive and hands them `open` to + * call from their own gesture — e.g. a row that selects on tap and opens + * this menu on long-press. + */ + readonly children: ReactNode | ((open: () => void) => ReactNode); +}; + +/** + * Token-styled anchored dropdown for Android, drop-in for the subset of the + * MenuView contract the app uses (actions with state/subtitle/image/ + * attributes, one level of subactions). The native AppCompat PopupMenu caps + * out on theming — stock animation, item metrics, and submenu chrome — so + * ControlPillMenu renders this instead on Android while iOS keeps the native + * UIMenu. Styling follows the themed native popup (12dp radius, plain rows, + * trailing check glyph); submenus drill in under a muted parent-title header. + */ +export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { + const [anchor, setAnchor] = useState(null); + const [path, setPath] = useState([]); + // Height of the modal's root view, in the modal's own coordinate space. + // Menus that flip above their anchor are pinned by their BOTTOM edge + // (bottom = rootHeight - anchorTop), so drill-in height changes grow + // upward without any re-measurement — positioning them via `top` from the + // menu's measured height made every submenu transition settle over two + // frames and jitter. + const [rootHeight, setRootHeight] = useState(null); + // Window frame of the overlay root, measured on layout. Anchor coordinates + // are converted into this frame, so the menu lands correctly no matter + // where the portal host sits (status bar, keyboard resize, etc.). + const [overlay, setOverlay] = useState(null); + const anchorRef = useRef(null); + const overlayRef = useRef(null); + + const isDarkMode = useColorScheme() === "dark"; + const keyboardVisible = useKeyboardState((state) => state.isVisible); + const keyboardHeight = useKeyboardState((state) => state.height); + const rippleColor = useThemeColor("--color-subtle"); + const iconColor = useThemeColor("--color-icon"); + const iconSubtleColor = useThemeColor("--color-icon-subtle"); + const dangerColor = useThemeColor("--color-danger-foreground"); + + const close = useCallback(() => { + setAnchor(null); + setPath([]); + setOverlay(null); + setRootHeight(null); + }, []); + + const open = useCallback(() => { + anchorRef.current?.measureInWindow((x, y, width, height) => { + setAnchor({ x, y, width, height }); + }); + }, []); + + const measureOverlay = useCallback(() => { + overlayRef.current?.measureInWindow((x, y, width, height) => { + setOverlay({ x, y, width, height }); + setRootHeight(height); + }); + }, []); + + // The dropdown renders in-window (no Modal takes focus), so the hardware + // back gesture needs explicit handling while it is open. Back steps out of + // a drilled-in submenu one level at a time (mirroring the tappable parent + // header) before closing the menu. Under predictive back + // (enableOnBackInvokedCallback) this stays correct: back reaches JS + // through always-registered OnBackPressedDispatcher callbacks (react-native + // core on Android 16+, withAndroidPredictiveBackCompat on 13-15), which + // also keeps the system from playing a "leave app" preview while the menu + // merely closes. + const submenuDepth = path.length; + useEffect(() => { + if (anchor === null) { + return; + } + const subscription = BackHandler.addEventListener("hardwareBackPress", () => { + if (submenuDepth > 0) { + setPath((current) => current.slice(0, -1)); + } else { + close(); + } + return true; + }); + return () => subscription.remove(); + }, [anchor, close, submenuDepth]); + + const parent = path.length > 0 ? path[path.length - 1] : null; + const levelActions = (parent?.subactions ?? props.actions).filter( + (action) => !(action.attributes?.hidden ?? false), + ); + + // Anchor in overlay-local coordinates (both measured in window space). + const local = + anchor === null || overlay === null + ? null + : { + x: anchor.x - overlay.x, + y: anchor.y - overlay.y, + width: anchor.width, + height: anchor.height, + }; + const preferredLeft = + local === null || overlay === null + ? 0 + : local.x + local.width / 2 <= overlay.width / 2 + ? local.x + : local.x + local.width - MENU_WIDTH; + const left = + overlay === null + ? 0 + : Math.min( + Math.max(preferredLeft, SCREEN_MARGIN), + overlay.width - MENU_WIDTH - SCREEN_MARGIN, + ); + // The keyboard stays up while the menu is open (in-window overlay, no + // focus change), so the space it covers is not usable — without this the + // composer-pill menus "open down" into the IME and can't be tapped. + const usableBottom = + overlay === null ? 0 : overlay.height - (keyboardVisible ? keyboardHeight : 0); + const spaceBelow = + local === null || overlay === null + ? 0 + : usableBottom - (local.y + local.height) - ANCHOR_GAP - SCREEN_MARGIN; + const spaceAbove = local === null ? 0 : local.y - ANCHOR_GAP - SCREEN_MARGIN; + const opensDown = spaceBelow >= 280 || spaceBelow >= spaceAbove; + const maxHeight = Math.min(opensDown ? spaceBelow : spaceAbove, 480); + // The menu needs the overlay frame before it can be placed; it stays + // unmounted for that first frame so the fade-in plays at the final position. + const placeable = local !== null && rootHeight !== null; + + const onPressItem = useCallback( + (action: MenuAction) => { + if ((action.subactions?.length ?? 0) > 0) { + setPath((current) => [...current, action]); + return; + } + close(); + if (action.id !== undefined) { + props.onPressAction?.({ + nativeEvent: { event: action.id }, + } as Parameters>[0]); + } + }, + [close, props.onPressAction], + ); + + return ( + <> + {typeof props.children === "function" ? ( + + {props.children(open)} + + ) : ( + + {props.children} + + )} + {anchor === null ? null : ( + + + + {!placeable || local === null ? null : ( + + {/* Frosted backdrop: blur of the app content behind the menu, + washed with the translucent card tone so rows keep contrast. */} + + + {/* keyboardShouldPersistTaps: the menu often opens over an + active editor; the first item tap must act, not just + dismiss the keyboard. */} + + {parent !== null ? ( + // Muted parent title as the submenu header; tapping it + // steps back, but it reads as a label, not a button. + setPath((current) => current.slice(0, -1))} + > + + {parent.title} + + + ) : props.title ? ( + <> + + + {props.title} + + + + + ) : null} + {levelActions.map((action, index) => { + const destructive = action.attributes?.destructive ?? false; + const disabled = action.attributes?.disabled ?? false; + const hasSubmenu = (action.subactions?.length ?? 0) > 0; + return ( + onPressItem(action)} + > + + + {action.title} + + {action.subtitle ? ( + + {action.subtitle} + + ) : null} + + {hasSubmenu ? ( + + ) : action.state === "on" ? ( + + ) : action.image ? ( + + ) : null} + + ); + })} + + + )} + + + )} + + ); +} diff --git a/apps/mobile/src/components/AndroidScreenHeader.tsx b/apps/mobile/src/components/AndroidScreenHeader.tsx new file mode 100644 index 00000000000..8e803d3c1a6 --- /dev/null +++ b/apps/mobile/src/components/AndroidScreenHeader.tsx @@ -0,0 +1,110 @@ +import type { ReactNode } from "react"; +import { Pressable, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { SymbolView, type AppSymbolName } from "./AppSymbol"; +import { AppText as Text } from "./AppText"; +import { cn } from "../lib/cn"; +import { useThemeColor } from "../lib/useThemeColor"; + +export interface AndroidHeaderAction { + readonly accessibilityLabel: string; + readonly icon: AppSymbolName; + readonly onPress: () => void; + readonly disabled?: boolean; +} + +export function AndroidHeaderIconButton(props: { + readonly accessibilityLabel: string; + readonly icon: AppSymbolName; + readonly onPress?: () => void; + readonly disabled?: boolean; +}) { + const foregroundColor = useThemeColor("--color-foreground"); + const disabledColor = useThemeColor("--color-icon-subtle"); + + return ( + + + + ); +} + +export function AndroidScreenHeader(props: { + readonly title: string; + readonly subtitle?: string | null; + readonly actions?: ReadonlyArray; + readonly trailing?: ReactNode; + readonly onBack?: () => void; +}) { + const insets = useSafeAreaInsets(); + const foregroundColor = useThemeColor("--color-foreground"); + + return ( + + + {props.onBack ? ( + + + + ) : null} + + + + {props.title} + + {props.subtitle ? ( + + {props.subtitle} + + ) : null} + + + {props.actions?.map((action) => ( + + ))} + {props.trailing} + + + ); +} diff --git a/apps/mobile/src/components/AppSymbol.tsx b/apps/mobile/src/components/AppSymbol.tsx new file mode 100644 index 00000000000..ba1dd59585e --- /dev/null +++ b/apps/mobile/src/components/AppSymbol.tsx @@ -0,0 +1,204 @@ +import { + IconAdjustmentsHorizontal, + IconAlertCircle, + IconAlertTriangle, + IconArchive, + IconArrowBackUp, + IconArrowDownCircle, + IconArrowRight, + IconArrowRightCircle, + IconArrowUp, + IconArrowUpCircle, + IconArrowUpRight, + IconArrowUpRightCircle, + IconArrowsMaximize, + IconBellRinging, + IconBolt, + IconCamera, + IconCheck, + IconChevronDown, + IconCode, + IconChevronLeft, + IconChevronRight, + IconChevronUp, + IconCircleCheck, + IconCircleXFilled, + IconCopy, + IconDeviceDesktop, + IconDots, + IconDotsCircleHorizontal, + IconEdit, + IconExternalLink, + IconEye, + IconFileText, + IconFilter, + IconFolder, + IconFolderOpen, + IconFolderPlus, + IconGitBranch, + IconHammer, + IconGitMerge, + IconGitPullRequest, + IconInfoCircle, + IconKeyboard, + IconKeyboardHide, + IconLayoutColumns, + IconLayoutSidebar, + IconLetterSpacing, + IconLink, + IconMessage, + IconMinus, + IconNetwork, + IconPalette, + IconPlayerPlay, + IconPlayerStopFilled, + IconPlus, + IconQrcode, + IconRefresh, + IconSearch, + IconServer, + IconSettings, + IconSparkles, + IconLayoutSidebarRight, + IconTerminal2, + IconTextDecrease, + IconTextIncrease, + IconTool, + IconTrash, + IconTypography, + IconUserCircle, + IconWifiOff, + IconWorld, + IconX, + type Icon, +} from "@tabler/icons-react-native"; +import { Platform } from "react-native"; +import { SymbolView as ExpoSymbolView, type SFSymbol, type SymbolViewProps } from "expo-symbols"; + +const ANDROID_ICON_BY_SF_SYMBOL: Partial> = { + "arrow.branch": IconGitBranch, + "arrow.clockwise": IconRefresh, + "arrow.down.circle": IconArrowDownCircle, + "arrow.right": IconArrowRight, + "arrow.right.circle": IconArrowRightCircle, + "arrow.triangle.branch": IconGitBranch, + "arrow.triangle.pull": IconGitPullRequest, + "arrow.turn.left.up": IconArrowBackUp, + "arrow.up": IconArrowUp, + "arrow.up.circle": IconArrowUpCircle, + "arrow.up.left.and.arrow.down.right": IconArrowsMaximize, + "arrow.up.right": IconArrowUpRight, + "arrow.up.right.circle": IconArrowUpRightCircle, + "arrow.uturn.backward": IconArrowBackUp, + archivebox: IconArchive, + "archivebox.fill": IconArchive, + "bell.badge": IconBellRinging, + "bolt.circle": IconBolt, + "bolt.horizontal.circle": IconBolt, + camera: IconCamera, + checkmark: IconCheck, + "checkmark.circle": IconCircleCheck, + "chevron.down": IconChevronDown, + "chevron.left": IconChevronLeft, + "chevron.left.forwardslash.chevron.right": IconCode, + "chevron.right": IconChevronRight, + "chevron.up": IconChevronUp, + desktopcomputer: IconDeviceDesktop, + "doc.on.doc": IconCopy, + "doc.text": IconFileText, + ellipsis: IconDots, + "ellipsis.circle": IconDotsCircleHorizontal, + "exclamationmark.triangle": IconAlertTriangle, + eye: IconEye, + folder: IconFolder, + "folder.badge.plus": IconFolderPlus, + "folder.fill": IconFolder, + gearshape: IconSettings, + "info.circle": IconInfoCircle, + link: IconLink, + "line.3.horizontal.decrease.circle": IconFilter, + "line.3.horizontal.decrease.circle.fill": IconFilter, + magnifyingglass: IconSearch, + paintbrush: IconPalette, + "person.crop.circle": IconUserCircle, + play: IconPlayerPlay, + plus: IconPlus, + "qrcode.viewfinder": IconQrcode, + "point.3.connected.trianglepath.dotted": IconNetwork, + "point.topleft.down.curvedto.point.bottomright.up": IconGitMerge, + safari: IconExternalLink, + "server.rack": IconServer, + "sidebar.left": IconLayoutSidebar, + "sidebar.right": IconLayoutSidebarRight, + "slider.horizontal.3": IconAdjustmentsHorizontal, + "square.and.pencil": IconEdit, + "square.split.2x1": IconLayoutColumns, + "stop.fill": IconPlayerStopFilled, + terminal: IconTerminal2, + "text.bubble": IconMessage, + "text.word.spacing": IconLetterSpacing, + "textformat.size": IconTypography, + "textformat.size.larger": IconTextIncrease, + "textformat.size.smaller": IconTextDecrease, + trash: IconTrash, + "wifi.slash": IconWifiOff, + xmark: IconX, + "xmark.circle.fill": IconCircleXFilled, +}; + +// Callers can pass `{ ios, android }` names where `android` is a Material +// icon name (the raw expo-symbols contract). Resolve those here too so the +// android key keeps working through this wrapper — it wins over the SF map +// when both match (e.g. folder vs folder_open for expanded project groups). +const ANDROID_ICON_BY_MATERIAL_NAME: Record = { + auto_awesome: IconSparkles, + bolt: IconBolt, + build: IconTool, + chat_bubble: IconMessage, + check: IconCheck, + close: IconX, + construction: IconHammer, + content_copy: IconCopy, + edit: IconEdit, + error: IconAlertCircle, + folder: IconFolder, + folder_open: IconFolderOpen, + keyboard: IconKeyboard, + keyboard_arrow_down: IconChevronDown, + keyboard_arrow_up: IconChevronUp, + keyboard_hide: IconKeyboardHide, + public: IconWorld, + remove: IconMinus, + terminal: IconTerminal2, + visibility: IconEye, +}; + +export type { SFSymbol } from "expo-symbols"; +export type AppSymbolName = SymbolViewProps["name"]; + +export function SymbolView(props: SymbolViewProps) { + if (Platform.OS !== "android") { + return ; + } + + const materialName = typeof props.name === "string" ? undefined : props.name.android; + const sfSymbol = typeof props.name === "string" ? props.name : props.name.ios; + const AndroidIcon = + (materialName ? ANDROID_ICON_BY_MATERIAL_NAME[materialName] : undefined) ?? + (sfSymbol ? ANDROID_ICON_BY_SF_SYMBOL[sfSymbol] : undefined); + + if (!AndroidIcon) { + return props.fallback ?? null; + } + + return ( + + ); +} diff --git a/apps/mobile/src/components/AppText.tsx b/apps/mobile/src/components/AppText.tsx index 33bffb1f8cd..39517f0e62e 100644 --- a/apps/mobile/src/components/AppText.tsx +++ b/apps/mobile/src/components/AppText.tsx @@ -4,7 +4,6 @@ import { type TextInputProps as RNTextInputProps, type TextProps as RNTextProps, } from "react-native"; -import { useThemeColor } from "../lib/useThemeColor"; import { cn } from "../lib/cn"; @@ -18,7 +17,7 @@ export function AppText({ className, ...props }: AppTextProps) { return ; } -export type AppTextInputProps = RNTextInputProps & { +export type AppTextInputProps = Omit & { readonly className?: string; readonly ref?: React.Ref; }; @@ -27,14 +26,7 @@ export type AppTextInputProps = RNTextInputProps & { * Thin wrapper around RN TextInput with default input styling. * Uses Uniwind className — no manual style parsing. */ -export function AppTextInput({ - className, - placeholderTextColor, - ref, - ...props -}: AppTextInputProps) { - const placeholderColor = useThemeColor("--color-placeholder"); - +export function AppTextInput({ className, ref, ...props }: AppTextInputProps) { return ( ); diff --git a/apps/mobile/src/components/BrandMark.tsx b/apps/mobile/src/components/BrandMark.tsx index 78aaf6b0ce4..4d1ac01b1ff 100644 --- a/apps/mobile/src/components/BrandMark.tsx +++ b/apps/mobile/src/components/BrandMark.tsx @@ -22,14 +22,9 @@ export function BrandMark(props: { readonly compact?: boolean; readonly stageLab /> - - T3 Code - + T3 Code - + {stageLabel} diff --git a/apps/mobile/src/components/ComposerAttachmentStrip.tsx b/apps/mobile/src/components/ComposerAttachmentStrip.tsx index 5b4d3d626c9..0621285c03e 100644 --- a/apps/mobile/src/components/ComposerAttachmentStrip.tsx +++ b/apps/mobile/src/components/ComposerAttachmentStrip.tsx @@ -1,4 +1,4 @@ -import { SymbolView } from "expo-symbols"; +import { SymbolView } from "../components/AppSymbol"; import { Image, Pressable, ScrollView, View } from "react-native"; import { useThemeColor } from "../lib/useThemeColor"; @@ -39,14 +39,14 @@ export function ComposerAttachmentStrip(props: ComposerAttachmentStripProps) { horizontal showsHorizontalScrollIndicator={false} keyboardShouldPersistTaps="always" - style={{ flexGrow: 0 }} + className="grow-0" > - + {props.attachments.map((image) => ( props.onRemove(image.id)} diff --git a/apps/mobile/src/components/ComposerToolbarTrigger.tsx b/apps/mobile/src/components/ComposerToolbarTrigger.tsx index e054a13f697..20187624964 100644 --- a/apps/mobile/src/components/ComposerToolbarTrigger.tsx +++ b/apps/mobile/src/components/ComposerToolbarTrigger.tsx @@ -1,4 +1,3 @@ -import { SymbolView } from "expo-symbols"; import type { ComponentProps, ReactNode } from "react"; import { useCallback, useMemo, useState } from "react"; import { @@ -16,6 +15,7 @@ import { import { useThemeColor } from "../lib/useThemeColor"; import { cn } from "../lib/cn"; import { AppText as Text } from "./AppText"; +import { SymbolView } from "./AppSymbol"; export const COMPOSER_TOOLBAR_CONTROL_HEIGHT = 44; export const COMPOSER_TOOLBAR_GAP = 8; @@ -31,11 +31,9 @@ export function ComposerToolbarRow(props: { }) { return ( + ; }) { const isDarkMode = useColorScheme() === "dark"; @@ -183,7 +182,11 @@ export function ComposerToolbarButton(props: { disabled={props.disabled} onPress={props.onPress} className={cn( - "h-11 flex-row items-center justify-center rounded-full active:opacity-70", + // Default width cap lives in the class chain (not the inline style) + // so callers can lift it with max-w-full — flex-filling pills in the + // thread composer stretch to the row's edge. The numeric maxWidth + // prop still wins via the inline style below. + "h-11 max-w-[172px] flex-row items-center justify-center rounded-full active:opacity-70", isCircle ? "w-11" : "gap-2 px-3.5", variant === "primary" ? props.disabled @@ -194,6 +197,7 @@ export function ComposerToolbarButton(props: { : props.active ? "bg-subtle-strong" : "bg-subtle", + props.className, )} style={({ pressed }) => [ { @@ -204,7 +208,7 @@ export function ComposerToolbarButton(props: { : defaultBorderColor : filledBorderColor, borderWidth: 1, - maxWidth: props.maxWidth ?? 172, + maxWidth: props.maxWidth, minWidth: props.minWidth, opacity: props.disabled ? 0.55 : pressed ? 0.72 : 1, shadowColor: "#000", diff --git a/apps/mobile/src/components/ConfirmDialogHost.tsx b/apps/mobile/src/components/ConfirmDialogHost.tsx new file mode 100644 index 00000000000..81daa3d6a2d --- /dev/null +++ b/apps/mobile/src/components/ConfirmDialogHost.tsx @@ -0,0 +1,111 @@ +import { useCallback, useEffect, useState } from "react"; +import { Modal, Pressable, View } from "react-native"; + +import { useThemeColor } from "../lib/useThemeColor"; +import { cn } from "../lib/cn"; +import { AppText } from "./AppText"; + +export type ConfirmDialogRequest = { + readonly title: string; + readonly message?: string; + readonly cancelText?: string; + readonly confirmText: string; + readonly destructive?: boolean; + readonly onConfirm: () => void; + readonly onCancel?: () => void; +}; + +let presentRequest: ((request: ConfirmDialogRequest) => void) | null = null; + +/** + * Imperative confirm dialog, Alert.alert-shaped. Native iOS alerts already + * match the app (and support per-button destructive red), so this is for + * Android, where the native dialog can only theme all confirm buttons at + * once. Requires ConfirmDialogHost to be mounted at the app root. + */ +export function showConfirmDialog(request: ConfirmDialogRequest): void { + presentRequest?.(request); +} + +/** + * Android-style alert dialog matching the native one themed by + * withAndroidModernAlertDialog — left-aligned text, right-aligned text + * buttons — with what the native theme can't do: a per-dialog destructive + * button color and a dimmer message than the title. + */ +export function ConfirmDialogHost() { + const [request, setRequest] = useState(null); + const pressedOverlay = useThemeColor("--color-subtle"); + + useEffect(() => { + presentRequest = setRequest; + return () => { + presentRequest = null; + }; + }, []); + + const handleCancel = useCallback(() => { + request?.onCancel?.(); + setRequest(null); + }, [request]); + + const handleConfirm = useCallback(() => { + request?.onConfirm(); + setRequest(null); + }, [request]); + + return ( + + {request === null ? null : ( + + + {request.title} + {request.message === undefined ? null : ( + + {request.message} + + )} + + + + + {request.cancelText ?? "Cancel"} + + + + + + + {request.confirmText} + + + + + + + )} + + ); +} diff --git a/apps/mobile/src/components/ControlPill.tsx b/apps/mobile/src/components/ControlPill.tsx index 670c17fbfbf..31e95cf9dbb 100644 --- a/apps/mobile/src/components/ControlPill.tsx +++ b/apps/mobile/src/components/ControlPill.tsx @@ -1,10 +1,18 @@ import { MenuView } from "@react-native-menu/menu"; -import type { ComponentProps, ReactNode } from "react"; -import { Pressable, useColorScheme, View } from "react-native"; -import { SymbolView } from "expo-symbols"; +import * as Haptics from "expo-haptics"; +import { + cloneElement, + isValidElement, + type ComponentProps, + type ReactElement, + type ReactNode, +} from "react"; +import { Platform, Pressable, useColorScheme, View } from "react-native"; import { useThemeColor } from "../lib/useThemeColor"; import { cn } from "../lib/cn"; +import { AndroidAnchoredMenu } from "./AndroidAnchoredMenu"; +import { SymbolView } from "./AppSymbol"; import { AppText as Text } from "./AppText"; export function ControlPill(props: { @@ -74,16 +82,60 @@ export function ControlPill(props: { ); } +// iOS renders the native UIMenu (standard checkmark for `state: "on"`); +// Android renders the token-styled AndroidAnchoredMenu, since the native +// AppCompat popup can't be themed past its stock animation, metrics, and +// submenu chrome. export function ControlPillMenu( props: Omit, "children" | "themeVariant"> & { readonly children: ReactNode; + readonly className?: string; }, ) { const isDarkMode = useColorScheme() === "dark"; + if (Platform.OS === "android") { + // Long-press menus keep their child interactive: the child element gets + // an injected onLongPress (mirroring the iOS context-menu interaction) + // so its own tap handling still works. + if (props.shouldOpenOnLongPress && isValidElement(props.children)) { + const child = props.children as ReactElement<{ onLongPress?: () => void }>; + return ( + + {(open) => + cloneElement(child, { + onLongPress: () => { + void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); + open(); + }, + }) + } + + ); + } + return ( + + {props.children} + + ); + } + + const { className: _className, ...menuProps } = props; return ( - - {props.children} + + {menuProps.children} ); } diff --git a/apps/mobile/src/components/CopyTextButton.tsx b/apps/mobile/src/components/CopyTextButton.tsx index 712b272a909..7f4e060eda0 100644 --- a/apps/mobile/src/components/CopyTextButton.tsx +++ b/apps/mobile/src/components/CopyTextButton.tsx @@ -1,4 +1,4 @@ -import { SymbolView } from "expo-symbols"; +import { SymbolView } from "../components/AppSymbol"; import { memo, useEffect, useRef, useState } from "react"; import { Pressable, type ColorValue } from "react-native"; @@ -58,7 +58,11 @@ export const CopyTextButton = memo(function CopyTextButton(props: { })} > - {leftSlot} - - {centerSlot} - - {rightSlot} + {leftSlot} + {centerSlot} + {rightSlot} diff --git a/apps/mobile/src/components/GlassSurface.tsx b/apps/mobile/src/components/GlassSurface.tsx index 03311e3b7e9..f34bd4e2836 100644 --- a/apps/mobile/src/components/GlassSurface.tsx +++ b/apps/mobile/src/components/GlassSurface.tsx @@ -10,7 +10,7 @@ import { } from "react-native"; import { useThemeColor } from "../lib/useThemeColor"; -export interface GlassSurfaceProps extends ViewProps { +export interface GlassSurfaceProps extends Omit { readonly children: ReactNode; readonly glassEffectStyle?: "clear" | "regular" | "none"; readonly tintColor?: ColorValue; diff --git a/apps/mobile/src/components/OverlayPortal.tsx b/apps/mobile/src/components/OverlayPortal.tsx new file mode 100644 index 00000000000..747595174ff --- /dev/null +++ b/apps/mobile/src/components/OverlayPortal.tsx @@ -0,0 +1,69 @@ +import { type ReactNode, useEffect, useRef, useState } from "react"; +import { View } from "react-native"; + +// Minimal in-tree portal for Android overlays. AndroidAnchoredMenu projects +// its dropdown here instead of into an RN Modal: a Modal is a separate native +// window, so presenting one moves window focus and closes the soft keyboard — +// which matters for menus anchored to the keyboard-sticky composer pills. +type Entries = ReadonlyMap; +type Listener = (entries: Entries) => void; + +let nextKey = 0; +const entries = new Map(); +const listeners = new Set(); + +function emit() { + const snapshot = new Map(entries); + for (const listener of listeners) { + listener(snapshot); + } +} + +/** Projects children into the app-root OverlayPortalHost. */ +export function OverlayPortal(props: { readonly children: ReactNode }) { + const keyRef = useRef(null); + keyRef.current ??= nextKey++; + const key = keyRef.current; + + // No dependency array: re-project after every render so the host always + // shows the current content (menus re-render while open — drill-in, theme). + useEffect(() => { + entries.set(key, props.children); + emit(); + }); + + useEffect( + () => () => { + entries.delete(key); + emit(); + }, + [key], + ); + + return null; +} + +/** Mounted once at the app root, above the navigation container. */ +export function OverlayPortalHost() { + const [current, setCurrent] = useState(() => new Map()); + + useEffect(() => { + listeners.add(setCurrent); + return () => { + listeners.delete(setCurrent); + }; + }, []); + + if (current.size === 0) { + return null; + } + return ( + + {[...current.entries()].map(([key, node]) => ( + + {node} + + ))} + + ); +} diff --git a/apps/mobile/src/components/PierreEntryIcon.tsx b/apps/mobile/src/components/PierreEntryIcon.tsx index 15ea24331b0..fa79c4f60e8 100644 --- a/apps/mobile/src/components/PierreEntryIcon.tsx +++ b/apps/mobile/src/components/PierreEntryIcon.tsx @@ -1,4 +1,4 @@ -import { SymbolView } from "expo-symbols"; +import { SymbolView } from "../components/AppSymbol"; import { Image, type ImageStyle, type StyleProp } from "react-native"; import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons"; diff --git a/apps/mobile/src/components/ProjectFavicon.tsx b/apps/mobile/src/components/ProjectFavicon.tsx index ba306c5a9fe..7c29eeaa24d 100644 --- a/apps/mobile/src/components/ProjectFavicon.tsx +++ b/apps/mobile/src/components/ProjectFavicon.tsx @@ -1,6 +1,7 @@ -import { SymbolView } from "expo-symbols"; +import { SymbolView } from "./AppSymbol"; +import { Image } from "expo-image"; import { useState } from "react"; -import { Image, View } from "react-native"; +import { View } from "react-native"; import type { EnvironmentId } from "@t3tools/contracts"; import { useThemeColor } from "../lib/useThemeColor"; import { useAssetUrl } from "../state/assets"; @@ -11,6 +12,7 @@ const loadedFaviconUrls = new Set(); /* ─── Component ──────────────────────────────────────────────────────── */ export function ProjectFavicon(props: { readonly environmentId: EnvironmentId; + readonly open?: boolean; readonly size?: number; readonly projectTitle: string; readonly workspaceRoot?: string | null; @@ -27,6 +29,7 @@ export function ProjectFavicon(props: { @@ -35,6 +38,7 @@ export function ProjectFavicon(props: { function ProjectFaviconImage(props: { readonly faviconUrl: string | null; + readonly open?: boolean; readonly projectTitle: string; readonly size: number; }) { @@ -58,7 +62,7 @@ function ProjectFaviconImage(props: { {/* Folder icon fallback (matches web's FolderIcon) */} {!showImage ? ( { if (props.faviconUrl) loadedFaviconUrls.add(props.faviconUrl); setStatus("loaded"); diff --git a/apps/mobile/src/components/T3Wordmark.tsx b/apps/mobile/src/components/T3Wordmark.tsx new file mode 100644 index 00000000000..81106557c66 --- /dev/null +++ b/apps/mobile/src/components/T3Wordmark.tsx @@ -0,0 +1,23 @@ +import type { ColorValue } from "react-native"; +import Svg, { Path } from "react-native-svg"; + +/** + * The "T3" brand mark, matching the desktop sidebar's T3Wordmark SVG + * (apps/web Sidebar.tsx). Width derives from the viewBox aspect ratio. + */ +export function T3Wordmark(props: { readonly height: number; readonly color: ColorValue }) { + const aspectRatio = 94.3941 / 56.96; + return ( + + + + ); +} diff --git a/apps/mobile/src/connection/catalog-store.ts b/apps/mobile/src/connection/catalog-store.ts index b5bda400670..6a4fcd35f6d 100644 --- a/apps/mobile/src/connection/catalog-store.ts +++ b/apps/mobile/src/connection/catalog-store.ts @@ -10,6 +10,7 @@ import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; +import * as MobileSecureStorage from "../persistence/mobile-secure-storage"; import { migrateLegacyConnectionCatalog } from "./migration"; export const CONNECTION_CATALOG_KEY = "t3code.connection-catalog.v1"; @@ -22,23 +23,22 @@ function catalogError(operation: string, cause: unknown) { }); } +const ConnectionCatalogDocumentJson = Schema.fromJsonString(ConnectionCatalogDocument); +const decodeConnectionCatalogDocument = Schema.decodeEffect(ConnectionCatalogDocumentJson); +const encodeConnectionCatalogDocument = Schema.encodeEffect(ConnectionCatalogDocumentJson); + const decodeCatalog = Effect.fn("mobile.connectionStorage.decodeCatalog")(function* (raw: string) { - const parsed = yield* Effect.try({ - try: () => JSON.parse(raw) as unknown, - catch: (cause) => catalogError("decode", cause), - }); - return yield* Effect.fromResult( - Schema.decodeUnknownResult(ConnectionCatalogDocument)(parsed), - ).pipe(Effect.mapError((cause) => catalogError("decode", cause))); + return yield* decodeConnectionCatalogDocument(raw).pipe( + Effect.mapError((cause) => catalogError("decode", cause)), + ); }); const encodeCatalog = Effect.fn("mobile.connectionStorage.encodeCatalog")(function* ( catalog: ConnectionCatalogDocumentType, ) { - const encoded = yield* Effect.fromResult( - Schema.encodeUnknownResult(ConnectionCatalogDocument)(catalog), - ).pipe(Effect.mapError((cause) => catalogError("encode", cause))); - return JSON.stringify(encoded); + return yield* encodeConnectionCatalogDocument(catalog).pipe( + Effect.mapError((cause) => catalogError("encode", cause)), + ); }); interface CatalogStore { @@ -48,20 +48,19 @@ interface CatalogStore { ) => Effect.Effect; } -export interface SecureCatalogStorage { - readonly getItem: (key: string) => Effect.Effect; - readonly setItem: (key: string, value: string) => Effect.Effect; - readonly deleteItem: (key: string) => Effect.Effect; -} - -export const makeCatalogStore = Effect.fn("mobile.connectionStorage.makeCatalogStore")(function* ( - storage: SecureCatalogStorage, -) { +export const make = Effect.fn("mobile.connectionStorage.makeCatalogStore")(function* () { + const storage = yield* MobileSecureStorage.MobileSecureStorage; + const getItem = (key: string) => + storage.getItem(key).pipe(Effect.mapError((cause) => catalogError("load", cause))); + const setItem = (key: string, value: string) => + storage.setItem(key, value).pipe(Effect.mapError((cause) => catalogError("save", cause))); + const deleteItem = (key: string) => + storage.removeItem(key).pipe(Effect.mapError((cause) => catalogError("delete", cause))); const state = yield* Ref.make>(Option.none()); const lock = yield* Semaphore.make(1); const loadLegacyCatalog = Effect.fn("mobile.connectionStorage.loadLegacyCatalog")(function* () { - const legacyRaw = yield* storage.getItem(LEGACY_CONNECTIONS_KEY); + const legacyRaw = yield* getItem(LEGACY_CONNECTIONS_KEY); const catalog = legacyRaw === null || legacyRaw.trim() === "" ? EMPTY_CONNECTION_CATALOG_DOCUMENT @@ -75,8 +74,8 @@ export const makeCatalogStore = Effect.fn("mobile.connectionStorage.makeCatalogS ); if (legacyRaw !== null && legacyRaw.trim() !== "") { const encoded = yield* encodeCatalog(catalog); - yield* storage.setItem(CONNECTION_CATALOG_KEY, encoded); - yield* storage.deleteItem(LEGACY_CONNECTIONS_KEY); + yield* setItem(CONNECTION_CATALOG_KEY, encoded); + yield* deleteItem(LEGACY_CONNECTIONS_KEY); } return catalog; }); @@ -86,13 +85,13 @@ export const makeCatalogStore = Effect.fn("mobile.connectionStorage.makeCatalogS if (Option.isSome(cached)) { return cached.value; } - const raw = yield* storage.getItem(CONNECTION_CATALOG_KEY); + const raw = yield* getItem(CONNECTION_CATALOG_KEY); let catalog: ConnectionCatalogDocumentType; if (raw !== null && raw.trim() !== "") { catalog = yield* decodeCatalog(raw).pipe( Effect.catch((error) => Effect.logWarning("Discarding corrupt mobile connection catalog", error).pipe( - Effect.andThen(storage.deleteItem(CONNECTION_CATALOG_KEY)), + Effect.andThen(deleteItem(CONNECTION_CATALOG_KEY)), Effect.andThen(loadLegacyCatalog()), ), ), @@ -111,7 +110,7 @@ export const makeCatalogStore = Effect.fn("mobile.connectionStorage.makeCatalogS Effect.gen(function* () { const next = transform(yield* loadUnlocked()); const encoded = yield* encodeCatalog(next); - yield* storage.setItem(CONNECTION_CATALOG_KEY, encoded); + yield* setItem(CONNECTION_CATALOG_KEY, encoded); yield* Ref.set(state, Option.some(next)); }), ); diff --git a/apps/mobile/src/connection/environment-cache-store.test.ts b/apps/mobile/src/connection/environment-cache-store.test.ts new file mode 100644 index 00000000000..0caf2b41592 --- /dev/null +++ b/apps/mobile/src/connection/environment-cache-store.test.ts @@ -0,0 +1,97 @@ +import { EnvironmentId, type VcsListRefsResult } from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; + +import { type ClientCacheKind, MobileDatabase } from "../persistence/mobile-database"; +import { make } from "./environment-cache-store"; + +const ENVIRONMENT_ID = EnvironmentId.make("environment-1"); +const REFS: VcsListRefsResult = { + refs: [ + { + name: "main", + current: true, + isDefault: true, + worktreePath: "/repo", + }, + ], + isRepo: true, + hasPrimaryRemote: true, + nextCursor: null, + totalCount: 1, +}; + +function cacheId(environmentId: EnvironmentId, kind: ClientCacheKind, cacheKey: string) { + return `${environmentId}:${kind}:${cacheKey}`; +} + +function makeDatabase() { + const values = new Map(); + const removed: Array = []; + const database = MobileDatabase.of({ + loadCache: (environmentId, kind, cacheKey) => + Effect.succeed(Option.fromUndefinedOr(values.get(cacheId(environmentId, kind, cacheKey)))), + saveCache: (environmentId, kind, cacheKey, _schemaVersion, payload) => + Effect.sync(() => { + values.set(cacheId(environmentId, kind, cacheKey), payload); + }), + removeCache: (environmentId, kind, cacheKey) => + Effect.sync(() => { + const id = cacheId(environmentId, kind, cacheKey); + removed.push(id); + values.delete(id); + }), + clearEnvironmentCache: (environmentId) => + Effect.sync(() => { + for (const key of values.keys()) { + if (key.startsWith(`${environmentId}:`)) values.delete(key); + } + }), + clearAllCaches: Effect.sync(() => values.clear()), + inspectCaches: Effect.succeed([]), + loadPreferencesJson: Effect.succeed(Option.none()), + savePreferencesJson: () => Effect.void, + }); + return { database, removed, values }; +} + +describe("mobile SQLite environment cache store", () => { + it.effect("round-trips schema-validated VCS refs", () => + Effect.gen(function* () { + const memory = makeDatabase(); + const store = yield* make().pipe(Effect.provideService(MobileDatabase, memory.database)); + + yield* store.saveVcsRefs(ENVIRONMENT_ID, "/repo", REFS); + + expect(yield* store.loadVcsRefs(ENVIRONMENT_ID, "/repo")).toEqual(Option.some(REFS)); + }), + ); + + it.effect("deletes a corrupt cache record and treats it as a miss", () => + Effect.gen(function* () { + const memory = makeDatabase(); + const store = yield* make().pipe(Effect.provideService(MobileDatabase, memory.database)); + const id = cacheId(ENVIRONMENT_ID, "vcs-refs", "/repo"); + memory.values.set(id, "{not-json"); + + expect(yield* store.loadVcsRefs(ENVIRONMENT_ID, "/repo")).toEqual(Option.none()); + expect(memory.removed).toEqual([id]); + }), + ); + + it.effect("clears one environment without touching another", () => + Effect.gen(function* () { + const memory = makeDatabase(); + const store = yield* make().pipe(Effect.provideService(MobileDatabase, memory.database)); + const otherEnvironmentId = EnvironmentId.make("environment-2"); + yield* store.saveVcsRefs(ENVIRONMENT_ID, "/repo", REFS); + yield* store.saveVcsRefs(otherEnvironmentId, "/repo", REFS); + + yield* store.clear(ENVIRONMENT_ID); + + expect(yield* store.loadVcsRefs(ENVIRONMENT_ID, "/repo")).toEqual(Option.none()); + expect(yield* store.loadVcsRefs(otherEnvironmentId, "/repo")).toEqual(Option.some(REFS)); + }), + ); +}); diff --git a/apps/mobile/src/connection/environment-cache-store.ts b/apps/mobile/src/connection/environment-cache-store.ts new file mode 100644 index 00000000000..a39ef5e36cb --- /dev/null +++ b/apps/mobile/src/connection/environment-cache-store.ts @@ -0,0 +1,220 @@ +import { + ConnectionPersistenceError, + EnvironmentCacheStore, + ORCHESTRATION_CACHE_SCHEMA_VERSION, + StoredOrchestrationShellSnapshot, + StoredOrchestrationThreadSnapshot, +} from "@t3tools/client-runtime/platform"; +import { type EnvironmentId, ServerConfig, VcsListRefsResult } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import * as MobileDatabase from "../persistence/mobile-database"; + +const SERVER_CONFIG_CACHE_SCHEMA_VERSION = 1; +const VCS_REFS_CACHE_SCHEMA_VERSION = 1; + +const StoredShellSnapshot = StoredOrchestrationShellSnapshot; +const StoredThreadSnapshot = StoredOrchestrationThreadSnapshot; +const StoredServerConfig = Schema.Struct({ + schemaVersion: Schema.Literal(SERVER_CONFIG_CACHE_SCHEMA_VERSION), + environmentId: Schema.String, + config: ServerConfig, +}); +const StoredVcsRefs = Schema.Struct({ + schemaVersion: Schema.Literal(VCS_REFS_CACHE_SCHEMA_VERSION), + environmentId: Schema.String, + cwd: Schema.String, + refs: VcsListRefsResult, +}); + +const decodeStoredShellSnapshot = Schema.decodeUnknownEffect( + Schema.fromJsonString(StoredShellSnapshot), +); +const encodeStoredShellSnapshot = Schema.encodeEffect(Schema.fromJsonString(StoredShellSnapshot)); +const decodeStoredThreadSnapshot = Schema.decodeUnknownEffect( + Schema.fromJsonString(StoredThreadSnapshot), +); +const encodeStoredThreadSnapshot = Schema.encodeEffect(Schema.fromJsonString(StoredThreadSnapshot)); +const decodeStoredServerConfig = Schema.decodeUnknownEffect( + Schema.fromJsonString(StoredServerConfig), +); +const encodeStoredServerConfig = Schema.encodeEffect(Schema.fromJsonString(StoredServerConfig)); +const decodeStoredVcsRefs = Schema.decodeUnknownEffect(Schema.fromJsonString(StoredVcsRefs)); +const encodeStoredVcsRefs = Schema.encodeEffect(Schema.fromJsonString(StoredVcsRefs)); + +type CacheOperation = ConnectionPersistenceError["operation"]; + +function persistenceError(operation: CacheOperation, cause: unknown) { + return new ConnectionPersistenceError({ + operation, + message: `Could not ${operation.replaceAll("-", " ")}: ${String(cause)}`, + }); +} + +function mapDatabaseError(operation: CacheOperation) { + return (error: MobileDatabase.MobileDatabaseError) => persistenceError(operation, error); +} + +function loadDecodedCache(input: { + readonly database: MobileDatabase.MobileDatabase["Service"]; + readonly environmentId: EnvironmentId; + readonly kind: MobileDatabase.ClientCacheKind; + readonly cacheKey: string; + readonly operation: CacheOperation; + readonly decode: (raw: string) => Effect.Effect; + readonly select: (value: A) => Option.Option; +}): Effect.Effect, ConnectionPersistenceError> { + return input.database.loadCache(input.environmentId, input.kind, input.cacheKey).pipe( + Effect.mapError(mapDatabaseError(input.operation)), + Effect.flatMap( + Option.match({ + onNone: () => Effect.succeed(Option.none()), + onSome: (raw) => + input.decode(raw).pipe( + Effect.map(input.select), + Effect.catch((cause) => + Effect.logWarning("Discarding corrupt mobile client cache record.", { + environmentId: input.environmentId, + kind: input.kind, + cacheKey: input.cacheKey, + cause: String(cause), + }).pipe( + Effect.andThen( + input.database + .removeCache(input.environmentId, input.kind, input.cacheKey) + .pipe(Effect.catch(() => Effect.void)), + ), + Effect.as(Option.none()), + ), + ), + ), + }), + ), + ); +} + +export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () { + const database = yield* MobileDatabase.MobileDatabase; + return EnvironmentCacheStore.of({ + loadShell: Effect.fn("MobileEnvironmentCache.loadShell")((environmentId) => + loadDecodedCache({ + database, + environmentId, + kind: "shell", + cacheKey: "snapshot", + operation: "load-shell", + decode: decodeStoredShellSnapshot, + select: (stored) => + stored.environmentId === environmentId ? Option.some(stored.snapshot) : Option.none(), + }), + ), + saveShell: Effect.fn("MobileEnvironmentCache.saveShell")(function* (environmentId, snapshot) { + const payload = yield* encodeStoredShellSnapshot({ + schemaVersion: ORCHESTRATION_CACHE_SCHEMA_VERSION, + environmentId, + snapshot, + }).pipe(Effect.mapError((cause) => persistenceError("save-shell", cause))); + yield* database + .saveCache(environmentId, "shell", "snapshot", ORCHESTRATION_CACHE_SCHEMA_VERSION, payload) + .pipe(Effect.mapError(mapDatabaseError("save-shell"))); + }), + loadThread: Effect.fn("MobileEnvironmentCache.loadThread")((environmentId, threadId) => + loadDecodedCache({ + database, + environmentId, + kind: "thread", + cacheKey: threadId, + operation: "load-thread", + decode: decodeStoredThreadSnapshot, + select: (stored) => + stored.environmentId === environmentId && stored.threadId === threadId + ? Option.some(stored.snapshot) + : Option.none(), + }), + ), + saveThread: Effect.fn("MobileEnvironmentCache.saveThread")(function* (environmentId, snapshot) { + const threadId = snapshot.projection.thread.id; + const payload = yield* encodeStoredThreadSnapshot({ + schemaVersion: ORCHESTRATION_CACHE_SCHEMA_VERSION, + environmentId, + threadId, + snapshot, + }).pipe(Effect.mapError((cause) => persistenceError("save-thread", cause))); + yield* database + .saveCache(environmentId, "thread", threadId, ORCHESTRATION_CACHE_SCHEMA_VERSION, payload) + .pipe(Effect.mapError(mapDatabaseError("save-thread"))); + }), + removeThread: Effect.fn("MobileEnvironmentCache.removeThread")((environmentId, threadId) => + database + .removeCache(environmentId, "thread", threadId) + .pipe(Effect.mapError(mapDatabaseError("remove-thread"))), + ), + loadServerConfig: Effect.fn("MobileEnvironmentCache.loadServerConfig")((environmentId) => + loadDecodedCache({ + database, + environmentId, + kind: "server-config", + cacheKey: "config", + operation: "load-server-config", + decode: decodeStoredServerConfig, + select: (stored) => + stored.environmentId === environmentId ? Option.some(stored.config) : Option.none(), + }), + ), + saveServerConfig: Effect.fn("MobileEnvironmentCache.saveServerConfig")( + function* (environmentId, config) { + const payload = yield* encodeStoredServerConfig({ + schemaVersion: SERVER_CONFIG_CACHE_SCHEMA_VERSION, + environmentId, + config, + }).pipe(Effect.mapError((cause) => persistenceError("save-server-config", cause))); + yield* database + .saveCache( + environmentId, + "server-config", + "config", + SERVER_CONFIG_CACHE_SCHEMA_VERSION, + payload, + ) + .pipe(Effect.mapError(mapDatabaseError("save-server-config"))); + }, + ), + loadVcsRefs: Effect.fn("MobileEnvironmentCache.loadVcsRefs")((environmentId, cwd) => + loadDecodedCache({ + database, + environmentId, + kind: "vcs-refs", + cacheKey: cwd, + operation: "load-vcs-refs", + decode: decodeStoredVcsRefs, + select: (stored) => + stored.environmentId === environmentId && stored.cwd === cwd + ? Option.some(stored.refs) + : Option.none(), + }), + ), + saveVcsRefs: Effect.fn("MobileEnvironmentCache.saveVcsRefs")( + function* (environmentId, cwd, refs) { + const payload = yield* encodeStoredVcsRefs({ + schemaVersion: VCS_REFS_CACHE_SCHEMA_VERSION, + environmentId, + cwd, + refs, + }).pipe(Effect.mapError((cause) => persistenceError("save-vcs-refs", cause))); + yield* database + .saveCache(environmentId, "vcs-refs", cwd, VCS_REFS_CACHE_SCHEMA_VERSION, payload) + .pipe(Effect.mapError(mapDatabaseError("save-vcs-refs"))); + }, + ), + clear: Effect.fn("MobileEnvironmentCache.clear")((environmentId) => + database + .clearEnvironmentCache(environmentId) + .pipe(Effect.mapError(mapDatabaseError("clear-environment"))), + ), + }); +}); + +export const layer = Layer.effect(EnvironmentCacheStore, make()); diff --git a/apps/mobile/src/connection/platform.ts b/apps/mobile/src/connection/platform.ts index fd3c1530392..cc51b56ee70 100644 --- a/apps/mobile/src/connection/platform.ts +++ b/apps/mobile/src/connection/platform.ts @@ -25,7 +25,8 @@ import * as Network from "expo-network"; import { AppState } from "react-native"; import { authClientMetadata } from "../lib/authClientMetadata"; -import { loadOrCreateAgentAwarenessDeviceId } from "../lib/storage"; +import * as Runtime from "../lib/runtime"; +import * as MobileStorage from "../persistence/mobile-storage"; import { appAtomRegistry } from "../state/atom-registry"; import { clearThreadOutboxEnvironment } from "../state/thread-outbox"; import { clearComposerDraftsEnvironment } from "../state/use-composer-drafts"; @@ -83,82 +84,87 @@ const wakeupsLayer = Wakeups.layer({ ), }); -const capabilitiesLayer = Layer.succeedContext( - Context.make( - CloudSession, - CloudSession.of({ - clerkToken: Effect.gen(function* () { - const session = appAtomRegistry.get(managedRelaySessionAtom); - if (session === null) { - return yield* new ConnectionBlockedError({ - reason: "authentication", - detail: "Sign in to T3 Connect to connect this environment.", - }); - } - const token = yield* session.readClerkToken().pipe( - Effect.mapError( - (error) => - new ConnectionTransientError({ - reason: "network", - detail: error.message, - }), - ), - ); - if (token === null) { - return yield* new ConnectionBlockedError({ - reason: "authentication", - detail: "The T3 Connect session is unavailable.", - }); - } - return token; - }), - }), - ).pipe( - Context.add( - PrimaryEnvironmentAuth, - PrimaryEnvironmentAuth.of({ bearerToken: Effect.succeed(Option.none()) }), - ), - Context.add( - RelayDeviceIdentity, - RelayDeviceIdentity.of({ - deviceId: Effect.tryPromise({ - try: () => loadOrCreateAgentAwarenessDeviceId(), - catch: (cause) => - new ConnectionTransientError({ - reason: "remote-unavailable", - detail: `Could not load the mobile device identity: ${String(cause)}`, - }), - }).pipe(Effect.map(Option.some)), - }), - ), - Context.add( - ClientPresentation, - ClientPresentation.of({ - metadata: authClientMetadata(), - scopes: AuthStandardClientScopes, +const capabilitiesLayer = Layer.effectContext( + Effect.gen(function* () { + const storage = yield* MobileStorage.MobileStorage; + return Context.make( + CloudSession, + CloudSession.of({ + clerkToken: Effect.gen(function* () { + const session = appAtomRegistry.get(managedRelaySessionAtom); + if (session === null) { + return yield* new ConnectionBlockedError({ + reason: "authentication", + detail: "Sign in to T3 Connect to connect this environment.", + }); + } + const token = yield* session.readClerkToken().pipe( + Effect.mapError( + (error) => + new ConnectionTransientError({ + reason: "network", + detail: error.message, + }), + ), + ); + if (token === null) { + return yield* new ConnectionBlockedError({ + reason: "authentication", + detail: "The T3 Connect session is unavailable.", + }); + } + return token; + }), }), - ), - Context.add( - SshEnvironmentGateway, - SshEnvironmentGateway.of({ - provision: () => - Effect.fail( - new ConnectionBlockedError({ - reason: "unsupported", - detail: "SSH environments are only available in the desktop app.", - }), - ), - prepare: () => - Effect.fail( - new ConnectionBlockedError({ - reason: "unsupported", - detail: "SSH environments are only available in the desktop app.", - }), + ).pipe( + Context.add( + PrimaryEnvironmentAuth, + PrimaryEnvironmentAuth.of({ bearerToken: Effect.succeed(Option.none()) }), + ), + Context.add( + RelayDeviceIdentity, + RelayDeviceIdentity.of({ + deviceId: storage.loadOrCreateAgentAwarenessDeviceId.pipe( + Effect.mapError( + (cause) => + new ConnectionTransientError({ + reason: "remote-unavailable", + detail: `Could not load the mobile device identity: ${String(cause)}`, + }), + ), + Effect.map(Option.some), ), - disconnect: () => Effect.void, - }), - ), - ), + }), + ), + Context.add( + ClientPresentation, + ClientPresentation.of({ + metadata: authClientMetadata(), + scopes: AuthStandardClientScopes, + }), + ), + Context.add( + SshEnvironmentGateway, + SshEnvironmentGateway.of({ + provision: () => + Effect.fail( + new ConnectionBlockedError({ + reason: "unsupported", + detail: "SSH environments are only available in the desktop app.", + }), + ), + prepare: () => + Effect.fail( + new ConnectionBlockedError({ + reason: "unsupported", + detail: "SSH environments are only available in the desktop app.", + }), + ), + disconnect: () => Effect.void, + }), + ), + ); + }), ); const platformConnectionSourceLayer = Layer.succeed( @@ -168,6 +174,13 @@ const platformConnectionSourceLayer = Layer.succeed( }), ); +const providedConnectionStorageLayer = connectionStorageLayer.pipe( + Layer.provide(Runtime.runtimeContextLayer), +); +const providedCapabilitiesLayer = capabilitiesLayer.pipe( + Layer.provide(Runtime.runtimeContextLayer), +); + const environmentOwnedDataCleanupLayer = Layer.succeed( EnvironmentOwnedDataCleanup, EnvironmentOwnedDataCleanup.of({ @@ -190,10 +203,11 @@ const environmentOwnedDataCleanupLayer = Layer.succeed( ); type ConnectionPlatformLayerSource = - | typeof connectionStorageLayer + | typeof providedConnectionStorageLayer + | typeof Runtime.runtimeContextLayer | typeof connectivityLayer | typeof wakeupsLayer - | typeof capabilitiesLayer + | typeof providedCapabilitiesLayer | typeof platformConnectionSourceLayer | typeof environmentOwnedDataCleanupLayer; @@ -202,10 +216,11 @@ export const connectionPlatformLayer: Layer.Layer< Layer.Error, Layer.Services > = Layer.mergeAll( - connectionStorageLayer, + providedConnectionStorageLayer, + Runtime.runtimeContextLayer, connectivityLayer, wakeupsLayer, - capabilitiesLayer, + providedCapabilitiesLayer, platformConnectionSourceLayer, environmentOwnedDataCleanupLayer, ); diff --git a/apps/mobile/src/connection/storage.test.ts b/apps/mobile/src/connection/storage.test.ts index 031c152e659..34ba6ed850a 100644 --- a/apps/mobile/src/connection/storage.test.ts +++ b/apps/mobile/src/connection/storage.test.ts @@ -1,28 +1,35 @@ import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import { vi } from "vite-plus/test"; -import { - CONNECTION_CATALOG_KEY, - LEGACY_CONNECTIONS_KEY, - makeCatalogStore, - type SecureCatalogStorage, -} from "./catalog-store"; +vi.mock("react-native", () => ({ + Platform: { OS: "ios" }, +})); + +vi.mock("expo-secure-store", () => ({ + deleteItemAsync: vi.fn(), + getItemAsync: vi.fn(), + setItemAsync: vi.fn(), +})); + +import { CONNECTION_CATALOG_KEY, LEGACY_CONNECTIONS_KEY, make } from "./catalog-store"; +import { MobileSecureStorage } from "../persistence/mobile-secure-storage"; function makeStorage(initial: Readonly>) { const values = new Map(Object.entries(initial)); const deleted: Array = []; - const storage: SecureCatalogStorage = { + const storage = MobileSecureStorage.of({ getItem: (key) => Effect.sync(() => values.get(key) ?? null), setItem: (key, value) => Effect.sync(() => { values.set(key, value); }), - deleteItem: (key) => + removeItem: (key) => Effect.sync(() => { deleted.push(key); values.delete(key); }), - }; + }); return { deleted, storage, values }; } @@ -32,7 +39,9 @@ describe("mobile connection catalog storage", () => { const memory = makeStorage({ [CONNECTION_CATALOG_KEY]: "{not-json", }); - const catalog = yield* makeCatalogStore(memory.storage); + const catalog = yield* make().pipe( + Effect.provideService(MobileSecureStorage, memory.storage), + ); expect((yield* catalog.read).targets).toEqual([]); expect(memory.deleted).toEqual([CONNECTION_CATALOG_KEY]); @@ -44,7 +53,9 @@ describe("mobile connection catalog storage", () => { const memory = makeStorage({ [LEGACY_CONNECTIONS_KEY]: JSON.stringify({ connections: [{ invalid: true }] }), }); - const catalog = yield* makeCatalogStore(memory.storage); + const catalog = yield* make().pipe( + Effect.provideService(MobileSecureStorage, memory.storage), + ); expect((yield* catalog.read).targets).toEqual([]); expect(memory.deleted).toEqual([LEGACY_CONNECTIONS_KEY]); @@ -71,7 +82,9 @@ describe("mobile connection catalog storage", () => { ], }), }); - const catalog = yield* makeCatalogStore(memory.storage); + const catalog = yield* make().pipe( + Effect.provideService(MobileSecureStorage, memory.storage), + ); expect((yield* catalog.read).targets).toHaveLength(1); expect(memory.deleted).toEqual([CONNECTION_CATALOG_KEY, LEGACY_CONNECTIONS_KEY]); diff --git a/apps/mobile/src/connection/storage.ts b/apps/mobile/src/connection/storage.ts index 13882a96580..e844181673d 100644 --- a/apps/mobile/src/connection/storage.ts +++ b/apps/mobile/src/connection/storage.ts @@ -2,11 +2,6 @@ import { ConnectionPersistenceError, ConnectionRegistrationStore, ConnectionTargetStore, - EnvironmentCacheStore, - ORCHESTRATION_CACHE_SCHEMA_VERSION, - StoredOrchestrationShellSnapshot, - StoredOrchestrationThreadSnapshot, - decodeOrDiscardOrchestrationCache, registerConnectionInCatalog, removeConnectionFromCatalog, removeCatalogValue, @@ -18,88 +13,11 @@ import { CredentialStore, ProfileStore, } from "@t3tools/client-runtime/connection"; -import { EnvironmentId, ThreadId } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; -import * as Schema from "effect/Schema"; -import * as SecureStore from "expo-secure-store"; - -import { makeCatalogStore, type SecureCatalogStorage } from "./catalog-store"; - -const SHELL_SNAPSHOT_CACHE_DIRECTORY = "connection-shell-snapshots"; -const LEGACY_SHELL_SNAPSHOT_CACHE_DIRECTORY = "shell-snapshots"; -const THREAD_SNAPSHOT_CACHE_DIRECTORY = "connection-thread-snapshots"; - -const StoredShellSnapshot = StoredOrchestrationShellSnapshot; -const StoredThreadSnapshot = StoredOrchestrationThreadSnapshot; -const decodeStoredShellSnapshot = Schema.decodeUnknownResult(StoredShellSnapshot); -const encodeStoredShellSnapshot = Schema.encodeUnknownResult(StoredShellSnapshot); -const decodeStoredThreadSnapshot = Schema.decodeUnknownResult(StoredThreadSnapshot); -const encodeStoredThreadSnapshot = Schema.encodeUnknownResult(StoredThreadSnapshot); - -function catalogError(operation: string, cause: unknown) { - return new ConnectionTransientError({ - reason: "remote-unavailable", - detail: `Could not ${operation} the local connection catalog: ${String(cause)}`, - }); -} - -function shellPersistenceError( - operation: - | "load-shell" - | "save-shell" - | "load-thread" - | "save-thread" - | "remove-thread" - | "clear-environment", - cause: unknown, -) { - return new ConnectionPersistenceError({ - operation, - message: `Could not ${operation.replaceAll("-", " ")}: ${String(cause)}`, - }); -} - -function threadSnapshotFileName(threadId: ThreadId): string { - return `${encodeURIComponent(threadId)}.json`; -} - -const threadSnapshotDirectory = Effect.fn("mobile.connectionStorage.threadSnapshotDirectory")( - function* ( - environmentId: EnvironmentId, - operation: "load-thread" | "save-thread" | "remove-thread" | "clear-environment", - ) { - return yield* Effect.tryPromise({ - try: async () => { - const { Directory, Paths } = await import("expo-file-system"); - const directory = new Directory( - Paths.document, - THREAD_SNAPSHOT_CACHE_DIRECTORY, - encodeURIComponent(environmentId), - ); - if (operation !== "clear-environment") { - directory.create({ idempotent: true, intermediates: true }); - } - return directory; - }, - catch: (cause) => shellPersistenceError(operation, cause), - }); - }, -); - -const threadSnapshotFile = Effect.fn("mobile.connectionStorage.threadSnapshotFile")(function* ( - environmentId: EnvironmentId, - threadId: ThreadId, - operation: "load-thread" | "save-thread" | "remove-thread", -) { - const { File } = yield* Effect.promise(() => import("expo-file-system")); - return new File( - yield* threadSnapshotDirectory(environmentId, operation), - threadSnapshotFileName(threadId), - ); -}); +import * as CatalogStore from "./catalog-store"; function targetPersistenceError( operation: "list-targets" | "register-connection" | "remove-connection", @@ -111,59 +29,9 @@ function targetPersistenceError( }); } -const secureCatalogStorage: SecureCatalogStorage = { - getItem: (key) => - Effect.tryPromise({ - try: () => SecureStore.getItemAsync(key), - catch: (cause) => catalogError("load", cause), - }), - setItem: (key, value) => - Effect.tryPromise({ - try: () => SecureStore.setItemAsync(key, value), - catch: (cause) => catalogError("save", cause), - }), - deleteItem: (key) => - Effect.tryPromise({ - try: () => SecureStore.deleteItemAsync(key), - catch: (cause) => catalogError("delete", cause), - }), -}; - -function shellSnapshotFileName(environmentId: EnvironmentId): string { - return `${encodeURIComponent(environmentId)}.json`; -} - -const shellSnapshotFileInDirectory = Effect.fn( - "mobile.connectionStorage.shellSnapshotFileInDirectory", -)(function* ( - environmentId: EnvironmentId, - operation: "load-shell" | "save-shell" | "clear-environment", - directoryName: string, -) { - return yield* Effect.tryPromise({ - try: async () => { - const { Directory, File, Paths } = await import("expo-file-system"); - const directory = new Directory(Paths.document, directoryName); - directory.create({ idempotent: true, intermediates: true }); - return new File(directory, shellSnapshotFileName(environmentId)); - }, - catch: (cause) => shellPersistenceError(operation, cause), - }); -}); - -const shellSnapshotFile = ( - environmentId: EnvironmentId, - operation: "load-shell" | "save-shell" | "clear-environment", -) => shellSnapshotFileInDirectory(environmentId, operation, SHELL_SNAPSHOT_CACHE_DIRECTORY); - -const legacyShellSnapshotFile = ( - environmentId: EnvironmentId, - operation: "load-shell" | "clear-environment", -) => shellSnapshotFileInDirectory(environmentId, operation, LEGACY_SHELL_SNAPSHOT_CACHE_DIRECTORY); - export const connectionStorageLayer = Layer.effectContext( Effect.gen(function* () { - const catalog = yield* makeCatalogStore(secureCatalogStorage); + const catalog = yield* CatalogStore.make(); const targetStore = ConnectionTargetStore.of({ list: catalog.read.pipe( @@ -260,182 +128,11 @@ export const connectionStorageLayer = Layer.effectContext( ), })), }); - const cacheStore = EnvironmentCacheStore.of({ - loadShell: (environmentId) => - Effect.gen(function* () { - const file = yield* shellSnapshotFile(environmentId, "load-shell"); - if (file.exists) { - const raw = yield* Effect.tryPromise({ - try: () => file.text(), - catch: (cause) => shellPersistenceError("load-shell", cause), - }); - const parsed = yield* Effect.try({ - try: () => JSON.parse(raw) as unknown, - catch: (cause) => shellPersistenceError("load-shell", cause), - }); - const stored = yield* Effect.fromResult(decodeStoredShellSnapshot(parsed)).pipe( - Effect.mapError((cause) => shellPersistenceError("load-shell", cause)), - ); - return stored.environmentId === environmentId - ? Option.some(stored.snapshot) - : Option.none(); - } - - const legacyFile = yield* legacyShellSnapshotFile(environmentId, "load-shell"); - if (legacyFile.exists) { - yield* Effect.try({ - try: () => legacyFile.delete(), - catch: (cause) => shellPersistenceError("load-shell", cause), - }); - } - - return Option.none(); - }).pipe((decode) => - decodeOrDiscardOrchestrationCache( - decode, - shellSnapshotFile(environmentId, "load-shell").pipe( - Effect.flatMap((file) => - Effect.try({ - try: () => { - if (file.exists) file.delete(); - }, - catch: (cause) => shellPersistenceError("load-shell", cause), - }), - ), - Effect.catch(() => Effect.void), - ), - ), - ), - saveShell: (environmentId, snapshot) => - Effect.gen(function* () { - const file = yield* shellSnapshotFile(environmentId, "save-shell"); - const stored = { - schemaVersion: ORCHESTRATION_CACHE_SCHEMA_VERSION, - environmentId, - snapshot, - } as const; - const encoded = yield* Effect.fromResult(encodeStoredShellSnapshot(stored)).pipe( - Effect.mapError((cause) => shellPersistenceError("save-shell", cause)), - ); - yield* Effect.try({ - try: () => { - if (!file.exists) { - file.create({ intermediates: true, overwrite: true }); - } - file.write(JSON.stringify(encoded)); - }, - catch: (cause) => shellPersistenceError("save-shell", cause), - }); - }), - loadThread: (environmentId, threadId) => - Effect.gen(function* () { - const file = yield* threadSnapshotFile(environmentId, threadId, "load-thread"); - if (!file.exists) { - return Option.none(); - } - const raw = yield* Effect.tryPromise({ - try: () => file.text(), - catch: (cause) => shellPersistenceError("load-thread", cause), - }); - const parsed = yield* Effect.try({ - try: () => JSON.parse(raw) as unknown, - catch: (cause) => shellPersistenceError("load-thread", cause), - }); - const stored = yield* Effect.fromResult(decodeStoredThreadSnapshot(parsed)).pipe( - Effect.mapError((cause) => shellPersistenceError("load-thread", cause)), - ); - return stored.environmentId === environmentId && stored.threadId === threadId - ? Option.some(stored.snapshot) - : Option.none(); - }).pipe((decode) => - decodeOrDiscardOrchestrationCache( - decode, - threadSnapshotFile(environmentId, threadId, "load-thread").pipe( - Effect.flatMap((file) => - Effect.try({ - try: () => { - if (file.exists) file.delete(); - }, - catch: (cause) => shellPersistenceError("load-thread", cause), - }), - ), - Effect.catch(() => Effect.void), - ), - ), - ), - saveThread: (environmentId, snapshot) => - Effect.gen(function* () { - const file = yield* threadSnapshotFile( - environmentId, - snapshot.projection.thread.id, - "save-thread", - ); - const encoded = yield* Effect.fromResult( - encodeStoredThreadSnapshot({ - schemaVersion: ORCHESTRATION_CACHE_SCHEMA_VERSION, - environmentId, - threadId: snapshot.projection.thread.id, - snapshot, - }), - ).pipe(Effect.mapError((cause) => shellPersistenceError("save-thread", cause))); - yield* Effect.try({ - try: () => { - if (!file.exists) { - file.create({ intermediates: true, overwrite: true }); - } - file.write(JSON.stringify(encoded)); - }, - catch: (cause) => shellPersistenceError("save-thread", cause), - }); - }), - removeThread: (environmentId, threadId) => - Effect.gen(function* () { - const file = yield* threadSnapshotFile(environmentId, threadId, "remove-thread"); - if (file.exists) { - file.delete(); - } - }).pipe( - Effect.mapError((cause) => - cause._tag === "ConnectionPersistenceError" - ? cause - : shellPersistenceError("remove-thread", cause), - ), - ), - clear: (environmentId) => - Effect.gen(function* () { - const file = yield* shellSnapshotFile(environmentId, "clear-environment"); - if (file.exists) { - yield* Effect.try({ - try: () => file.delete(), - catch: (cause) => shellPersistenceError("clear-environment", cause), - }); - } - const legacyFile = yield* legacyShellSnapshotFile(environmentId, "clear-environment"); - if (legacyFile.exists) { - yield* Effect.try({ - try: () => legacyFile.delete(), - catch: (cause) => shellPersistenceError("clear-environment", cause), - }); - } - const threadDirectory = yield* threadSnapshotDirectory( - environmentId, - "clear-environment", - ); - if (threadDirectory.exists) { - yield* Effect.try({ - try: () => threadDirectory.delete(), - catch: (cause) => shellPersistenceError("clear-environment", cause), - }); - } - }), - }); - return Context.make(ConnectionTargetStore, targetStore).pipe( Context.add(ConnectionRegistrationStore, registrationStore), Context.add(ProfileStore.ConnectionProfileStore, profileStore), Context.add(CredentialStore.ConnectionCredentialStore, credentialStore), Context.add(TokenStore.RemoteDpopAccessTokenStore, remoteTokenStore), - Context.add(EnvironmentCacheStore, cacheStore), ); }), ); diff --git a/apps/mobile/src/features/agent-awareness/capabilities.ts b/apps/mobile/src/features/agent-awareness/capabilities.ts new file mode 100644 index 00000000000..d627f365754 --- /dev/null +++ b/apps/mobile/src/features/agent-awareness/capabilities.ts @@ -0,0 +1,5 @@ +import Constants from "expo-constants"; + +export function supportsAgentAwarenessPush() { + return Constants.expoConfig?.extra?.iosPersonalTeamBuild !== true; +} diff --git a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts index 5de14ea76fc..0ee3e59b982 100644 --- a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts +++ b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts @@ -7,21 +7,30 @@ import * as Layer from "effect/Layer"; import { HttpClient } from "effect/unstable/http"; import type { SavedRemoteConnection } from "../../lib/connection"; -import { savePreferencesPatch } from "../../lib/storage"; -import { linkEnvironmentToCloud } from "../cloud/linkEnvironment"; +import { MobileStorage } from "../../persistence/mobile-storage"; +import { + CloudEnvironmentLinkError, + linkEnvironmentToCloudWithPreference, +} from "../cloud/linkEnvironment"; import { setLiveActivityUpdatesEnabled } from "./liveActivityPreferences"; -import { refreshAgentAwarenessRegistration } from "./remoteRegistration"; +import { updateAgentAwarenessRegistrationPreferences } from "./remoteRegistration"; -vi.mock("../../lib/storage", () => ({ - savePreferencesPatch: vi.fn(() => Promise.resolve()), +vi.mock("expo-secure-store", () => ({ + deleteItemAsync: vi.fn(), + getItemAsync: vi.fn(), + setItemAsync: vi.fn(), +})); + +vi.mock("react-native", () => ({ + Platform: { OS: "ios" }, })); vi.mock("../cloud/linkEnvironment", () => ({ - linkEnvironmentToCloud: vi.fn(() => Effect.void), + linkEnvironmentToCloudWithPreference: vi.fn(() => Effect.void), })); vi.mock("./remoteRegistration", () => ({ - refreshAgentAwarenessRegistration: vi.fn(() => Effect.void), + updateAgentAwarenessRegistrationPreferences: vi.fn(() => Effect.void), })); const connection: SavedRemoteConnection = { @@ -40,6 +49,21 @@ const testLayer = Layer.mergeAll( HttpClient.HttpClient, HttpClient.make(() => Effect.die("unexpected HTTP request")), ), + Layer.succeed( + MobileStorage, + MobileStorage.of({ + loadSavedConnections: Effect.succeed([]), + saveConnection: () => Effect.void, + clearSavedConnection: () => Effect.void, + loadOrCreateAgentAwarenessDeviceId: Effect.succeed("device-1"), + loadAgentAwarenessDeviceId: Effect.succeed("device-1"), + loadAgentAwarenessRegistrationRecord: Effect.succeed(null), + saveAgentAwarenessRegistrationRecord: () => Effect.void, + clearAgentAwarenessRegistrationRecord: Effect.void, + loadRecentThreadShortcuts: Effect.succeed([]), + saveRecentThreadShortcuts: () => Effect.void, + }), + ), ); describe("liveActivityPreferences", () => { @@ -51,15 +75,18 @@ describe("liveActivityPreferences", () => { Effect.gen(function* () { yield* setLiveActivityUpdatesEnabled({ enabled: false, + previousEnabled: true, clerkToken: "clerk-token", connections: [connection], }); - expect(savePreferencesPatch).toHaveBeenCalledWith({ liveActivitiesEnabled: false }); - expect(refreshAgentAwarenessRegistration).toHaveBeenCalledTimes(1); - expect(linkEnvironmentToCloud).toHaveBeenCalledWith({ + expect(updateAgentAwarenessRegistrationPreferences).toHaveBeenCalledWith({ + liveActivitiesEnabled: false, + }); + expect(linkEnvironmentToCloudWithPreference).toHaveBeenCalledWith({ clerkToken: "clerk-token", connection, + liveActivitiesEnabled: false, }); }).pipe(Effect.provide(testLayer)), ); @@ -68,15 +95,18 @@ describe("liveActivityPreferences", () => { Effect.gen(function* () { yield* setLiveActivityUpdatesEnabled({ enabled: true, + previousEnabled: false, clerkToken: "clerk-token", connections: [connection], }); - expect(savePreferencesPatch).toHaveBeenCalledWith({ liveActivitiesEnabled: true }); - expect(refreshAgentAwarenessRegistration).toHaveBeenCalledTimes(1); - expect(linkEnvironmentToCloud).toHaveBeenCalledWith({ + expect(updateAgentAwarenessRegistrationPreferences).toHaveBeenCalledWith({ + liveActivitiesEnabled: true, + }); + expect(linkEnvironmentToCloudWithPreference).toHaveBeenCalledWith({ clerkToken: "clerk-token", connection, + liveActivitiesEnabled: true, }); }).pipe(Effect.provide(testLayer)), ); @@ -85,13 +115,15 @@ describe("liveActivityPreferences", () => { Effect.gen(function* () { yield* setLiveActivityUpdatesEnabled({ enabled: false, + previousEnabled: true, clerkToken: null, connections: [connection], }); - expect(savePreferencesPatch).toHaveBeenCalledWith({ liveActivitiesEnabled: false }); - expect(refreshAgentAwarenessRegistration).toHaveBeenCalledTimes(1); - expect(linkEnvironmentToCloud).not.toHaveBeenCalled(); + expect(updateAgentAwarenessRegistrationPreferences).toHaveBeenCalledWith({ + liveActivitiesEnabled: false, + }); + expect(linkEnvironmentToCloudWithPreference).not.toHaveBeenCalled(); }).pipe(Effect.provide(testLayer)), ); @@ -104,14 +136,51 @@ describe("liveActivityPreferences", () => { return Effect.gen(function* () { yield* setLiveActivityUpdatesEnabled({ enabled: true, + previousEnabled: false, clerkToken: "clerk-token", connections: [connection, managedConnection], }); - expect(linkEnvironmentToCloud).toHaveBeenCalledTimes(1); - expect(linkEnvironmentToCloud).toHaveBeenCalledWith({ + expect(linkEnvironmentToCloudWithPreference).toHaveBeenCalledTimes(1); + expect(linkEnvironmentToCloudWithPreference).toHaveBeenCalledWith({ + clerkToken: "clerk-token", + connection, + liveActivitiesEnabled: true, + }); + }).pipe(Effect.provide(testLayer)); + }); + + it.effect("restores relay preferences when an environment update fails", () => { + vi.mocked(linkEnvironmentToCloudWithPreference).mockImplementationOnce(() => + Effect.fail(new CloudEnvironmentLinkError({ message: "environment update failed" })), + ); + + return Effect.gen(function* () { + const exit = yield* Effect.exit( + setLiveActivityUpdatesEnabled({ + enabled: false, + previousEnabled: true, + clerkToken: "clerk-token", + connections: [connection], + }), + ); + + expect(exit._tag).toBe("Failure"); + expect(updateAgentAwarenessRegistrationPreferences).toHaveBeenNthCalledWith(1, { + liveActivitiesEnabled: false, + }); + expect(updateAgentAwarenessRegistrationPreferences).toHaveBeenNthCalledWith(2, { + liveActivitiesEnabled: true, + }); + expect(linkEnvironmentToCloudWithPreference).toHaveBeenNthCalledWith(1, { + clerkToken: "clerk-token", + connection, + liveActivitiesEnabled: false, + }); + expect(linkEnvironmentToCloudWithPreference).toHaveBeenNthCalledWith(2, { clerkToken: "clerk-token", connection, + liveActivitiesEnabled: true, }); }).pipe(Effect.provide(testLayer)); }); diff --git a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts index 932376e8bce..1b30769e7bc 100644 --- a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts +++ b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts @@ -1,47 +1,73 @@ import * as Effect from "effect/Effect"; -import * as Schema from "effect/Schema"; -import { HttpClient } from "effect/unstable/http"; -import { ManagedRelay } from "@t3tools/client-runtime/relay"; import type { SavedRemoteConnection } from "../../lib/connection"; -import { savePreferencesPatch } from "../../lib/storage"; -import { linkEnvironmentToCloud } from "../cloud/linkEnvironment"; -import { refreshAgentAwarenessRegistration } from "./remoteRegistration"; - -export class LiveActivityPreferenceSaveError extends Schema.TaggedErrorClass()( - "LiveActivityPreferenceSaveError", - { - enabled: Schema.Boolean, - cause: Schema.Defect(), - }, -) { - override get message(): string { - return `Failed to save the Live Activity updates setting (enabled: ${this.enabled}).`; - } -} - -export function setLiveActivityUpdatesEnabled(input: { - readonly enabled: boolean; - readonly clerkToken: string | null; - readonly connections: ReadonlyArray; -}): Effect.Effect { - return Effect.gen(function* () { - yield* Effect.tryPromise({ - try: () => savePreferencesPatch({ liveActivitiesEnabled: input.enabled }), - catch: (cause) => new LiveActivityPreferenceSaveError({ enabled: input.enabled, cause }), +import { linkEnvironmentToCloudWithPreference } from "../cloud/linkEnvironment"; +import { updateAgentAwarenessRegistrationPreferences } from "./remoteRegistration"; + +export const setLiveActivityUpdatesEnabled = Effect.fn("setLiveActivityUpdatesEnabled")( + function* (input: { + readonly enabled: boolean; + readonly previousEnabled: boolean; + readonly clerkToken: string | null; + readonly connections: ReadonlyArray; + }) { + const linkedConnections = input.connections.filter( + (connection) => connection.bearerToken !== null, + ); + + const updateRelayPreference = Effect.fn("updateRelayPreference")(function* (enabled: boolean) { + yield* updateAgentAwarenessRegistrationPreferences({ + liveActivitiesEnabled: enabled, + }); + + const clerkToken = input.clerkToken; + if (!clerkToken) return; + + yield* Effect.forEach( + linkedConnections, + (connection) => + linkEnvironmentToCloudWithPreference({ + clerkToken, + connection, + liveActivitiesEnabled: enabled, + }), + { concurrency: "unbounded" }, + ); }); - yield* refreshAgentAwarenessRegistration(); + const restoreRelayPreference = Effect.fn("restoreRelayPreference")(function* () { + yield* updateAgentAwarenessRegistrationPreferences({ + liveActivitiesEnabled: input.previousEnabled, + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("Could not restore Live Activity device preference.", cause), + ), + ); + + const clerkToken = input.clerkToken; + if (!clerkToken) return; - const clerkToken = input.clerkToken; - if (!clerkToken) { - return; - } + yield* Effect.forEach( + linkedConnections, + (connection) => + linkEnvironmentToCloudWithPreference({ + clerkToken, + connection, + liveActivitiesEnabled: input.previousEnabled, + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning( + `Could not restore Live Activity preference for environment ${connection.environmentId}.`, + cause, + ), + ), + ), + { concurrency: "unbounded" }, + ); + }); - yield* Effect.forEach( - input.connections.filter((connection) => connection.bearerToken !== null), - (connection) => linkEnvironmentToCloud({ clerkToken, connection }), - { concurrency: "unbounded" }, + yield* updateRelayPreference(input.enabled).pipe( + Effect.onError(() => restoreRelayPreference()), ); - }); -} + }, +); diff --git a/apps/mobile/src/features/agent-awareness/registrationPayload.ts b/apps/mobile/src/features/agent-awareness/registrationPayload.ts index cd2e36a403c..8279a27fee6 100644 --- a/apps/mobile/src/features/agent-awareness/registrationPayload.ts +++ b/apps/mobile/src/features/agent-awareness/registrationPayload.ts @@ -1,6 +1,7 @@ import type { RelayDeviceRegistrationRequest } from "@t3tools/contracts/relay"; -import type { Preferences } from "../../lib/storage"; +import type { Preferences } from "../../persistence/mobile-preferences"; +import { supportsAgentAwarenessPush } from "./capabilities"; // Development builds are Xcode-signed and receive sandbox APNs tokens; // preview and production builds are distribution-signed and use production @@ -21,7 +22,8 @@ export function makeRelayDeviceRegistrationRequest(input: { readonly notificationsEnabled: boolean; readonly preferences: Preferences; }): RelayDeviceRegistrationRequest { - const liveActivitiesEnabled = input.preferences.liveActivitiesEnabled !== false; + const pushAvailable = supportsAgentAwarenessPush(); + const liveActivitiesEnabled = pushAvailable && input.preferences.liveActivitiesEnabled !== false; return { deviceId: input.deviceId, label: input.label, @@ -34,7 +36,7 @@ export function makeRelayDeviceRegistrationRequest(input: { ...(input.pushToStartToken ? { pushToStartToken: input.pushToStartToken } : {}), preferences: { liveActivitiesEnabled, - notificationsEnabled: input.notificationsEnabled, + notificationsEnabled: pushAvailable && input.notificationsEnabled, notifyOnApproval: true, notifyOnInput: true, notifyOnCompletion: true, diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index e296825c7ee..b1a48a35a0a 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -21,12 +21,13 @@ import { loadAgentAwarenessRegistrationRecord, loadOrCreateAgentAwarenessDeviceId, saveAgentAwarenessRegistrationRecord, -} from "../../lib/storage"; +} from "../../persistence/imperative"; import { makeRelayDeviceRegistrationRequest, resolveApsEnvironment } from "./registrationPayload"; import { AgentAwarenessOperationError, __resetAgentAwarenessRemoteRegistrationForTest, getAgentAwarenessRegistrationStatus, + mergeAgentAwarenessRegistrationPreferences, refreshActiveLiveActivityRemoteRegistration, refreshAgentAwarenessRegistration, normalizeAgentAwarenessRelayBaseUrl, @@ -142,7 +143,7 @@ vi.mock("../../lib/runtime", () => ({ }, })); -vi.mock("../../lib/storage", () => ({ +vi.mock("../../persistence/imperative", () => ({ loadAgentAwarenessDeviceId: vi.fn(() => Promise.resolve("device-1")), loadOrCreateAgentAwarenessDeviceId: vi.fn(() => Promise.resolve("device-1")), loadPreferences: vi.fn(() => Promise.resolve({ liveActivitiesEnabled: false })), @@ -286,6 +287,26 @@ describe("makeRelayDeviceRegistrationRequest", () => { expect(resolveApsEnvironment(undefined)).toBe("production"); }); + it("disables push features in Personal Team relay registrations", () => { + Constants.expoConfig!.extra = { iosPersonalTeamBuild: true }; + + expect( + makeRelayDeviceRegistrationRequest({ + deviceId: "device-1", + label: "Julius's iPhone", + iosMajorVersion: 18, + appVersion: "1.0.0", + pushToken: "apns-token", + pushToStartToken: "push-to-start-token", + notificationsEnabled: true, + preferences: {}, + }).preferences, + ).toMatchObject({ + liveActivitiesEnabled: false, + notificationsEnabled: false, + }); + }); + it("marks notification delivery disabled when APNs permission is unavailable", () => { expect( makeRelayDeviceRegistrationRequest({ @@ -324,6 +345,15 @@ describe("makeRelayDeviceRegistrationRequest", () => { expect(normalizeAgentAwarenessRelayBaseUrl(" ")).toBeNull(); }); + it("overrides persisted preferences for an in-flight registration", () => { + expect( + mergeAgentAwarenessRegistrationPreferences( + { liveActivitiesEnabled: false, baseFontSize: 18 }, + { liveActivitiesEnabled: true }, + ), + ).toEqual({ liveActivitiesEnabled: true, baseFontSize: 18 }); + }); + it.effect("registers at most one listener while a Live Activity push token is pending", () => { registerAgentAwarenessConnection(savedConnection()); const addPushTokenListener = vi.fn(); diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts index e9de93c1dcb..449f90886cf 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -20,6 +20,7 @@ import { import type { SavedRemoteConnection } from "../../lib/connection"; import { runtime } from "../../lib/runtime"; +import type { Preferences } from "../../persistence/mobile-preferences"; import { clearAgentAwarenessRegistrationRecord, loadAgentAwarenessDeviceId, @@ -27,9 +28,10 @@ import { loadOrCreateAgentAwarenessDeviceId, loadPreferences, saveAgentAwarenessRegistrationRecord, -} from "../../lib/storage"; +} from "../../persistence/imperative"; import AgentActivity, { type AgentActivityProps } from "../../widgets/AgentActivity"; import { resolveCloudPublicConfig } from "../cloud/publicConfig"; +import { supportsAgentAwarenessPush } from "./capabilities"; import { makeRelayDeviceRegistrationRequest, resolveApsEnvironment } from "./registrationPayload"; const REMOTE_ACTIVITY_REGISTRATION_RETRY_MS = 15_000; @@ -124,6 +126,17 @@ interface DeviceRegistrationInput { readonly observedPushToken?: string; } +interface RegisterDeviceInput extends DeviceRegistrationInput { + readonly preferencesOverride?: Partial; +} + +export function mergeAgentAwarenessRegistrationPreferences( + stored: Preferences, + override: Partial | undefined, +): Preferences { + return { ...stored, ...override }; +} + export function normalizeAgentAwarenessRelayBaseUrl( value: string | null | undefined, ): string | null { @@ -237,7 +250,7 @@ function iosMajorVersion(): number { function nativePushTokenRegistration(observedPushToken?: string) { return Effect.gen(function* () { - if (!canRegisterRemoteLiveActivities()) { + if (!canRegisterRemoteLiveActivities() || !supportsAgentAwarenessPush()) { return { notificationsEnabled: false, pushToken: null }; } if (observedPushToken) { @@ -655,7 +668,7 @@ function enqueueDeviceRegistration(input: DeviceRegistrationInput, context: stri } function registerDevice( - input: DeviceRegistrationInput = {}, + input: RegisterDeviceInput = {}, expectedGeneration = deviceRegistrationGeneration, ): Effect.Effect { return Effect.gen(function* () { @@ -665,7 +678,7 @@ function registerDevice( } logRegistrationDebug("device registration loading local state", { expectedGeneration }); - const [deviceId, preferences] = yield* Effect.all([ + const [deviceId, storedPreferences] = yield* Effect.all([ Effect.tryPromise({ try: () => loadOrCreateAgentAwarenessDeviceId(), catch: (cause) => @@ -683,6 +696,10 @@ function registerDevice( }), }), ]); + const preferences = mergeAgentAwarenessRegistrationPreferences( + storedPreferences, + input.preferencesOverride, + ); const pushTokenRegistration = yield* nativePushTokenRegistration(input?.observedPushToken); logRegistrationDebug("device registration local state ready", { expectedGeneration, @@ -820,6 +837,21 @@ export function refreshAgentAwarenessRegistration(): Effect.Effect< ); } +export function updateAgentAwarenessRegistrationPreferences( + preferencesOverride: Partial, +): Effect.Effect { + return registerDevice({ preferencesOverride }).pipe( + Effect.tapError((error) => + Effect.sync(() => { + if (registrationStatus !== "registered") { + setRegistrationStatus("failed"); + } + logRegistrationError("device preference registration refresh failed", error); + }), + ), + ); +} + export function __resetAgentAwarenessRemoteRegistrationForTest(): void { environmentConnections.clear(); pushTokenSubscription?.remove(); diff --git a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx index 6ef0819abd7..916802e9faf 100644 --- a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx +++ b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx @@ -6,9 +6,11 @@ import { LegendList } from "@legendapp/list/react-native"; import type { EnvironmentId } from "@t3tools/contracts"; import type { MenuAction } from "@react-native-menu/menu"; import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; -import { SymbolView } from "expo-symbols"; +import { SymbolView } from "../../components/AppSymbol"; +import { useNavigation } from "@react-navigation/native"; import { useCallback, useMemo, useRef, type ComponentProps } from "react"; import { + TextInput, ActivityIndicator, Platform, Pressable, @@ -23,6 +25,7 @@ import { AppText as Text } from "../../components/AppText"; import { ControlPillMenu } from "../../components/ControlPill"; import { EmptyState } from "../../components/EmptyState"; import { ProjectFavicon } from "../../components/ProjectFavicon"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; import { relativeTime } from "../../lib/time"; import { useThemeColor } from "../../lib/useThemeColor"; import { ThreadSwipeable } from "../home/thread-swipe-actions"; @@ -34,20 +37,6 @@ export interface ArchivedThreadsHeaderEnvironment { readonly label: string; } -const THREAD_ACTIONS: MenuAction[] = [ - { - id: "unarchive", - title: "Unarchive", - image: "arrow.uturn.backward", - }, - { - id: "delete", - title: "Delete", - image: "trash", - attributes: { destructive: true }, - }, -]; - type ArchivedThreadListItem = | { readonly kind: "project"; @@ -66,6 +55,7 @@ type ArchivedThreadListItem = function ArchivedThreadsHeader(props: { readonly environments: ReadonlyArray; + readonly searchQuery: string; readonly selectedEnvironmentId: EnvironmentId | null; readonly sortOrder: ArchivedThreadSortOrder; readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void; @@ -74,9 +64,140 @@ function ArchivedThreadsHeader(props: { readonly onSortOrderChange: (sortOrder: ArchivedThreadSortOrder) => void; }) { const { width } = useWindowDimensions(); + const navigation = useNavigation(); + const insets = useSafeAreaInsets(); const hasCustomFilter = props.selectedEnvironmentId !== null || props.sortOrder !== "newest"; + const searchIconColor = useThemeColor("--color-icon"); + const searchTextColor = useThemeColor("--color-foreground"); const usesNativeChrome = Platform.OS === "ios"; const usesCompactMailToolbar = Platform.OS === "ios" && width < 700; + const androidFilterActions = useMemo( + () => [ + { + id: "environment", + title: "Environment", + subactions: [ + { + id: "environment:all", + title: "All environments", + state: props.selectedEnvironmentId === null ? ("on" as const) : undefined, + }, + ...props.environments.map((environment) => ({ + id: `environment:${environment.environmentId}`, + title: environment.label, + state: + props.selectedEnvironmentId === environment.environmentId + ? ("on" as const) + : undefined, + })), + ], + }, + { + id: "sort", + title: "Sort by archived date", + subactions: [ + { + id: "sort:newest", + title: "Newest first", + state: props.sortOrder === "newest" ? ("on" as const) : undefined, + }, + { + id: "sort:oldest", + title: "Oldest first", + state: props.sortOrder === "oldest" ? ("on" as const) : undefined, + }, + ], + }, + ], + [props.environments, props.selectedEnvironmentId, props.sortOrder], + ); + const handleAndroidFilterAction = useCallback( + (event: { nativeEvent: { event: string } }) => { + const action = event.nativeEvent.event; + if (action === "environment:all") { + props.onEnvironmentChange(null); + } else if (action.startsWith("environment:")) { + props.onEnvironmentChange(action.slice("environment:".length) as EnvironmentId); + } else if (action === "sort:newest") { + props.onSortOrderChange("newest"); + } else if (action === "sort:oldest") { + props.onSortOrderChange("oldest"); + } + }, + [props.onEnvironmentChange, props.onSortOrderChange], + ); + + if (Platform.OS === "android") { + // Single header row matching the app's Android chrome (AndroidScreenHeader + // palette): back chevron, inline search, filter menu. + return ( + <> + + + + navigation.goBack()} + className="size-11 items-center justify-center" + > + + + + + + + + + + + + + + + ); + } const archiveFilterMenu = { title: "Archived thread options", items: [ @@ -244,9 +365,8 @@ function ProjectGroupLabel(props: { workspaceRoot={props.project.workspaceRoot} /> {props.project.title} @@ -280,20 +400,18 @@ function ArchivedThreadRow(props: { const subtitle = [props.environmentLabel, props.thread.branch].filter((part): part is string => Boolean(part), ); - const handleMenuAction = useCallback( - (event: { nativeEvent: { event: string } }) => { - if (event.nativeEvent.event === "unarchive") { - props.onUnarchive(); - } else if (event.nativeEvent.event === "delete") { - props.onDelete(); - } - }, - [props.onDelete, props.onUnarchive], - ); - return ( @@ -331,10 +445,7 @@ function ArchivedThreadRow(props: { > {props.thread.title} - + {timestamp} @@ -347,26 +458,14 @@ function ArchivedThreadRow(props: { type="monochrome" /> {subtitle.join(" · ")} ) : null} - - - - - - )} @@ -508,6 +607,7 @@ export function ArchivedThreadsScreen(props: { void }) { const { errors, fetchStatus, waitlist } = useWaitlist(); - const colors = useCloudWaitlistColors(); const [emailAddress, setEmailAddress] = useState(""); const [requestError, setRequestError] = useState(null); const isSubmitting = fetchStatus === "fetching"; @@ -34,7 +33,7 @@ export function CloudWaitlistEnrollment(props: { readonly onSignIn: () => void } if (waitlist.id) { return ( - + You are on the waitlist @@ -47,19 +46,22 @@ export function CloudWaitlistEnrollment(props: { readonly onSignIn: () => void } } return ( - + Enter your email and we will let you know when access is ready. - + Email address { setEmailAddress(value); @@ -67,16 +69,8 @@ export function CloudWaitlistEnrollment(props: { readonly onSignIn: () => void } }} onSubmitEditing={() => void joinWaitlist()} placeholder="Enter your email address" - placeholderTextColor={colors.placeholder} + placeholderTextColorClassName="accent-placeholder" returnKeyType="join" - style={[ - styles.input, - { - backgroundColor: colors.input, - borderColor: - fieldError || requestError ? colors.dangerForeground : colors.inputBorder, - }, - ]} textContentType="emailAddress" value={emailAddress} /> @@ -99,15 +93,11 @@ export function CloudWaitlistEnrollment(props: { readonly onSignIn: () => void } }} disabled={isSubmitting || emailAddress.trim().length === 0} onPress={() => void joinWaitlist()} - style={[ - styles.primaryButton, - { - backgroundColor: colors.primary, - opacity: isSubmitting || emailAddress.trim().length === 0 ? 0.45 : 1, - }, - ]} + className="min-h-[54px] flex-row items-center justify-center gap-2 rounded-full bg-primary px-5 py-3 disabled:opacity-[0.45]" > - {isSubmitting ? : null} + {isSubmitting ? ( + + ) : null} {isSubmitting ? "Joining" : "Join the waitlist"} @@ -120,7 +110,7 @@ export function CloudWaitlistEnrollment(props: { readonly onSignIn: () => void } function SignInAction(props: { readonly onPress: () => void }) { return ( - + Already have access? Sign in @@ -128,48 +118,3 @@ function SignInAction(props: { readonly onPress: () => void }) { ); } - -function useCloudWaitlistColors() { - return { - dangerForeground: String(useThemeColor("--color-danger-foreground")), - input: String(useThemeColor("--color-input")), - inputBorder: String(useThemeColor("--color-input-border")), - placeholder: String(useThemeColor("--color-placeholder")), - primary: String(useThemeColor("--color-primary")), - primaryForeground: String(useThemeColor("--color-primary-foreground")), - }; -} - -const styles = StyleSheet.create({ - content: { - gap: 18, - }, - field: { - gap: 8, - }, - input: { - borderCurve: "continuous", - borderRadius: 16, - borderWidth: 1, - minHeight: 54, - paddingHorizontal: 16, - paddingVertical: 14, - }, - primaryButton: { - alignItems: "center", - borderRadius: 999, - flexDirection: "row", - gap: 8, - justifyContent: "center", - minHeight: 54, - paddingHorizontal: 20, - paddingVertical: 12, - }, - signInRow: { - alignItems: "center", - flexDirection: "row", - gap: 4, - justifyContent: "center", - paddingTop: 4, - }, -}); diff --git a/apps/mobile/src/features/cloud/ConnectOnboardingRouteScreen.tsx b/apps/mobile/src/features/cloud/ConnectOnboardingRouteScreen.tsx index 657905c8b65..1f56ea8da17 100644 --- a/apps/mobile/src/features/cloud/ConnectOnboardingRouteScreen.tsx +++ b/apps/mobile/src/features/cloud/ConnectOnboardingRouteScreen.tsx @@ -88,7 +88,7 @@ function ConfiguredConnectOnboardingRouteScreen() { alwaysBounceVertical contentInsetAdjustmentBehavior="automatic" showsVerticalScrollIndicator={false} - style={{ flex: 1 }} + className="flex-1" contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }} contentContainerStyle={{ gap: 16, diff --git a/apps/mobile/src/features/cloud/connectOnboardingOptOut.ts b/apps/mobile/src/features/cloud/connectOnboardingOptOut.ts index 8042bd0aa6c..ff74863b614 100644 --- a/apps/mobile/src/features/cloud/connectOnboardingOptOut.ts +++ b/apps/mobile/src/features/cloud/connectOnboardingOptOut.ts @@ -1,7 +1,7 @@ -import { loadPreferences, updatePreferences } from "../../lib/storage"; +import { loadPreferences, updatePreferences } from "../../persistence/imperative"; // Lives apart from connectOnboarding.ts so CloudAuthProvider (which imports -// the request signal) never pulls lib/storage — expo-secure-store — into its +// the request signal) never pulls the persistence adapter into its // module graph; that breaks CloudAuthProvider.test.ts suite loading. /** Whether the account chose "Don't show this again". */ diff --git a/apps/mobile/src/features/cloud/linkEnvironment.test.ts b/apps/mobile/src/features/cloud/linkEnvironment.test.ts index b9ab3aeab05..c75d60d5fdf 100644 --- a/apps/mobile/src/features/cloud/linkEnvironment.test.ts +++ b/apps/mobile/src/features/cloud/linkEnvironment.test.ts @@ -7,10 +7,13 @@ import { RelayMobileClientId } from "@t3tools/contracts/relay"; import { ManagedRelay } from "@t3tools/client-runtime/relay"; import { remoteHttpClientLayer } from "@t3tools/client-runtime/rpc"; import { HttpClient } from "effect/unstable/http"; +import { MobilePreferencesStore } from "../../persistence/mobile-preferences"; +import { MobileStorage } from "../../persistence/mobile-storage"; import { cloudEnvironmentsPendingStatus, linkEnvironmentToCloud, + linkEnvironmentToCloudWithPreference, connectCloudEnvironment, listCloudEnvironments, listCloudEnvironmentsWithStatus, @@ -36,11 +39,14 @@ vi.mock("react-native", () => ({ }, })); -vi.mock("../../lib/storage", () => ({ - loadOrCreateAgentAwarenessDeviceId: vi.fn(() => Promise.resolve("device-1")), - loadPreferences: vi.fn(() => Promise.resolve({})), +vi.mock("expo-secure-store", () => ({ + deleteItemAsync: vi.fn(), + getItemAsync: vi.fn(), + setItemAsync: vi.fn(), })); +const loadPreferences = vi.fn(() => Effect.succeed({})); + const savedConnection = { environmentId: EnvironmentId.make("env-1"), environmentLabel: "Desktop", @@ -69,6 +75,29 @@ function cloudClientLayer() { const httpClientLayer = remoteHttpClientLayer((input, init) => globalThis.fetch(input, init)); return Layer.mergeAll( httpClientLayer, + Layer.succeed( + MobilePreferencesStore, + MobilePreferencesStore.of({ + load: loadPreferences(), + savePatch: (patch) => Effect.succeed(patch), + update: () => Effect.succeed({}), + }), + ), + Layer.succeed( + MobileStorage, + MobileStorage.of({ + loadSavedConnections: Effect.succeed([]), + saveConnection: () => Effect.void, + clearSavedConnection: () => Effect.void, + loadOrCreateAgentAwarenessDeviceId: Effect.succeed("device-1"), + loadAgentAwarenessDeviceId: Effect.succeed("device-1"), + loadAgentAwarenessRegistrationRecord: Effect.succeed(null), + saveAgentAwarenessRegistrationRecord: () => Effect.void, + clearAgentAwarenessRegistrationRecord: Effect.void, + loadRecentThreadShortcuts: Effect.succeed([]), + saveRecentThreadShortcuts: () => Effect.void, + }), + ), ManagedRelay.layer({ relayUrl: "https://relay.example.test", clientId: RelayMobileClientId, @@ -80,7 +109,11 @@ const withCloudServices = ( effect: Effect.Effect< A, E, - HttpClient.HttpClient | ManagedRelay.ManagedRelayClient | ManagedRelay.ManagedRelayDpopSigner + | HttpClient.HttpClient + | ManagedRelay.ManagedRelayClient + | ManagedRelay.ManagedRelayDpopSigner + | MobilePreferencesStore + | MobileStorage >, ) => effect.pipe(Effect.provide(cloudClientLayer())); @@ -146,6 +179,7 @@ describe("mobile cloud link environment client", () => { beforeEach(() => { vi.restoreAllMocks(); createProofMock.mockClear(); + loadPreferences.mockClear(); }); it("normalizes configured relay base URLs before building DPoP-bound requests", () => { @@ -705,10 +739,7 @@ describe("mobile cloud link environment client", () => { it.effect("preserves disabled Live Activity preferences when linking an environment", () => Effect.gen(function* () { - const storage = yield* Effect.promise(() => import("../../lib/storage")); - vi.mocked(storage.loadPreferences).mockResolvedValueOnce({ - liveActivitiesEnabled: false, - }); + loadPreferences.mockReturnValueOnce(Effect.succeed({ liveActivitiesEnabled: false })); const bodies: Array = []; const fetchMock = vi.fn((url: string | URL, init?: RequestInit) => { if (init?.body) { @@ -761,6 +792,45 @@ describe("mobile cloud link environment client", () => { }), ); + it.effect("uses an explicit Live Activity preference when persisted state is unavailable", () => + Effect.gen(function* () { + loadPreferences.mockReturnValueOnce(Effect.die("persisted preferences must not be read")); + const bodies: Array> = []; + const fetchMock = vi.fn((url: string | URL, init?: RequestInit) => { + if (init?.body) { + // @effect-diagnostics-next-line preferSchemaOverJson:off + bodies.push(JSON.parse(requestBodyText(init.body)) as Record); + } + if (String(url).endsWith("/v1/client/environment-link-challenges")) { + return Promise.resolve(Response.json(validLinkChallengeResponse())); + } + if (String(url).endsWith("/api/connect/link-proof")) { + return Promise.resolve(Response.json(validLinkProof())); + } + if (String(url).endsWith("/v1/client/environment-links")) { + return Promise.resolve(Response.json(validLinkResponse())); + } + return Promise.resolve( + Response.json({ ok: true, endpointRuntimeStatus: { status: "configured" } }), + ); + }); + vi.stubGlobal("fetch", fetchMock); + + yield* withCloudServices( + linkEnvironmentToCloudWithPreference({ + clerkToken: "clerk-token", + connection: savedConnection, + liveActivitiesEnabled: true, + }), + ); + + expect(bodies.filter((body) => "liveActivitiesEnabled" in body)).toEqual([ + expect.objectContaining({ liveActivitiesEnabled: true }), + expect.objectContaining({ liveActivitiesEnabled: true }), + ]); + }), + ); + it.effect( "does not persist cloud connect bootstrap credentials in saved connection records", () => diff --git a/apps/mobile/src/features/cloud/linkEnvironment.ts b/apps/mobile/src/features/cloud/linkEnvironment.ts index a77ca628978..5423686e048 100644 --- a/apps/mobile/src/features/cloud/linkEnvironment.ts +++ b/apps/mobile/src/features/cloud/linkEnvironment.ts @@ -30,7 +30,8 @@ import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime/rpc"; import { authClientMetadata } from "../../lib/authClientMetadata"; import type { SavedRemoteConnection } from "../../lib/connection"; -import { loadOrCreateAgentAwarenessDeviceId, loadPreferences } from "../../lib/storage"; +import * as MobilePreferences from "../../persistence/mobile-preferences"; +import * as MobileStorage from "../../persistence/mobile-storage"; import { resolveCloudPublicConfig } from "./publicConfig"; const RELAY_STATUS_AND_CONNECT_SCOPES = [ @@ -256,14 +257,19 @@ function ensureConnectEndpointMatchesEnvironment(input: { return Effect.void; } -export function linkEnvironmentToCloud(input: { +interface LinkEnvironmentToCloudInput { readonly connection: SavedRemoteConnection; readonly clerkToken: string; -}): Effect.Effect< - void, - CloudEnvironmentLinkError, - HttpClient.HttpClient | ManagedRelay.ManagedRelayClient -> { +} + +type LinkEnvironmentToCloudRequirements = + | HttpClient.HttpClient + | ManagedRelay.ManagedRelayClient + | MobileStorage.MobileStorage; + +export function linkEnvironmentToCloudWithPreference( + input: LinkEnvironmentToCloudInput & { readonly liveActivitiesEnabled: boolean }, +): Effect.Effect { return Effect.gen(function* () { if (!input.connection.bearerToken) { return yield* new CloudEnvironmentLinkError({ @@ -273,15 +279,11 @@ export function linkEnvironmentToCloud(input: { const localBearerToken = input.connection.bearerToken; const relayUrl = yield* requireRelayUrl(); const relayClient = yield* ManagedRelay.ManagedRelayClient; - const deviceId = yield* Effect.tryPromise({ - try: () => loadOrCreateAgentAwarenessDeviceId(), - catch: cloudEnvironmentLinkError("Could not load the mobile device id."), - }); - const preferences = yield* Effect.tryPromise({ - try: () => loadPreferences(), - catch: cloudEnvironmentLinkError("Could not load mobile notification preferences."), - }); - const liveActivitiesEnabled = preferences.liveActivitiesEnabled !== false; + const storage = yield* MobileStorage.MobileStorage; + const deviceId = yield* storage.loadOrCreateAgentAwarenessDeviceId.pipe( + Effect.mapError(cloudEnvironmentLinkError("Could not load the mobile device id.")), + ); + const liveActivitiesEnabled = input.liveActivitiesEnabled; const challenge = yield* relayClient .createEnvironmentLinkChallenge({ clerkToken: input.clerkToken, @@ -350,6 +352,25 @@ export function linkEnvironmentToCloud(input: { }); } +export function linkEnvironmentToCloud( + input: LinkEnvironmentToCloudInput, +): Effect.Effect< + void, + CloudEnvironmentLinkError, + LinkEnvironmentToCloudRequirements | MobilePreferences.MobilePreferencesStore +> { + return MobilePreferences.MobilePreferencesStore.pipe( + Effect.flatMap((preferencesStore) => preferencesStore.load), + Effect.mapError(cloudEnvironmentLinkError("Could not load mobile notification preferences.")), + Effect.flatMap((preferences) => + linkEnvironmentToCloudWithPreference({ + ...input, + liveActivitiesEnabled: preferences.liveActivitiesEnabled !== false, + }), + ), + ); +} + export function listCloudEnvironments(input: { readonly clerkToken: string; }): Effect.Effect< @@ -460,10 +481,10 @@ export function listCloudEnvironmentsWithStatus(input: { const loadAgentAwarenessDeviceId = Effect.fn("mobile.cloud.loadAgentAwarenessDeviceId")( function* () { - return yield* Effect.tryPromise({ - try: () => loadOrCreateAgentAwarenessDeviceId(), - catch: cloudEnvironmentLinkError("Could not load the mobile device id."), - }); + const storage = yield* MobileStorage.MobileStorage; + return yield* storage.loadOrCreateAgentAwarenessDeviceId.pipe( + Effect.mapError(cloudEnvironmentLinkError("Could not load the mobile device id.")), + ); }, ); @@ -557,7 +578,10 @@ export function connectCloudEnvironment(input: { }): Effect.Effect< SavedRemoteConnection, CloudEnvironmentLinkError, - HttpClient.HttpClient | ManagedRelay.ManagedRelayClient | ManagedRelay.ManagedRelayDpopSigner + | HttpClient.HttpClient + | ManagedRelay.ManagedRelayClient + | ManagedRelay.ManagedRelayDpopSigner + | MobileStorage.MobileStorage > { return connectRelayManagedEnvironment({ clerkToken: input.clerkToken, @@ -572,7 +596,10 @@ export function refreshCloudEnvironmentConnection(input: { }): Effect.Effect< SavedRemoteConnection, CloudEnvironmentLinkError, - HttpClient.HttpClient | ManagedRelay.ManagedRelayClient | ManagedRelay.ManagedRelayDpopSigner + | HttpClient.HttpClient + | ManagedRelay.ManagedRelayClient + | ManagedRelay.ManagedRelayDpopSigner + | MobileStorage.MobileStorage > { return connectRelayManagedEnvironment({ clerkToken: input.clerkToken, diff --git a/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx b/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx index 854ae37d4ce..400c4e5e3d3 100644 --- a/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx +++ b/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx @@ -1,5 +1,5 @@ import { useAuth } from "@clerk/expo"; -import { SymbolView } from "expo-symbols"; +import { SymbolView } from "../../components/AppSymbol"; import { connectionStatusText, type EnvironmentConnectionPhase, @@ -297,9 +297,8 @@ function CloudEnvironmentRowShell(props: { {props.connectionError ? ( {measuredErrorText} @@ -321,7 +320,7 @@ function CloudEnvironmentRowShell(props: { { event.stopPropagation(); copyTextWithHaptic(errorTraceId, { target: "connection-trace-id" }); @@ -329,7 +328,6 @@ function CloudEnvironmentRowShell(props: { onPress={(event) => { event.stopPropagation(); }} - style={{ textDecorationStyle: "dotted" }} > {errorTraceId} diff --git a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx index 4b2da3e24ca..03a0eb5025f 100644 --- a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx +++ b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx @@ -1,4 +1,4 @@ -import { SymbolView } from "expo-symbols"; +import { SymbolView } from "../../components/AppSymbol"; import { connectionStatusText } from "@t3tools/client-runtime/connection"; import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; import type { EnvironmentId } from "@t3tools/contracts"; @@ -38,7 +38,6 @@ export function ConnectionEnvironmentRow(props: { 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 statusLabel = connectionStatusLabel(props.environment); @@ -98,7 +97,7 @@ export function ConnectionEnvironmentRow(props: { { event.stopPropagation(); copyTextWithHaptic(statusTraceId, { target: "connection-trace-id" }); @@ -106,7 +105,6 @@ export function ConnectionEnvironmentRow(props: { onPress={(event) => { event.stopPropagation(); }} - style={{ textDecorationStyle: "dotted" }} > {statusTraceId} @@ -140,17 +138,13 @@ export function ConnectionEnvironmentRow(props: { ) : ( <> - + Label - + URL - + Save diff --git a/apps/mobile/src/features/connection/ConnectionSheetButton.tsx b/apps/mobile/src/features/connection/ConnectionSheetButton.tsx index 8a692d80729..d58ee338973 100644 --- a/apps/mobile/src/features/connection/ConnectionSheetButton.tsx +++ b/apps/mobile/src/features/connection/ConnectionSheetButton.tsx @@ -1,4 +1,4 @@ -import { SymbolView } from "expo-symbols"; +import { SymbolView } from "../../components/AppSymbol"; import { Platform, Pressable } from "react-native"; import { useThemeColor } from "../../lib/useThemeColor"; @@ -37,33 +37,11 @@ export function ConnectionSheetButton(props: { }) { 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 textColor = tone === "primary" ? primaryFg : tone === "danger" ? dangerFg : secondaryFg; const primaryShadow = tone === "primary" @@ -84,28 +62,32 @@ export function ConnectionSheetButton(props: { props.compact ? "min-h-[42px] flex-row items-center justify-center gap-1.5 rounded-[14px] px-3.5 py-2.5" : "min-h-[48px] flex-row items-center justify-center gap-2 rounded-[16px] px-4 py-3", + "disabled:opacity-50", + tone === "primary" + ? "bg-primary" + : tone === "danger" + ? "border border-danger-border bg-danger" + : "border border-border bg-secondary", )} disabled={props.disabled} onPress={props.onPress} - style={[ - { - backgroundColor: colors.backgroundColor, - borderWidth: tone === "primary" ? 0 : 1, - borderColor: colors.borderColor, - opacity: props.disabled ? 0.5 : 1, - }, - primaryShadow, - ]} + style={primaryShadow} > {props.label} diff --git a/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx b/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx index af4431c6a66..de3799ac8a8 100644 --- a/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx +++ b/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx @@ -3,10 +3,11 @@ import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/Stac import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; import { AsyncResult } from "effect/unstable/reactivity"; import { useCallback, useEffect, useState } from "react"; -import { Alert, ScrollView, View } from "react-native"; +import { Alert, Platform, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useThemeColor } from "../../lib/useThemeColor"; +import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; import { ErrorBanner } from "../../components/ErrorBanner"; import { ConnectionSheetButton } from "./ConnectionSheetButton"; @@ -38,7 +39,6 @@ export function ConnectionsNewRouteScreen({ const [scannerLocked, setScannerLocked] = useState(false); const headerIconColor = useThemeColor("--color-icon"); - const placeholderColor = useThemeColor("--color-placeholder"); const connectDisabled = isSubmitting || hostInput.trim().length === 0; @@ -137,28 +137,50 @@ export function ConnectionsNewRouteScreen({ - - { - if (showScanner) { - closeScanner(); - } else { - void openScanner(); - } - }} - separateBackground - tintColor={headerIconColor} + {Platform.OS === "android" ? ( + navigation.goBack()} + actions={[ + { + accessibilityLabel: showScanner ? "Close scanner" : "Scan QR code", + icon: showScanner ? "xmark" : "camera", + onPress: () => { + if (showScanner) { + closeScanner(); + } else { + void openScanner(); + } + }, + }, + ]} /> - + ) : ( + + { + if (showScanner) { + closeScanner(); + } else { + void openScanner(); + } + }} + separateBackground + tintColor={headerIconColor} + /> + + )} {showScanner ? ( cameraPermission?.granted ? ( - + ) : ( - + Camera permission is required to scan a QR code. @@ -200,10 +216,7 @@ export function ConnectionsNewRouteScreen({ ) : ( - + Host - + Pairing code - - navigation.navigate("ConnectionsNew")} - separateBackground + {Platform.OS === "android" ? ( + navigation.goBack()} + actions={[ + { + accessibilityLabel: "Add environment", + icon: "plus", + onPress: () => navigation.navigate("ConnectionsNew"), + }, + ]} /> - + ) : ( + + navigation.navigate("ConnectionsNew")} + separateBackground + /> + + )} { const renderers: CustomRenderers = { link: ({ href, children }) => ( { if (href) { void tryOpenExternalUrl(href, "markdown-link"); @@ -57,7 +62,6 @@ function useMarkdownPreviewStyles(): MarkdownPreviewStyles { }} style={{ color: link, - fontFamily: "DMSans_500Medium", textDecorationLine: "none", }} > @@ -74,11 +78,14 @@ function useMarkdownPreviewStyles(): MarkdownPreviewStyles { link, blockquote: blockquoteBorder, border: horizontalRule, + surface: "transparent", surfaceLight: blockquoteBackground, accent: link, tableBorder: horizontalRule, tableHeader: blockquoteBackground, tableHeaderText: strong, + tableRowOdd: blockquoteBackground, + tableRowEven: "transparent", code: codeText, codeBackground, }, @@ -86,21 +93,21 @@ function useMarkdownPreviewStyles(): MarkdownPreviewStyles { styles: { text: { color: body, - fontFamily: "DMSans_400Regular", + fontFamily: regularFontFamily, fontSize: markdownFontSizes.m, lineHeight: markdownFontSizes.bodyLineHeight, }, heading: { color: strong, - fontFamily: "DMSans_700Bold", + fontFamily: boldFontFamily, }, strong: { color: strong, - fontFamily: "DMSans_700Bold", + fontFamily: boldFontFamily, }, link: { color: link, - fontFamily: "DMSans_500Medium", + fontFamily: mediumFontFamily, }, blockquote: { backgroundColor: blockquoteBackground, @@ -141,9 +148,9 @@ function useMarkdownPreviewStyles(): MarkdownPreviewStyles { fontSize: nativeMarkdownTypography.fontSize, lineHeight: nativeMarkdownTypography.lineHeight, headingFontSizes: nativeMarkdownTypography.headingFontSizes, - fontFamily: "DMSans_400Regular", - headingFontFamily: "DMSans_700Bold", - boldFontFamily: "DMSans_700Bold", + fontFamily: regularFontFamily, + headingFontFamily: boldFontFamily, + boldFontFamily, }, }; }, [ @@ -155,8 +162,11 @@ function useMarkdownPreviewStyles(): MarkdownPreviewStyles { horizontalRule, link, markdownFontSizes, + mediumFontFamily, nativeMarkdownTypography, + regularFontFamily, strong, + boldFontFamily, ]); } diff --git a/apps/mobile/src/features/files/FileTreeBrowser.tsx b/apps/mobile/src/features/files/FileTreeBrowser.tsx index e0a9808bc3c..b29121201f5 100644 --- a/apps/mobile/src/features/files/FileTreeBrowser.tsx +++ b/apps/mobile/src/features/files/FileTreeBrowser.tsx @@ -1,5 +1,5 @@ import type { ProjectEntry } from "@t3tools/contracts"; -import { SymbolView } from "expo-symbols"; +import { SymbolView } from "../../components/AppSymbol"; import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ActivityIndicator, @@ -246,7 +246,7 @@ export function FileTreeBrowser(props: { // flex-1 Views is ignored, which is why the tree rendered under the header with no blur. return ( item.node.path} contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : "never"} diff --git a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx index 487d8271e1b..012f99536d2 100644 --- a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx +++ b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx @@ -1,14 +1,7 @@ import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { - ActivityIndicator, - Platform, - Pressable, - ScrollView, - useColorScheme, - View, -} from "react-native"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { ActivityIndicator, Platform, useColorScheme, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg"; import { @@ -18,10 +11,11 @@ import { ThreadId, } from "@t3tools/contracts"; -import { AppText as Text } from "../../components/AppText"; +import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; +import { SymbolView } from "../../components/AppSymbol"; +import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; import { EmptyState } from "../../components/EmptyState"; import { LoadingScreen } from "../../components/LoadingScreen"; -import { cn } from "../../lib/cn"; import { resolveFileSelectionNavigationAction } from "../../lib/adaptive-navigation"; import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; @@ -48,7 +42,6 @@ import { WorkspaceFileImagePreview } from "./WorkspaceFileImagePreview"; import { WorkspaceFileWebPreview } from "./WorkspaceFileWebPreview"; import { basename, - fileBreadcrumbs, isBrowserPreviewFile, isImagePreviewFile, isMarkdownPreviewFile, @@ -224,14 +217,7 @@ function FilesToolbarBottomFade() { pointerEvents="none" accessibilityElementsHidden importantForAccessibility="no-hide-descendants" - style={{ - bottom: 0, - height: 112, - left: 0, - position: "absolute", - right: 0, - zIndex: 1, - }} + className="absolute inset-x-0 bottom-0 z-[1] h-28" > @@ -254,7 +240,9 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { useAdaptiveWorkspaceLayout(); const [searchQuery, setSearchQuery] = useState(""); const colorScheme = useColorScheme(); + const isAndroid = Platform.OS === "android"; const highlightTheme = colorScheme === "dark" ? "dark" : "light"; + const iconColor = String(useThemeColor("--color-icon-muted")); const { cwd, environmentId, projectName, selectedThread, threadId } = useThreadFilesWorkspace( props.route.params, ); @@ -374,6 +362,7 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { Only genuinely dynamic options are set here. */} 0 ? projectName : undefined, // No refresh button: the list already supports pull-to-refresh. @@ -402,22 +391,55 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { }, }} /> - {layout.usesSplitView ? ( - - + - - ) : null} - {usesCompactMailToolbar ? null : ( - - - + + + + + + ) : ( + <> + {layout.usesSplitView ? ( + + + + ) : null} + {usesCompactMailToolbar ? null : ( + + + + )} + )} void; + readonly children: ReactNode; +}) { + if (Platform.OS !== "android") { + return <>{props.children}; + } + + return ; +} + +function AndroidHomeFab(props: { + readonly onStartNewTask: () => void; + readonly children: ReactNode; +}) { + const insets = useSafeAreaInsets(); + const primaryForegroundColor = useThemeColor("--color-primary-foreground"); + + return ( + + {props.children} + + + + + ); +} diff --git a/apps/mobile/src/features/home/HomeHeader.tsx b/apps/mobile/src/features/home/HomeHeader.tsx index 652dc02fccb..82df2915147 100644 --- a/apps/mobile/src/features/home/HomeHeader.tsx +++ b/apps/mobile/src/features/home/HomeHeader.tsx @@ -3,12 +3,20 @@ import type { SidebarProjectGroupingMode, SidebarThreadSortOrder, } from "@t3tools/contracts"; -import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; -import { useCallback, useRef } from "react"; -import { Platform } from "react-native"; +import type { MenuAction } from "@react-native-menu/menu"; +import { + NativeHeaderToolbar, + NativeStackScreenOptions, + type AppNativeStackNavigationOptions, +} from "../../native/StackHeader"; +import { useCallback, useMemo, useRef } from "react"; +import { Platform, Pressable, Text as RNText, TextInput, View } from "react-native"; import type { SearchBarCommands } from "react-native-screens"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader"; +import { ControlPillMenu } from "../../components/ControlPill"; +import { SymbolView } from "../../components/AppSymbol"; +import { T3Wordmark } from "../../components/T3Wordmark"; import { useThemeColor } from "../../lib/useThemeColor"; import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items"; @@ -26,10 +34,10 @@ import { } from "./home-list-options"; export type HomeHeaderEnvironment = HomeListFilterMenuEnvironment; -const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version); export function HomeHeader(props: { readonly environments: ReadonlyArray; + readonly searchQuery: string; readonly selectedEnvironmentId: EnvironmentId | null; readonly projectSortOrder: HomeProjectSortOrder; readonly threadSortOrder: SidebarThreadSortOrder; @@ -42,7 +50,216 @@ export function HomeHeader(props: { readonly onOpenSettings: () => void; readonly onStartNewTask: () => void; }) { + if (Platform.OS === "android") { + return ; + } + + return ; +} + +type HomeHeaderProps = Parameters[0]; + +function checkedMenuState(checked: boolean) { + return checked ? ("on" as const) : undefined; +} + +function AndroidHomeHeader(props: HomeHeaderProps) { + const insets = useSafeAreaInsets(); + const iconColor = useThemeColor("--color-icon"); + const mutedColor = useThemeColor("--color-foreground-muted"); + const hasCustomListOptions = hasCustomHomeListOptions(props); + const menuActions = useMemo( + () => [ + { + id: "environment", + title: "Environment", + subactions: [ + { + id: "environment:all", + title: "All environments", + state: checkedMenuState(props.selectedEnvironmentId === null), + }, + ...props.environments.map((environment) => ({ + id: `environment:${environment.environmentId}`, + title: environment.label, + state: checkedMenuState(props.selectedEnvironmentId === environment.environmentId), + })), + ], + }, + { + id: "project-sort", + title: "Sort projects", + subactions: PROJECT_SORT_OPTIONS.map((option) => ({ + id: `project-sort:${option.value}`, + title: option.label, + state: checkedMenuState(props.projectSortOrder === option.value), + })), + }, + { + id: "thread-sort", + title: "Sort threads", + subactions: THREAD_SORT_OPTIONS.map((option) => ({ + id: `thread-sort:${option.value}`, + title: option.label, + state: checkedMenuState(props.threadSortOrder === option.value), + })), + }, + { + id: "project-grouping", + title: "Group projects", + subactions: PROJECT_GROUPING_OPTIONS.map((option) => ({ + id: `project-grouping:${option.value}`, + title: option.label, + state: checkedMenuState(props.projectGroupingMode === option.value), + })), + }, + ], + [ + props.environments, + props.projectGroupingMode, + props.projectSortOrder, + props.selectedEnvironmentId, + props.threadSortOrder, + ], + ); + const handleMenuAction = useCallback( + (event: { nativeEvent: { event: string } }) => { + const id = event.nativeEvent.event; + if (id === "environment:all") { + props.onEnvironmentChange(null); + return; + } + + if (id.startsWith("environment:")) { + const environmentId = id.slice("environment:".length); + const environment = props.environments.find( + (candidate) => candidate.environmentId === environmentId, + ); + if (environment) { + props.onEnvironmentChange(environment.environmentId); + } + return; + } + + const projectSort = PROJECT_SORT_OPTIONS.find( + (option) => id === `project-sort:${option.value}`, + ); + if (projectSort) { + props.onProjectSortOrderChange(projectSort.value); + return; + } + + const threadSort = THREAD_SORT_OPTIONS.find((option) => id === `thread-sort:${option.value}`); + if (threadSort) { + props.onThreadSortOrderChange(threadSort.value); + return; + } + + const grouping = PROJECT_GROUPING_OPTIONS.find( + (option) => id === `project-grouping:${option.value}`, + ); + if (grouping) { + props.onProjectGroupingModeChange(grouping.value); + } + }, + [props], + ); + + return ( + <> + + + + + + {/* Mirrors the desktop SidebarBrand: T3 mark + muted "Code". */} + + + Code + + + + Alpha + + + + + + + + + + {/* Built identically to the filter button so the two circles + match exactly (ControlPill sizes via Tailwind classes and + resolves to a different box). */} + + + + + + + + + {props.searchQuery.length > 0 ? ( + props.onSearchQueryChange("")} + > + + + ) : null} + + + + + ); +} + +function IosHomeHeader(props: HomeHeaderProps) { const searchBarRef = useRef(null); + const propsRef = useRef(props); + propsRef.current = props; const iconColor = useThemeColor("--color-icon"); const hasCustomListOptions = hasCustomHomeListOptions(props); const focusSearch = useCallback(() => { @@ -50,63 +267,67 @@ export function HomeHeader(props: { return searchBarRef.current !== null; }, []); useHardwareKeyboardCommand("focusSearch", focusSearch); - const filterMenu = buildHomeListFilterMenu(props); + const screenOptions = useMemo((): AppNativeStackNavigationOptions => { + return { + headerTintColor: iconColor, + unstable_headerRightItems: + Platform.OS === "ios" + ? () => [ + withNativeGlassHeaderItem({ + accessibilityLabel: "Open settings", + icon: { name: "ellipsis", type: "sfSymbol" } as const, + identifier: "home-settings", + label: "", + onPress: () => propsRef.current.onOpenSettings(), + type: "button", + }), + ] + : undefined, + unstable_headerToolbarItems: + Platform.OS === "ios" + ? () => [ + createNativeMailSearchToolbarItem({ + composeButtonId: "home-new-task", + composeSystemImageName: "square.and.pencil", + // Build at invocation time so filter onPress handlers always + // read current callbacks via propsRef, matching compose/settings. + filterMenu: buildHomeListFilterMenu(propsRef.current), + filterButtonId: "home-filter", + filterSystemImageName: hasCustomListOptions + ? "line.3.horizontal.decrease.circle.fill" + : "line.3.horizontal.decrease", + onComposePress: () => propsRef.current.onStartNewTask(), + onSearchTextChange: (query) => propsRef.current.onSearchQueryChange(query), + placeholder: "Search", + searchTextChangeId: "home-search-text", + }), + ] + : undefined, + headerSearchBarOptions: + Platform.OS === "ios" + ? undefined + : { + ref: searchBarRef, + allowToolbarIntegration: true, + hideNavigationBar: false, + placeholder: "Search", + onCancelButtonPress: () => propsRef.current.onSearchQueryChange(""), + onChangeText: (event) => propsRef.current.onSearchQueryChange(event.nativeEvent.text), + }, + }; + }, [ + hasCustomListOptions, + iconColor, + props.environments, + props.projectGroupingMode, + props.projectSortOrder, + props.selectedEnvironmentId, + props.threadSortOrder, + ]); return ( <> - [ - withNativeGlassHeaderItem({ - accessibilityLabel: "Open settings", - icon: { name: "ellipsis", type: "sfSymbol" } as const, - identifier: "home-settings", - label: "", - onPress: props.onOpenSettings, - type: "button", - }), - ] - : undefined, - unstable_headerToolbarItems: - Platform.OS === "ios" - ? () => [ - createNativeMailSearchToolbarItem({ - composeButtonId: "home-new-task", - composeSystemImageName: "square.and.pencil", - filterMenu, - filterButtonId: "home-filter", - filterSystemImageName: hasCustomListOptions - ? "line.3.horizontal.decrease.circle.fill" - : "line.3.horizontal.decrease", - onComposePress: props.onStartNewTask, - onSearchTextChange: props.onSearchQueryChange, - placeholder: "Search", - searchTextChangeId: "home-search-text", - }), - ] - : undefined, - headerSearchBarOptions: - Platform.OS === "ios" - ? undefined - : { - ref: searchBarRef, - allowToolbarIntegration: true, - hideNavigationBar: false, - placeholder: "Search", - onCancelButtonPress: () => { - props.onSearchQueryChange(""); - }, - onChangeText: (event) => { - props.onSearchQueryChange(event.nativeEvent.text); - }, - }, - }} - /> + {Platform.OS === "ios" ? null : ( diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index eaea597211f..74699e3e8ac 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -1,7 +1,7 @@ import * as Arr from "effect/Array"; import * as Order from "effect/Order"; import { useNavigation } from "@react-navigation/native"; -import { useMemo, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; import { useProjects, useThreadShells } from "../../state/entities"; @@ -11,12 +11,16 @@ import { useSavedRemoteConnections } from "../../state/use-remote-environment-re import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; import { WorkspaceEmptyDetail } from "../layout/WorkspaceEmptyDetail"; import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar"; +import { AndroidHomeFabLayout } from "./AndroidHomeFab"; import { HomeScreen } from "./HomeScreen"; import { HomeHeader } from "./HomeHeader"; import { useHomeListOptions } from "./home-list-options"; import { usePendingTaskListActions } from "./usePendingTaskListActions"; import { useThreadListActions } from "./useThreadListActions"; +const EMPTY_HOME_TITLE_OPTIONS = { title: "", headerTitle: "" } as const; +const THREADS_HOME_TITLE_OPTIONS = { title: "Threads", headerTitle: "Threads" } as const; + /* ─── Route screen ───────────────────────────────────────────────────── */ export function HomeRouteScreen() { @@ -56,94 +60,101 @@ export function HomeRouteScreen() { setThreadSortOrder, } = useHomeListOptions(availableEnvironmentIds); const selectedEnvironmentId = listOptions.selectedEnvironmentId; + const openSettings = useCallback(() => { + navigation.navigate("SettingsSheet", { screen: "Settings" }); + }, [navigation]); + const openNewTask = useCallback(() => { + navigation.navigate("NewTaskSheet", { screen: "NewTask" }); + }, [navigation]); // In split layouts the persistent sidebar IS the thread list — Home becomes // an empty detail pane so selecting a thread never transitions layouts. if (layout.usesSplitView) { return ( <> - + navigation.navigate("NewTaskSheet", { screen: "NewTask" })} + onPress={openNewTask} /> } /> - navigation.navigate("NewTaskSheet", { screen: "NewTask" })} - /> + ); } return ( - <> - {/* Restore the compact title in case the split branch blanked it. */} - - navigation.navigate("SettingsSheet", { screen: "Settings" })} - onProjectGroupingModeChange={setProjectGroupingMode} - onProjectSortOrderChange={setProjectSortOrder} - onSearchQueryChange={setSearchQuery} - onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })} - onThreadSortOrderChange={setThreadSortOrder} - /> + + <> + {/* Restore the compact title in case the split branch blanked it. */} + + - - navigation.navigate("SettingsSheet", { screen: "SettingsEnvironmentNew" }) - } - onArchiveThread={archiveThread} - onDeleteThread={confirmDeleteThread} - onEnvironmentChange={setSelectedEnvironmentId} - onOpenEnvironments={() => - navigation.navigate("SettingsSheet", { screen: "SettingsEnvironments" }) - } - onOpenSettings={() => navigation.navigate("SettingsSheet", { screen: "Settings" })} - onProjectGroupingModeChange={setProjectGroupingMode} - onProjectSortOrderChange={setProjectSortOrder} - onSearchQueryChange={setSearchQuery} - onSelectThread={(thread) => { - navigation.navigate("Thread", { - environmentId: thread.environmentId, - threadId: thread.id, - }); - }} - onSelectPendingTask={openPendingTask} - onDeletePendingTask={confirmDeletePendingTask} - onNewThreadInProject={(project) => { - navigation.navigate("NewTaskSheet", { - screen: "NewTaskDraft", - params: { - environmentId: String(project.environmentId), - projectId: String(project.id), - title: project.title, - }, - }); - }} - onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })} - onThreadSortOrderChange={setThreadSortOrder} - pendingTasks={pendingTasks} - projectGroupingMode={listOptions.projectGroupingMode} - projects={projects} - projectSortOrder={listOptions.projectSortOrder} - savedConnectionsById={savedConnectionsById} - searchQuery={searchQuery} - selectedEnvironmentId={selectedEnvironmentId} - threads={threads} - threadSortOrder={listOptions.threadSortOrder} - /> - + + navigation.navigate("SettingsSheet", { screen: "SettingsEnvironmentNew" }) + } + onArchiveThread={archiveThread} + onDeleteThread={confirmDeleteThread} + onEnvironmentChange={setSelectedEnvironmentId} + onOpenEnvironments={() => + navigation.navigate("SettingsSheet", { screen: "SettingsEnvironments" }) + } + onOpenSettings={openSettings} + onProjectGroupingModeChange={setProjectGroupingMode} + onProjectSortOrderChange={setProjectSortOrder} + onSearchQueryChange={setSearchQuery} + onSelectThread={(thread) => { + navigation.navigate("Thread", { + environmentId: thread.environmentId, + threadId: thread.id, + }); + }} + onSelectPendingTask={openPendingTask} + onDeletePendingTask={confirmDeletePendingTask} + onNewThreadInProject={(project) => { + navigation.navigate("NewTaskSheet", { + screen: "NewTaskDraft", + params: { + environmentId: String(project.environmentId), + projectId: String(project.id), + title: project.title, + }, + }); + }} + onStartNewTask={openNewTask} + onThreadSortOrderChange={setThreadSortOrder} + pendingTasks={pendingTasks} + projectGroupingMode={listOptions.projectGroupingMode} + projects={projects} + projectSortOrder={listOptions.projectSortOrder} + savedConnectionsById={savedConnectionsById} + searchQuery={searchQuery} + selectedEnvironmentId={selectedEnvironmentId} + threads={threads} + threadSortOrder={listOptions.threadSortOrder} + /> + + ); } diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 386880d76c8..e535ca08f71 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -12,6 +12,8 @@ import type { SidebarProjectGroupingMode, SidebarThreadSortOrder, } from "@t3tools/contracts"; +import { useAtomSet, useAtomValue } from "@effect/atom-react"; +import { AsyncResult } from "effect/unstable/reactivity"; import { useCallback, useMemo, useRef, useState } from "react"; import { ActivityIndicator, Platform, View } from "react-native"; import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; @@ -22,6 +24,7 @@ import { EmptyState } from "../../components/EmptyState"; import type { WorkspaceState } from "../../state/workspaceModel"; import type { SavedRemoteConnection } from "../../lib/connection"; import { scopedProjectKey } from "../../lib/scopedEntities"; +import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { PendingTaskListRow, @@ -78,8 +81,12 @@ interface HomeScreenProps { /* ─── Layout constants ───────────────────────────────────────────────── */ const ESTIMATED_THREAD_ROW_HEIGHT = 72; -/** Height of the floating custom header on non-iOS platforms. */ -const CUSTOM_HEADER_HEIGHT = 78; +/** + * Top spacing between the list and the Android custom header. The Android + * header (AndroidHomeHeader) is rendered in-flow above this screen and + * already consumes the top safe-area inset, so the list only needs breathing + * room here. + */ function deriveEmptyState(props: { readonly catalogState: WorkspaceState; @@ -144,8 +151,8 @@ function deriveEmptyState(props: { }; } -function HomeTopContentSpacer(props: { readonly topInset: number }) { - return ; +function HomeTopContentSpacer() { + return ; } /* ─── Main screen ────────────────────────────────────────────────────── */ @@ -154,21 +161,48 @@ export function HomeScreen(props: HomeScreenProps) { const [groupDisplayStates, setGroupDisplayStates] = useState< ReadonlyMap >(() => new Map()); + const preferencesResult = useAtomValue(mobilePreferencesAtom); + const savePreferences = useAtomSet(updateMobilePreferencesAtom); const openSwipeableRef = useRef(null); const listRef = useRef(null); const insets = useSafeAreaInsets(); const accentColor = useThemeColor("--color-icon-muted"); - const updateGroupDisplay = useCallback((key: string, action: HomeGroupDisplayAction) => { - setGroupDisplayStates((previous) => { - const next = new Map(previous); - next.set( - key, - nextGroupDisplayState(previous.get(key) ?? DEFAULT_GROUP_DISPLAY_STATE, action), - ); + const effectiveGroupDisplayStates = useMemo(() => { + const next = new Map(groupDisplayStates); + if (!AsyncResult.isSuccess(preferencesResult)) { return next; - }); - }, []); + } + for (const key of preferencesResult.value.collapsedProjectGroups ?? []) { + const existing = next.get(key); + next.set(key, { + ...(existing ?? DEFAULT_GROUP_DISPLAY_STATE), + collapsed: true, + }); + } + return next; + }, [groupDisplayStates, preferencesResult]); + const effectiveGroupDisplayStatesRef = useRef(effectiveGroupDisplayStates); + effectiveGroupDisplayStatesRef.current = effectiveGroupDisplayStates; + + const updateGroupDisplay = useCallback( + (key: string, action: HomeGroupDisplayAction) => { + const next = new Map(effectiveGroupDisplayStatesRef.current); + next.set(key, nextGroupDisplayState(next.get(key) ?? DEFAULT_GROUP_DISPLAY_STATE, action)); + effectiveGroupDisplayStatesRef.current = next; + setGroupDisplayStates(next); + if (action === "toggle-collapsed") { + const collapsedProjectGroups: string[] = []; + for (const [groupKey, state] of next) { + if (state.collapsed) { + collapsedProjectGroups.push(groupKey); + } + } + savePreferences({ collapsedProjectGroups }); + } + }, + [savePreferences], + ); const handleSwipeableWillOpen = useCallback((methods: SwipeableMethods) => { if (openSwipeableRef.current !== methods) { @@ -219,10 +253,10 @@ export function HomeScreen(props: HomeScreenProps) { () => buildHomeListLayout({ groups: projectGroups, - displayStates: groupDisplayStates, + displayStates: effectiveGroupDisplayStates, showAllThreads: hasSearchQuery, }), - [projectGroups, groupDisplayStates, hasSearchQuery], + [projectGroups, effectiveGroupDisplayStates, hasSearchQuery], ); const projectCwdByKey = useMemo(() => { @@ -355,7 +389,7 @@ export function HomeScreen(props: HomeScreenProps) { className="flex-1 items-center justify-center bg-screen px-8" style={{ paddingBottom: Math.max(insets.bottom, 24), - paddingTop: Platform.OS === "ios" ? insets.top + 72 : insets.top, + paddingTop: Platform.OS === "ios" ? insets.top + 72 : 0, }} > @@ -379,10 +413,10 @@ export function HomeScreen(props: HomeScreenProps) { const listHeader = ( <> - {Platform.OS === "ios" ? null : } + {Platform.OS === "ios" ? null : } {shouldShowConnectionStatus && Platform.OS === "ios" ? ( - + { expect(groups[0]?.threads.map((thread) => thread.environmentId)).toEqual([remoteEnvironmentId]); }); + it("excludes subagent threads", () => { + const environmentId = EnvironmentId.make("environment-1"); + const project = makeProject({ + environmentId, + id: ProjectId.make("project-1"), + title: "T3 Code", + }); + const parentThreadId = ThreadId.make("thread-parent"); + const forkThreadId = ThreadId.make("thread-fork"); + const threads = [ + makeThread({ + environmentId, + id: parentThreadId, + projectId: project.id, + title: "Parent thread", + }), + makeThread({ + environmentId, + id: forkThreadId, + projectId: project.id, + title: "Forked thread", + lineage: { + rootThreadId: parentThreadId, + parentThreadId, + relationshipToParent: "fork", + }, + }), + makeThread({ + environmentId, + id: ThreadId.make("thread-subagent"), + projectId: project.id, + title: "Continue the delegated task", + lineage: { + rootThreadId: parentThreadId, + parentThreadId, + relationshipToParent: "subagent", + }, + }), + ]; + + const groups = buildGroups([project], threads); + + expect(groups).toHaveLength(1); + expect(groups[0]?.threads.map((thread) => thread.id).toSorted()).toEqual( + [parentThreadId, forkThreadId].toSorted(), + ); + }); + it("matches web repository, repository-path, and separate grouping modes", () => { const environmentId = EnvironmentId.make("environment-1"); const repositoryIdentity = { diff --git a/apps/mobile/src/features/home/homeThreadList.ts b/apps/mobile/src/features/home/homeThreadList.ts index cada956d8bb..46656500fe3 100644 --- a/apps/mobile/src/features/home/homeThreadList.ts +++ b/apps/mobile/src/features/home/homeThreadList.ts @@ -165,6 +165,9 @@ export function buildHomeThreadGroups(input: { if (thread.archivedAt !== null) { continue; } + if (thread.lineage.relationshipToParent === "subagent") { + continue; + } if (input.environmentId !== null && thread.environmentId !== input.environmentId) { continue; } diff --git a/apps/mobile/src/features/home/thread-swipe-actions.tsx b/apps/mobile/src/features/home/thread-swipe-actions.tsx index dd412301077..84a9f32ee5e 100644 --- a/apps/mobile/src/features/home/thread-swipe-actions.tsx +++ b/apps/mobile/src/features/home/thread-swipe-actions.tsx @@ -1,4 +1,4 @@ -import { SymbolView } from "expo-symbols"; +import { SymbolView } from "../../components/AppSymbol"; import * as Haptics from "expo-haptics"; import { createContext, @@ -32,7 +32,8 @@ import Animated, { import { AppText as Text } from "../../components/AppText"; -const ACTION_ITEM_WIDTH = 50; +// Wide enough for the longest action label ("Unarchive"). +const ACTION_ITEM_WIDTH = 58; const ACTION_CIRCLE_SIZE = 36; const ACTION_ICON_SIZE = 15; @@ -196,7 +197,7 @@ export function ThreadSwipeable(props: { swipeableRef.current?.reset(); }, [resetKey]); const handleFullSwipeArmedChange = useCallback((armed: boolean) => { - if (armed && !fullSwipeArmedRef.current && process.env.EXPO_OS === "ios") { + if (armed && !fullSwipeArmedRef.current) { void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); } fullSwipeArmedRef.current = armed; @@ -410,7 +411,9 @@ function SwipeActionButton(props: { - {props.label} + + {props.label} + diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index cc5d0dd047f..8effdc942d9 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -4,6 +4,7 @@ import * as Haptics from "expo-haptics"; import { useCallback, useRef } from "react"; import { Alert } from "react-native"; +import { showConfirmDialog } from "../../components/ConfirmDialogHost"; import { scopedThreadKey } from "../../lib/scopedEntities"; import { threadEnvironment } from "../../state/threads"; import { useAtomCommand } from "../../state/use-atom-command"; @@ -21,9 +22,7 @@ function actionFailureMessage(action: ThreadListAction, cause: Cause.Cause { - Alert.alert( - "Delete thread?", - `“${thread.title}” will be permanently deleted, including its terminal history.`, - [ + const title = "Delete thread?"; + const message = `“${thread.title}” will be permanently deleted, including its terminal history.`; + if (process.env.EXPO_OS === "ios") { + Alert.alert(title, message, [ { text: "Cancel", style: "cancel" }, { text: "Delete", @@ -92,8 +91,18 @@ function useConfirmDeleteThread( void executeAction("delete", thread); }, }, - ], - ); + ]); + return; + } + showConfirmDialog({ + title, + message, + confirmText: "Delete", + destructive: true, + onConfirm: () => { + void executeAction("delete", thread); + }, + }); }, [executeAction], ); diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index 88e08f1d9ac..d1327473b0d 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -476,16 +476,17 @@ export function AdaptiveWorkspaceLayout(props: { return ( - + {shouldRenderPrimarySidebar && layout.listPaneWidth !== null ? ( ) : null} - + - + {props.renderInspector?.()} diff --git a/apps/mobile/src/features/layout/workspace-pane-divider.tsx b/apps/mobile/src/features/layout/workspace-pane-divider.tsx index a892ab73655..3be3cdf602d 100644 --- a/apps/mobile/src/features/layout/workspace-pane-divider.tsx +++ b/apps/mobile/src/features/layout/workspace-pane-divider.tsx @@ -69,6 +69,7 @@ export function WorkspacePaneDivider(props: WorkspacePaneDividerProps) { return ( setHovered(true)} onHoverOut={() => setHovered(false)} - style={styles.hitTarget} > @@ -91,15 +91,6 @@ export function WorkspacePaneDivider(props: WorkspacePaneDividerProps) { } const styles = StyleSheet.create({ - hitTarget: { - alignSelf: "stretch", - cursor: "pointer", - justifyContent: "center", - marginHorizontal: -22, - position: "relative", - width: 44, - zIndex: 100, - }, line: { alignSelf: "center", backgroundColor: diff --git a/apps/mobile/src/features/layout/workspace-sidebar-toolbar.tsx b/apps/mobile/src/features/layout/workspace-sidebar-toolbar.tsx index ad53d75323c..d07a003361e 100644 --- a/apps/mobile/src/features/layout/workspace-sidebar-toolbar.tsx +++ b/apps/mobile/src/features/layout/workspace-sidebar-toolbar.tsx @@ -1,5 +1,6 @@ import { NativeHeaderToolbar } from "../../native/StackHeader"; import type { ReactNode } from "react"; +import { Platform } from "react-native"; import { useAdaptiveWorkspaceLayout } from "./AdaptiveWorkspaceLayout"; @@ -11,7 +12,7 @@ export function WorkspaceSidebarToolbar( ) { const { layout, panes, togglePrimarySidebar } = useAdaptiveWorkspaceLayout(); - if (!layout.usesSplitView) { + if (Platform.OS === "android" || !layout.usesSplitView) { return null; } diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index 0a123f995d7..d56855e6df6 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -23,7 +23,7 @@ import { } from "@t3tools/client-runtime/state/projects"; import { CommandId, type EnvironmentId, ProjectId } from "@t3tools/contracts"; import { StackActions, useNavigation } from "@react-navigation/native"; -import { SymbolView } from "expo-symbols"; +import { SymbolView } from "../../components/AppSymbol"; import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; import { ActivityIndicator, Alert, Pressable, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -31,6 +31,7 @@ import * as Arr from "effect/Array"; import * as Cause from "effect/Cause"; import * as Order from "effect/Order"; import { AsyncResult } from "effect/unstable/reactivity"; +import { cn } from "../../lib/cn"; import { useProjects, useServerConfigs } from "../../state/entities"; import { filesystemEnvironment } from "../../state/filesystem"; @@ -99,10 +100,7 @@ function sourceFromParam(value: string | string[] | undefined): AddProjectRemote function SectionTitle(props: { readonly children: string }) { return ( - + {props.children} ); @@ -148,19 +146,17 @@ function ListRow(props: { readonly right?: ReactNode; readonly onPress?: () => void; }) { - const borderColor = useThemeColor("--color-border-subtle"); const chevronColor = useThemeColor("--color-chevron"); return ( ; export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProps) { + const isAndroid = Platform.OS === "android"; const navigation = useNavigation(); const insets = useSafeAreaInsets(); const { width } = useWindowDimensions(); @@ -143,15 +146,29 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp } } + const handleSubmit = useCallback(() => { + if (!target || !environmentId || !threadId || commentText.trim().length === 0) { + return; + } + + appendReviewCommentToDraft({ + environmentId, + threadId, + text: formatReviewCommentContext(target, commentText), + attachments, + }); + setAttachments([]); + dismissComposer(); + }, [attachments, commentText, dismissComposer, environmentId, target, threadId]); + return ( - - + + @@ -217,10 +234,7 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp style={{ height: codeSurface.rowHeight }} > - + {lineNumber ?? ""} @@ -253,8 +267,7 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp textAlignVertical="top" value={commentText} onChangeText={setCommentText} - className="h-full flex-1 border-0 bg-transparent px-0 py-0 font-sans text-base" - style={{ flex: 1, minHeight: 0 }} + className="h-full min-h-0 flex-1 border-0 bg-transparent px-0 py-0 font-sans text-base" /> @@ -279,7 +292,7 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp )} - {target ? ( + {!isAndroid && target ? ( { - if (!target || !environmentId || !threadId || commentText.trim().length === 0) { - return; - } - - appendReviewCommentToDraft({ - environmentId, - threadId, - text: formatReviewCommentContext(target, commentText), - attachments, - }); - setAttachments([]); - dismissComposer(); - }} + onPress={handleSubmit} /> ) : null} + {isAndroid && target ? ( + + + void handlePickImages()} + /> + + + + + ) : null} ; export function ReviewSheet(props: ReviewSheetProps) { + const isAndroid = Platform.OS === "android"; const { nativeReviewDiffStyle } = useAppearanceCodeSurface(); useAdaptiveWorkspacePaneRole("inspector"); const { panes, showAuxiliaryPane, toggleAuxiliaryPane } = useAdaptiveWorkspaceLayout(); @@ -362,7 +367,8 @@ export function ReviewSheet(props: ReviewSheetProps) { selectedThread !== null && String(selectedThread.id) === String(threadId); const selectedTheme = colorScheme === "dark" ? "dark" : "light"; // With a solid (non-overlay) header the content lays out below the header - // natively, so no manual top inset is needed. + // natively, so no manual top inset is needed. (Android renders its own + // in-flow AndroidScreenHeader, so it needs no inset either.) const topContentInset = 0; useEffect(() => { @@ -498,6 +504,52 @@ export function ReviewSheet(props: ReviewSheetProps) { hasCachedSelectedDiff, hasAnyCachedDiff, }); + const androidSectionMenuActions = useMemo(() => { + const sectionAction = (section: ReviewSectionItem | null, title: string): MenuAction => ({ + id: section ? `section:${section.id}` : `unavailable:${title}`, + title: section?.id === selectedSection?.id ? `${title} (selected)` : title, + attributes: section ? undefined : { disabled: true }, + }); + const actions: MenuAction[] = [ + sectionAction(sectionMenu.workingTree, "Working tree"), + sectionAction(sectionMenu.branchChanges, "Branch changes"), + sectionAction(sectionMenu.latestTurn, "Latest turn"), + ]; + + if (sectionMenu.turns.length > 0) { + actions.push({ + id: "turns", + title: "Turn", + subactions: sectionMenu.turns.map((section) => ({ + id: `section:${section.id}`, + title: section.id === selectedSection?.id ? `${section.title} (selected)` : section.title, + subtitle: section.subtitle ?? undefined, + })), + }); + } + + // The Android native diff surface has no pull-to-refresh, so refresh + // stays a menu action there (iOS refreshes via pull-to-refresh instead). + actions.push({ + id: "refresh", + title: "Refresh current diff", + attributes: { + disabled: !selectedSection || selectedSection.isLoading, + }, + }); + return actions; + }, [sectionMenu, selectedSection]); + const handleAndroidSectionMenuAction = useCallback( + (event: { nativeEvent: { event: string } }) => { + const id = event.nativeEvent.event; + if (id === "refresh") { + void refreshSelectedSection(); + } else if (id.startsWith("section:")) { + selectSection(id.slice("section:".length)); + } + }, + [refreshSelectedSection, selectSection], + ); const handleRetryEnvironment = useCallback(() => { void retryEnvironment(environmentId); }, [environmentId, retryEnvironment]); @@ -511,6 +563,13 @@ export function ReviewSheet(props: ReviewSheetProps) { threadId: String(threadId), }); }, [environmentId, navigation, threadId]); + const androidHeaderSubtitle = [ + selectedSection?.title, + headerDiffSummary.additions, + headerDiffSummary.deletions, + ] + .filter((part): part is string => Boolean(part)) + .join(" · "); // The changed-files navigator lives in the workspace inspector column — // the single right-hand pane per route — instead of an in-screen panel. @@ -554,18 +613,45 @@ export function ReviewSheet(props: ReviewSheetProps) { return ( <> 0 ? headerSubtitle : undefined, - }} + options={ + isAndroid + ? // Android draws its own in-flow header (AndroidScreenHeader below). + { headerShown: false } + : { + // Static header config lives in Stack.tsx (SOLID_HEADER_OPTIONS — the native + // diff scrolls internally, nothing for glass to sample). Only dynamic values + // here. + headerTintColor: headerIcon, + headerTitle: headerTitleText, + title: headerTitleText, + unstable_headerSubtitle: + Platform.OS === "ios" && headerSubtitle.length > 0 ? headerSubtitle : undefined, + } + } /> + {isAndroid ? ( + + + + ) : null + } + /> + ) : null} + - {showSectionToolbar || panes.supportsAuxiliaryPane || gitMenuAvailable ? ( + {!isAndroid && (showSectionToolbar || panes.supportsAuxiliaryPane || gitMenuAvailable) ? ( {panes.supportsAuxiliaryPane ? ( {showConnectionNotice ? ( - + {listHeader} {!selectedSection ? ( diff --git a/apps/mobile/src/features/review/reviewDiffRendering.tsx b/apps/mobile/src/features/review/reviewDiffRendering.tsx index d7f5c5ff8a8..d00cf2be4da 100644 --- a/apps/mobile/src/features/review/reviewDiffRendering.tsx +++ b/apps/mobile/src/features/review/reviewDiffRendering.tsx @@ -48,8 +48,8 @@ export function ReviewChangeBar(props: { {Array.from({ length: Math.ceil(height / 2) }, (_, index) => ( - - + + ))} diff --git a/apps/mobile/src/features/settings/SettingsAppearanceRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsAppearanceRouteScreen.tsx index 46b7d210236..21bcad35e20 100644 --- a/apps/mobile/src/features/settings/SettingsAppearanceRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsAppearanceRouteScreen.tsx @@ -13,12 +13,10 @@ export function SettingsAppearanceRouteScreen() { diff --git a/apps/mobile/src/features/settings/SettingsClientStorageRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsClientStorageRouteScreen.tsx new file mode 100644 index 00000000000..9e18d4675fb --- /dev/null +++ b/apps/mobile/src/features/settings/SettingsClientStorageRouteScreen.tsx @@ -0,0 +1,215 @@ +import { useAtomSet, useAtomValue } from "@effect/atom-react"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { SymbolView } from "expo-symbols"; +import { useMemo } from "react"; +import { ActivityIndicator, Alert, Pressable, ScrollView, 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 { + clearClientCacheAtom, + clientCacheSummaryAtom, + type EnvironmentClientCacheSummary, +} from "../../state/client-cache-state"; +import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; +import { SettingsSection } from "./components/SettingsSection"; + +export function SettingsClientStorageRouteScreen() { + const insets = useSafeAreaInsets(); + const iconColor = useThemeColor("--color-icon"); + const dangerForegroundColor = useThemeColor("--color-danger-foreground"); + const summaryResult = useAtomValue(clientCacheSummaryAtom); + const clearResult = useAtomValue(clearClientCacheAtom); + const clearCache = useAtomSet(clearClientCacheAtom); + const { savedConnectionsById } = useSavedRemoteConnections(); + const isClearing = clearResult.waiting; + const summary = AsyncResult.isSuccess(summaryResult) ? summaryResult.value : null; + const environmentSummaries = useMemo( + () => + [...(summary?.environments ?? [])].sort((left, right) => { + const leftLabel = savedConnectionsById[left.environmentId]?.environmentLabel ?? ""; + const rightLabel = savedConnectionsById[right.environmentId]?.environmentLabel ?? ""; + return leftLabel.localeCompare(rightLabel); + }), + [savedConnectionsById, summary?.environments], + ); + + const confirmClearEnvironment = (environment: EnvironmentClientCacheSummary) => { + const label = + savedConnectionsById[environment.environmentId]?.environmentLabel ?? + environment.environmentId; + Alert.alert( + `Clear cache for ${label}?`, + "This removes offline threads, server metadata, and cached branches for this environment. The saved connection and credentials stay intact.", + [ + { text: "Cancel", style: "cancel" }, + { + text: "Clear Cache", + style: "destructive", + onPress: () => + clearCache({ type: "environment", environmentId: environment.environmentId }), + }, + ], + ); + }; + + const confirmClearAll = () => { + Alert.alert( + "Clear all client caches?", + "This removes offline data for every environment. Connections, credentials, account data, and app preferences stay intact.", + [ + { text: "Cancel", style: "cancel" }, + { + text: "Clear All Caches", + style: "destructive", + onPress: () => clearCache({ type: "all" }), + }, + ], + ); + }; + + return ( + + + + {AsyncResult.isFailure(summaryResult) ? ( + + + Storage unavailable + + Restart the app and try again. + + + ) : !summary ? ( + + + + Inspecting cached data… + + + ) : environmentSummaries.length > 0 ? ( + environmentSummaries.map((environment, index) => ( + confirmClearEnvironment(environment)} + /> + )) + ) : ( + + + No cached data + + Offline cache records will appear here after environments are used. + + + )} + + + + + + + + {summary ? `Clear ${formatBytes(summary.payloadBytes)}` : "Clear caches"} + + {isClearing ? : null} + + + + Clearing caches never removes environment connections, credentials, account data, or + appearance preferences. + + {AsyncResult.isFailure(summaryResult) || AsyncResult.isFailure(clearResult) ? ( + + Client storage is temporarily unavailable. Try again after restarting the app. + + ) : null} + + + + ); +} + +function CacheEnvironmentRow(props: { + readonly environment: EnvironmentClientCacheSummary; + readonly environmentLabel: string; + readonly disabled: boolean; + readonly first: boolean; + readonly onClear: () => void; +}) { + const iconColor = useThemeColor("--color-icon"); + return ( + + + + {props.environmentLabel} + + + + Clear {formatBytes(props.environment.payloadBytes)} + + + + ); +} + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(bytes < 10 * 1024 ? 1 : 0)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(bytes < 10 * 1024 * 1024 ? 1 : 0)} MB`; +} diff --git a/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx index d5eecb5016f..ccf049e0c21 100644 --- a/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx @@ -1,12 +1,13 @@ -import { NativeHeaderToolbar } from "../../native/StackHeader"; +import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; import { useNavigation } from "@react-navigation/native"; -import { SymbolView } from "expo-symbols"; +import { SymbolView } from "../../components/AppSymbol"; import type { EnvironmentId } from "@t3tools/contracts"; import { useCallback, useState } from "react"; -import { ScrollView, View } from "react-native"; +import { Platform, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AppText as Text } from "../../components/AppText"; +import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; import { hasCloudPublicConfig } from "../cloud/publicConfig"; import { CloudEnvironmentRows } from "../connection/CloudEnvironmentRows"; import { ConnectionEnvironmentRow } from "../connection/ConnectionEnvironmentRow"; @@ -39,22 +40,42 @@ export function SettingsEnvironmentsRouteScreen() { return ( - - navigation.navigate("SettingsSheet", { screen: "SettingsEnvironmentNew" })} - separateBackground - tintColor={headerIconColor} - /> - + {Platform.OS === "android" ? ( + <> + {/* Android renders its own in-screen header instead of the native bar. */} + + navigation.goBack()} + actions={[ + { + accessibilityLabel: "Add environment", + icon: "plus", + onPress: () => + navigation.navigate("SettingsSheet", { screen: "SettingsEnvironmentNew" }), + }, + ]} + /> + + ) : ( + + + navigation.navigate("SettingsSheet", { screen: "SettingsEnvironmentNew" }) + } + separateBackground + tintColor={headerIconColor} + /> + + )} {hasLocalEnvironments ? ( @@ -63,10 +84,7 @@ export function SettingsEnvironmentsRouteScreen() { - [ - withNativeGlassHeaderItem({ - accessibilityLabel: "Close settings", - icon: { name: "xmark", type: "sfSymbol" } as const, - identifier: "settings-close", - label: "", - onPress: () => navigation.goBack(), - type: "button", - }), - ] - : undefined, - }} - /> + {Platform.OS === "android" ? ( + <> + {/* Android renders its own in-screen header instead of the native bar. */} + + navigation.goBack()} /> + + ) : ( + [ + withNativeGlassHeaderItem({ + accessibilityLabel: "Close settings", + icon: { name: "xmark", type: "sfSymbol" } as const, + identifier: "settings-close", + label: "", + onPress: () => navigation.goBack(), + type: "button", + }), + ] + : undefined, + }} + /> + )} {hasCloudPublicConfig() ? : } ); @@ -92,12 +104,10 @@ function LocalSettingsRouteScreen() { @@ -122,6 +132,9 @@ function LocalSettingsRouteScreen() { } function ConfiguredSettingsRouteScreen() { + const preferencesResult = useAtomValue(mobilePreferencesAtom); + const savePreferences = useAtomSet(updateMobilePreferencesAtom); + const agentAwarenessPushAvailable = supportsAgentAwarenessPush(); const insets = useSafeAreaInsets(); const navigation = useNavigation(); const { expand: expandClerkSheet } = useClerkSettingsSheetDetent(); @@ -131,6 +144,9 @@ function ConfiguredSettingsRouteScreen() { const [notificationStatus, setNotificationStatus] = useState("checking"); const [liveActivityStatus, setLiveActivityStatus] = useState("checking"); const deviceRegistered = useDeviceRegistered(); + const liveActivitiesPreferenceEnabled = AsyncResult.isSuccess(preferencesResult) + ? preferencesResult.value.liveActivitiesEnabled !== false + : true; const connections = useMemo(() => Object.values(savedConnectionsById), [savedConnectionsById]); const environmentCount = connections.length; @@ -167,16 +183,19 @@ function ConfiguredSettingsRouteScreen() { setLiveActivityStatus("signed-out"); return; } - void (async () => { - const result = await settlePromise(() => loadPreferences()); - if (result._tag === "Failure") { - reportAtomCommandResult(result, { label: "live activity preference load" }); + if (!AsyncResult.isSuccess(preferencesResult)) { + if (AsyncResult.isFailure(preferencesResult)) { + reportAtomCommandResult(preferencesResult, { label: "live activity preference load" }); setLiveActivityStatus("enabled"); - return; + } else { + setLiveActivityStatus("checking"); } - setLiveActivityStatus(result.value.liveActivitiesEnabled === false ? "disabled" : "enabled"); - })(); - }, [isLoaded, isSignedIn]); + return; + } + setLiveActivityStatus( + preferencesResult.value.liveActivitiesEnabled === false ? "disabled" : "enabled", + ); + }, [isLoaded, isSignedIn, preferencesResult]); const requestNotifications = useCallback(async () => { const result = await settleAsyncResult(() => @@ -279,6 +298,7 @@ function ConfiguredSettingsRouteScreen() { runtime.runPromiseExit( setLiveActivityUpdatesEnabled({ enabled: true, + previousEnabled: liveActivitiesPreferenceEnabled, clerkToken: tokenResult.value, connections, }), @@ -296,6 +316,7 @@ function ConfiguredSettingsRouteScreen() { return; } + savePreferences({ liveActivitiesEnabled: true }); refreshManagedRelayEnvironments(); setLiveActivityStatus("enabled"); // The environment link can succeed while this device's own registration @@ -314,7 +335,15 @@ function ConfiguredSettingsRouteScreen() { "This device could not be registered with T3 Connect, so Live Activities won't appear yet. They'll start once registration succeeds.", ); } - }, [connections, environmentCount, getToken, isSignedIn, promptSignIn]); + }, [ + connections, + environmentCount, + getToken, + isSignedIn, + liveActivitiesPreferenceEnabled, + promptSignIn, + savePreferences, + ]); const handleDeviceNotificationsChange = useCallback( (enabled: boolean) => { @@ -358,17 +387,20 @@ function ConfiguredSettingsRouteScreen() { runtime.runPromiseExit( setLiveActivityUpdatesEnabled({ enabled: false, + previousEnabled: liveActivitiesPreferenceEnabled, clerkToken: token, connections, }), ), ); if (updateResult._tag === "Failure") { + setLiveActivityStatus("enabled"); reportAtomCommandResult(updateResult, { label: "live activity disable", }); return; } + savePreferences({ liveActivitiesEnabled: false }); refreshManagedRelayEnvironments(); })(); return; @@ -381,7 +413,15 @@ function ConfiguredSettingsRouteScreen() { void linkEnvironments(); }, - [connections, getToken, isSignedIn, linkEnvironments, promptSignIn], + [ + connections, + getToken, + isSignedIn, + linkEnvironments, + liveActivitiesPreferenceEnabled, + promptSignIn, + savePreferences, + ], ); const openAccount = useCallback(() => { @@ -399,12 +439,10 @@ function ConfiguredSettingsRouteScreen() { @@ -431,22 +469,32 @@ function ConfiguredSettingsRouteScreen() { + (() => - resolveAppearancePreferences(null), + const preferencesResult = useAtomValue(mobilePreferencesAtom); + const savePreferences = useAtomSet(updateMobilePreferencesAtom); + const preferences = useMemo( + () => + resolveAppearancePreferences( + AsyncResult.isSuccess(preferencesResult) ? preferencesResult.value : null, + ), + [preferencesResult], ); - const [isReady, setIsReady] = useState(false); - - useEffect(() => { - let cancelled = false; - - void loadPreferences() - .then((stored) => { - if (cancelled) { - return; - } - - const resolved = resolveAppearancePreferences(stored); - setPreferences(resolved); - cacheTerminalFontSize(resolveAppearance(resolved).terminalFontSize); - setIsReady(true); - }) - .catch(() => { - if (cancelled) { - return; - } - - setIsReady(true); - }); - - return () => { - cancelled = true; - }; - }, []); + const isReady = AsyncResult.isSuccess(preferencesResult) && !preferencesResult.waiting; useEffect(() => { applyTextScaleVariables(preferences.baseFontSize); - }, [preferences.baseFontSize]); - - const updatePreferences = useCallback((patch: Partial) => { - setPreferences((current) => { - const next = resolveAppearancePreferences({ ...current, ...patch }); - cacheTerminalFontSize(resolveAppearance(next).terminalFontSize); - void savePreferencesPatch({ - baseFontSize: next.baseFontSize, - terminalFontSize: next.terminalFontSize, - codeFontSize: next.codeFontSize, - codeWordBreak: next.codeWordBreak, - }).catch(() => undefined); - return next; - }); - }, []); + cacheTerminalFontSize(resolveAppearance(preferences).terminalFontSize); + }, [preferences]); + + const updatePreferences = useCallback( + (patch: Partial) => { + savePreferences(patch); + }, + [savePreferences], + ); const setBaseFontSize = useCallback( (value: number) => { @@ -150,7 +118,7 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN } export function useAppearancePreferences(): AppearancePreferencesContextValue { - const context = useContext(AppearancePreferencesContext); + const context = use(AppearancePreferencesContext); if (!context) { throw new Error("useAppearancePreferences must be used within AppearancePreferencesProvider"); } diff --git a/apps/mobile/src/features/settings/appearance/components/FontSizeSliderRow.tsx b/apps/mobile/src/features/settings/appearance/components/FontSizeSliderRow.tsx index 5166ed11db7..c0e46d248e0 100644 --- a/apps/mobile/src/features/settings/appearance/components/FontSizeSliderRow.tsx +++ b/apps/mobile/src/features/settings/appearance/components/FontSizeSliderRow.tsx @@ -1,5 +1,5 @@ import * as Haptics from "expo-haptics"; -import { SymbolView } from "expo-symbols"; +import { SymbolView } from "../../../../components/AppSymbol"; import { useCallback, useEffect, useMemo, useRef } from "react"; import { View, type AccessibilityActionEvent } from "react-native"; import { Gesture, GestureDetector } from "react-native-gesture-handler"; @@ -136,7 +136,7 @@ export function FontSizeSliderRow(props: { }; return ( - + + + + diff --git a/apps/mobile/src/features/settings/components/SettingsSection.tsx b/apps/mobile/src/features/settings/components/SettingsSection.tsx index 7b4f0379161..9c87561a8ee 100644 --- a/apps/mobile/src/features/settings/components/SettingsSection.tsx +++ b/apps/mobile/src/features/settings/components/SettingsSection.tsx @@ -3,13 +3,21 @@ import { View } from "react-native"; import { AppText as Text } from "../../../components/AppText"; -export function SettingsSection(props: { readonly title: string; readonly children: ReactNode }) { +export function SettingsSection(props: { + readonly title: string; + readonly children: ReactNode; + /** Force the grouped card background; Android otherwise lists options flat. */ + readonly card?: boolean; +}) { return ( {props.title} {props.children} diff --git a/apps/mobile/src/features/settings/components/SettingsSwitchRow.tsx b/apps/mobile/src/features/settings/components/SettingsSwitchRow.tsx index 8620166eff2..38d707e231d 100644 --- a/apps/mobile/src/features/settings/components/SettingsSwitchRow.tsx +++ b/apps/mobile/src/features/settings/components/SettingsSwitchRow.tsx @@ -1,7 +1,7 @@ -import { SymbolView } from "expo-symbols"; import type { ComponentProps } from "react"; import { Switch, View } from "react-native"; +import { SymbolView } from "../../../components/AppSymbol"; import { AppText as Text } from "../../../components/AppText"; import { useThemeColor } from "../../../lib/useThemeColor"; @@ -20,8 +20,11 @@ export function SettingsSwitchRow(props: { return ( {props.label} diff --git a/apps/mobile/src/features/settings/components/settings-sheet-targets.ts b/apps/mobile/src/features/settings/components/settings-sheet-targets.ts index 7de54bceee5..677dd5e4ed7 100644 --- a/apps/mobile/src/features/settings/components/settings-sheet-targets.ts +++ b/apps/mobile/src/features/settings/components/settings-sheet-targets.ts @@ -1 +1,5 @@ -export type SettingsSheetTarget = "SettingsEnvironments" | "SettingsArchive" | "SettingsAppearance"; +export type SettingsSheetTarget = + | "SettingsEnvironments" + | "SettingsArchive" + | "SettingsAppearance" + | "SettingsClientStorage"; diff --git a/apps/mobile/src/features/shortcuts/appShortcuts.test.ts b/apps/mobile/src/features/shortcuts/appShortcuts.test.ts new file mode 100644 index 00000000000..17b63bb78fd --- /dev/null +++ b/apps/mobile/src/features/shortcuts/appShortcuts.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it } from "vite-plus/test"; +import type { NavigationState } from "@react-navigation/native"; + +import type { RecentThreadShortcut } from "../../persistence/imperative"; +import { + activeThreadRef, + buildShortcutActions, + MAX_RECENT_THREAD_SHORTCUTS, + NEW_TASK_SHORTCUT_ID, + shortcutHref, + withRecentThreadShortcut, +} from "./appShortcuts"; + +function navState(route: { name: string; params?: unknown }): NavigationState { + return { index: 0, routes: [route] } as unknown as NavigationState; +} + +function thread(suffix: string, title = `Thread ${suffix}`): RecentThreadShortcut { + return { environmentId: `env-${suffix}`, threadId: `thread-${suffix}`, title }; +} + +describe("withRecentThreadShortcut", () => { + it("prepends a newly opened thread", () => { + const next = withRecentThreadShortcut([thread("a")], thread("b")); + expect(next.map((entry) => entry.threadId)).toEqual(["thread-b", "thread-a"]); + }); + + it("moves a reopened thread to the front without duplicating it", () => { + const next = withRecentThreadShortcut([thread("a"), thread("b")], thread("b")); + expect(next.map((entry) => entry.threadId)).toEqual(["thread-b", "thread-a"]); + }); + + it("caps the list at the shortcut budget", () => { + const current = [thread("a"), thread("b"), thread("c")]; + const next = withRecentThreadShortcut(current, thread("d")); + expect(next).toHaveLength(MAX_RECENT_THREAD_SHORTCUTS); + expect(next[0]?.threadId).toBe("thread-d"); + expect(next.map((entry) => entry.threadId)).not.toContain("thread-c"); + }); + + it("returns the same array when the thread already leads with the same title", () => { + const current = [thread("a"), thread("b")]; + expect(withRecentThreadShortcut(current, thread("a"))).toBe(current); + }); + + it("keeps the known title when a reopen records an empty one", () => { + const current = [thread("a", "Fix the build")]; + const next = withRecentThreadShortcut(current, thread("a", "")); + expect(next).toBe(current); + }); + + it("updates the title once the shell provides one", () => { + const current = [thread("a", "")]; + const next = withRecentThreadShortcut(current, thread("a", "Fix the build")); + expect(next[0]?.title).toBe("Fix the build"); + expect(next).toHaveLength(1); + }); +}); + +describe("buildShortcutActions", () => { + it("leads with the static new-task action", () => { + const actions = buildShortcutActions([thread("a")]); + expect(actions[0]?.id).toBe(NEW_TASK_SHORTCUT_ID); + expect(actions[0]?.params?.href).toBe("/new"); + expect(actions).toHaveLength(2); + }); + + it("deep-links threads with encoded route params", () => { + const actions = buildShortcutActions([ + { environmentId: "env 1", threadId: "thread/2", title: "Spaced out" }, + ]); + expect(actions[1]?.params?.href).toBe("/threads/env%201/thread%2F2"); + expect(actions[1]?.title).toBe("Spaced out"); + }); + + it("falls back to a generic label for missing titles", () => { + const actions = buildShortcutActions([thread("a", " ")]); + expect(actions[1]?.title).toBe("Thread"); + }); +}); + +describe("shortcutHref", () => { + it("accepts exactly the destinations shortcuts can produce", () => { + expect(shortcutHref({ id: "x", title: "x", params: { href: "/new" } })).toBe("/new"); + expect(shortcutHref({ id: "x", title: "x", params: { href: "/threads/env-1/thread-2" } })).toBe( + "/threads/env-1/thread-2", + ); + expect( + shortcutHref({ id: "x", title: "x", params: { href: "/threads/env%201/thread%2F2" } }), + ).toBe("/threads/env%201/thread%2F2"); + }); + + it("rejects everything else", () => { + for (const href of [ + "https://evil.example", + "//evil.example", + "/settings", + "/threads/only-one-segment", + "/threads/a/b/c", + "/threads//x", + "/threads/a/b?x=1", + "/threads/a/b#frag", + "/new/extra", + ]) { + expect(shortcutHref({ id: "x", title: "x", params: { href } })).toBe(null); + } + expect(shortcutHref({ id: "x", title: "x", params: { href: 3 } })).toBe(null); + expect(shortcutHref({ id: "x", title: "x" })).toBe(null); + }); +}); + +describe("activeThreadRef", () => { + it("resolves the active Thread route's params", () => { + const ref = activeThreadRef( + navState({ name: "Thread", params: { environmentId: "env-1", threadId: "thread-2" } }), + ); + expect(ref).toEqual({ environmentId: "env-1", threadId: "thread-2" }); + }); + + it("takes the first value of array params", () => { + const ref = activeThreadRef( + navState({ name: "Thread", params: { environmentId: ["env-1"], threadId: ["thread-2"] } }), + ); + expect(ref).toEqual({ environmentId: "env-1", threadId: "thread-2" }); + }); + + it("returns null for non-thread routes", () => { + expect(activeThreadRef(navState({ name: "Home" }))).toBe(null); + }); + + it("returns null instead of throwing for malformed params", () => { + const malformed: unknown[] = [ + undefined, + {}, + { environmentId: "env-1" }, + { environmentId: "", threadId: "thread-2" }, + { environmentId: " ", threadId: "thread-2" }, + { environmentId: "env-1", threadId: " " }, + { environmentId: 42, threadId: "thread-2" }, + { environmentId: { nested: true }, threadId: "thread-2" }, + { environmentId: [], threadId: [] }, + ]; + for (const params of malformed) { + expect(activeThreadRef(navState({ name: "Thread", params }))).toBe(null); + } + }); +}); + +describe("launcher shortcut ids", () => { + it("cannot collide across different env/thread pairs", () => { + const a = buildShortcutActions([{ environmentId: "a-b", threadId: "c", title: "x" }]); + const b = buildShortcutActions([{ environmentId: "a", threadId: "b-c", title: "x" }]); + expect(a[1]?.id).not.toBe(b[1]?.id); + }); +}); diff --git a/apps/mobile/src/features/shortcuts/appShortcuts.ts b/apps/mobile/src/features/shortcuts/appShortcuts.ts new file mode 100644 index 00000000000..49e9d0999af --- /dev/null +++ b/apps/mobile/src/features/shortcuts/appShortcuts.ts @@ -0,0 +1,137 @@ +import type { Action } from "expo-quick-actions"; +import type { NavigationState } from "@react-navigation/native"; +import { EnvironmentId, ThreadId, type ScopedThreadRef } from "@t3tools/contracts"; + +import type { RecentThreadShortcut } from "../../persistence/imperative"; + +// Launchers cap visible shortcuts around 4; one slot is the static +// "New task" entry, the rest rotate through recently opened threads. +export const MAX_RECENT_THREAD_SHORTCUTS = 3; + +export const NEW_TASK_SHORTCUT_ID = "new-task"; +const NEW_TASK_SHORTCUT_HREF = "/new"; + +// Adaptive icon resource generated by the expo-quick-actions config plugin +// (androidIcons key in app.config.ts). +const SHORTCUT_ICON = "shortcut_icon"; + +// Matches only the thread deep-link shape shortcuts are allowed to carry: +// exactly two non-empty segments, no nested paths, queries, or fragments. +const THREAD_SHORTCUT_HREF_PATTERN = /^\/threads\/[^/?#]+\/[^/?#]+$/; + +function threadShortcutHref(thread: RecentThreadShortcut): string { + return `/threads/${encodeURIComponent(thread.environmentId)}/${encodeURIComponent(thread.threadId)}`; +} + +function firstRouteParam(value: string | string[] | undefined): string | null { + if (Array.isArray(value)) { + return value[0] ?? null; + } + + return value ?? null; +} + +/** + * Thread ref of the navigation state's ACTIVE route, or null. Never throws: + * route params can arrive malformed from external deep links or restored + * state, the branded id constructors throw on values failing their schema, + * and this runs during render of the root stack layout — an uncaught throw + * here would take down the whole navigation tree. + */ +export function activeThreadRef(state: NavigationState): ScopedThreadRef | null { + const route = state.routes[state.index]; + if (route?.name !== "Thread") { + return null; + } + + try { + const params = route.params as + | { + readonly environmentId?: string | string[]; + readonly threadId?: string | string[]; + } + | undefined; + const environmentId = firstRouteParam(params?.environmentId)?.trim(); + const threadId = firstRouteParam(params?.threadId)?.trim(); + if (!environmentId || !threadId) { + return null; + } + + return { + environmentId: EnvironmentId.make(environmentId), + threadId: ThreadId.make(threadId), + }; + } catch { + return null; + } +} + +function threadShortcutLabel(thread: RecentThreadShortcut): string { + const title = thread.title.trim(); + return title.length > 0 ? title : "Thread"; +} + +/** + * In-app navigation path carried by a shortcut, or null for anything + * malformed — shortcuts persist in the launcher across app updates, so this + * allowlists the exact destinations shortcuts can produce rather than + * trusting any path-shaped string (`//host`, arbitrary routes, stale + * persisted junk all navigate nowhere). + */ +export function shortcutHref(action: Action): string | null { + const href = action.params?.href; + if (typeof href !== "string") { + return null; + } + + return href === NEW_TASK_SHORTCUT_HREF || THREAD_SHORTCUT_HREF_PATTERN.test(href) ? href : null; +} + +/** + * Records an opened thread as the most recent shortcut entry. Returns the + * SAME array when nothing changed so effect chains keyed on identity skip + * redundant launcher/storage writes. An empty title never clobbers a known + * one — shells load after navigation, so the first record of a thread often + * predates its title. + */ +export function withRecentThreadShortcut( + current: ReadonlyArray, + opened: RecentThreadShortcut, +): ReadonlyArray { + const existing = current.find( + (thread) => + thread.environmentId === opened.environmentId && thread.threadId === opened.threadId, + ); + const title = opened.title.trim().length > 0 ? opened.title : (existing?.title ?? opened.title); + if (current[0] === existing && existing !== undefined && existing.title === title) { + return current; + } + + return [ + { environmentId: opened.environmentId, threadId: opened.threadId, title }, + ...current.filter((thread) => thread !== existing), + ].slice(0, MAX_RECENT_THREAD_SHORTCUTS); +} + +/** Full launcher shortcut list: static "New task" first, then recents. */ +export function buildShortcutActions(recents: ReadonlyArray): Action[] { + return [ + { + id: NEW_TASK_SHORTCUT_ID, + title: "New task", + icon: SHORTCUT_ICON, + params: { href: NEW_TASK_SHORTCUT_HREF }, + }, + ...recents.slice(0, MAX_RECENT_THREAD_SHORTCUTS).map( + (thread): Action => ({ + // The encoded href doubles as the launcher id: URI-encoding makes the + // env/thread join unambiguous (a plain `-` join lets different pairs + // collide and overwrite each other's launcher slots). + id: `thread:${threadShortcutHref(thread)}`, + title: threadShortcutLabel(thread), + icon: SHORTCUT_ICON, + params: { href: threadShortcutHref(thread) }, + }), + ), + ]; +} diff --git a/apps/mobile/src/features/shortcuts/useAppShortcuts.ts b/apps/mobile/src/features/shortcuts/useAppShortcuts.ts new file mode 100644 index 00000000000..3070a1d54e0 --- /dev/null +++ b/apps/mobile/src/features/shortcuts/useAppShortcuts.ts @@ -0,0 +1,136 @@ +import * as QuickActions from "expo-quick-actions"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { Platform } from "react-native"; +import { useLinkTo, type NavigationState } from "@react-navigation/native"; + +import { + loadRecentThreadShortcuts, + saveRecentThreadShortcuts, + type RecentThreadShortcut, +} from "../../persistence/imperative"; +import { useThreadShell } from "../../state/entities"; +import { + activeThreadRef, + buildShortcutActions, + shortcutHref, + withRecentThreadShortcut, +} from "./appShortcuts"; + +/** + * Owns the launcher app shortcuts (Android long-press menu): keeps the + * static "New task" entry plus the recently opened threads in sync, and + * routes shortcut taps — cold start included — to their in-app screens. + * Mounted once in the root stack layout. + */ +export function useAppShortcuts(state: NavigationState): void { + useShortcutNavigation(); + useRecentThreadShortcutSync(state); +} + +function useShortcutNavigation(): void { + const linkTo = useLinkTo(); + const handledInitialAction = useRef(false); + + useEffect(() => { + // Cold start: the tapped shortcut arrives as the launch action, before + // any listener can fire. Navigating from here pushes the target over the + // initial Home route, so back returns home instead of exiting the app. + if (!handledInitialAction.current) { + handledInitialAction.current = true; + const initialHref = QuickActions.initial ? shortcutHref(QuickActions.initial) : null; + if (initialHref !== null) { + linkTo(initialHref); + } + } + + const subscription = QuickActions.addListener((action) => { + const href = shortcutHref(action); + if (href !== null) { + linkTo(href); + } + }); + return () => subscription.remove(); + }, [linkTo]); +} + +function useRecentThreadShortcutSync(state: NavigationState): void { + const threadRef = useMemo(() => activeThreadRef(state), [state]); + const threadShell = useThreadShell(threadRef); + // null until the persisted list loads; recording waits on it so the first + // thread opened after a cold start cannot clobber older entries. + const [recents, setRecents] = useState | null>(null); + // Gates storage writes: a failed load falls back to an empty in-memory + // list (so the launcher still gets the "New task" item), but persisting + // that fallback would erase valid history over a transient read error. + // Real thread opens flip this on — by then the list is the new truth. + const persistableRef = useRef(false); + // Saves are fire-and-forget; chaining them keeps an older list from + // finishing after (and overwriting) a newer one. + const saveQueueRef = useRef>(Promise.resolve()); + + useEffect(() => { + if (Platform.OS !== "android") { + return; + } + + let cancelled = false; + void loadRecentThreadShortcuts() + .then((threads) => { + if (!cancelled) { + persistableRef.current = true; + setRecents(threads); + } + }) + .catch((error) => { + console.warn("[app-shortcuts] failed to load recent threads", error); + if (!cancelled) { + setRecents([]); + } + }); + return () => { + cancelled = true; + }; + }, []); + + const loaded = recents !== null; + const environmentId = threadRef?.environmentId ?? null; + const threadId = threadRef?.threadId ?? null; + const title = threadShell?.title ?? ""; + useEffect(() => { + if (!loaded || environmentId === null || threadId === null) { + return; + } + + // withRecentThreadShortcut returns the same array when nothing changed, + // so React bails out and the persist effect below does not re-fire. + setRecents((current) => { + if (current === null) { + return current; + } + const next = withRecentThreadShortcut(current, { environmentId, threadId, title }); + if (next !== current) { + persistableRef.current = true; + } + return next; + }); + }, [loaded, environmentId, threadId, title]); + + useEffect(() => { + if (recents === null) { + return; + } + + if (persistableRef.current) { + saveQueueRef.current = saveQueueRef.current.then( + () => + saveRecentThreadShortcuts(recents).catch((error) => { + console.warn("[app-shortcuts] failed to persist recent threads", error); + }), + () => undefined, + ); + } + void QuickActions.setItems(buildShortcutActions(recents)).catch((error) => { + console.warn("[app-shortcuts] failed to update launcher shortcuts", error); + }); + }, [recents]); +} diff --git a/apps/mobile/src/features/terminal/NativeTerminalSurface.tsx b/apps/mobile/src/features/terminal/NativeTerminalSurface.tsx index ad174a2502d..68d214193bd 100644 --- a/apps/mobile/src/features/terminal/NativeTerminalSurface.tsx +++ b/apps/mobile/src/features/terminal/NativeTerminalSurface.tsx @@ -82,9 +82,9 @@ const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: Ter return ( - + {statusLabel} props.onInput("\u0003")} > - + Ctrl-C diff --git a/apps/mobile/src/features/terminal/ThreadTerminalPanel.tsx b/apps/mobile/src/features/terminal/ThreadTerminalPanel.tsx index 5d0b0547e6e..1c7adb6834d 100644 --- a/apps/mobile/src/features/terminal/ThreadTerminalPanel.tsx +++ b/apps/mobile/src/features/terminal/ThreadTerminalPanel.tsx @@ -1,5 +1,5 @@ import { DEFAULT_TERMINAL_ID, type EnvironmentId, type ThreadId } from "@t3tools/contracts"; -import { SymbolView } from "expo-symbols"; +import { SymbolView } from "../../components/AppSymbol"; import { memo, useCallback, useEffect, useMemo, useRef } from "react"; import { Pressable, View } from "react-native"; @@ -32,6 +32,8 @@ export const ThreadTerminalPanel = memo(function ThreadTerminalPanel( ) { const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write"); const resizeTerminal = useAtomCommand(terminalEnvironment.resize, "terminal resize"); + const closeTerminal = useAtomCommand(terminalEnvironment.close, "terminal close"); + const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open"); const nativeTerminalAvailable = hasNativeTerminalSurface(); const terminalId = DEFAULT_TERMINAL_ID; const lastGridSizeRef = useRef({ @@ -63,6 +65,94 @@ export const ThreadTerminalPanel = memo(function ThreadTerminalPanel( const terminalKey = `${props.environmentId}:${props.threadId}:${terminalId}`; const isRunning = terminal.status === "running" || terminal.status === "starting"; + // Close the session and dismiss the panel when the process ends while + // attached (e.g. typing `exit`), mirroring the web drawer's + // onSessionExited flow. + const runningTerminalKeyRef = useRef(null); + const reopenedStaleTerminalKeyRef = useRef(null); + + // Attach subscriptions are cached with an idle TTL; reopening the panel + // after its session ended reuses the stale stream without a new attach + // RPC. Issue an explicit open so the server respawns the session and its + // snapshot flows into the live subscription. + useEffect(() => { + if (isRunning) { + reopenedStaleTerminalKeyRef.current = null; + return; + } + if ( + attachInput === null || + (terminal.status !== "closed" && terminal.status !== "exited") || + terminal.version === 0 || + runningTerminalKeyRef.current === terminalKey || + reopenedStaleTerminalKeyRef.current === terminalKey + ) { + return; + } + reopenedStaleTerminalKeyRef.current = terminalKey; + void openTerminal({ + environmentId: props.environmentId, + input: { + threadId: props.threadId, + terminalId, + cwd: props.cwd, + worktreePath: props.worktreePath, + cols: lastGridSizeRef.current.cols, + rows: lastGridSizeRef.current.rows, + }, + }).then((result) => { + // Release the guard on failure so a later render can retry the respawn. + if (result._tag === "Failure" && reopenedStaleTerminalKeyRef.current === terminalKey) { + reopenedStaleTerminalKeyRef.current = null; + } + }); + }, [ + attachInput, + isRunning, + openTerminal, + props.cwd, + props.environmentId, + props.threadId, + props.worktreePath, + terminal.status, + terminal.version, + terminalId, + terminalKey, + ]); + + useEffect(() => { + // Forget both markers while hidden: if the process ends while the panel + // is unobserved (or was just auto-closed), the next show must take the + // stale-reopen path instead of treating it as a live exit or skipping + // the respawn. + if (attachInput === null) { + runningTerminalKeyRef.current = null; + reopenedStaleTerminalKeyRef.current = null; + return; + } + if (isRunning) { + runningTerminalKeyRef.current = terminalKey; + return; + } + // The web drawer treats both exited and closed as session end. + const sessionEnded = terminal.status === "exited" || terminal.status === "closed"; + if (!sessionEnded || runningTerminalKeyRef.current !== terminalKey) { + return; + } + runningTerminalKeyRef.current = null; + // Mark this key handled so the stale-attach effect doesn't respawn the + // session the user just ended. + reopenedStaleTerminalKeyRef.current = terminalKey; + void closeTerminal({ + environmentId: props.environmentId, + input: { + threadId: props.threadId, + terminalId, + }, + }); + props.onClose(); + }, [attachInput, closeTerminal, isRunning, props, terminal.status, terminalId, terminalKey]); + const sendResize = useCallback( (size: TerminalGridSize) => { void resizeTerminal({ diff --git a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx index 827411d5dc2..3b63a0975b2 100644 --- a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx +++ b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx @@ -1,6 +1,7 @@ import { DEFAULT_TERMINAL_ID, EnvironmentId, ThreadId } from "@t3tools/contracts"; import { type KnownTerminalSession } from "@t3tools/client-runtime/state/terminal"; -import { SymbolView } from "expo-symbols"; +import type { MenuAction } from "@react-native-menu/menu"; +import { SymbolView } from "../../components/AppSymbol"; import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -12,11 +13,13 @@ import { useKeyboardState, } from "react-native-keyboard-controller"; +import { AndroidHeaderIconButton, AndroidScreenHeader } from "../../components/AndroidScreenHeader"; import { ComposerToolbarButton, ComposerToolbarRow, ComposerToolbarScroller, } from "../../components/ComposerToolbarTrigger"; +import { ControlPillMenu } from "../../components/ControlPill"; import { EmptyState } from "../../components/EmptyState"; import { GlassSurface } from "../../components/GlassSurface"; import { LoadingScreen } from "../../components/LoadingScreen"; @@ -58,6 +61,7 @@ import { buildTerminalMenuSessions, getTerminalStatusLabel, nextOpenTerminalId, + previousLiveTerminalId, resolveTerminalSessionLabel, type TerminalMenuSession, } from "./terminalMenu"; @@ -158,6 +162,8 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write"); const resizeTerminal = useAtomCommand(terminalEnvironment.resize, "terminal resize"); const clearTerminal = useAtomCommand(terminalEnvironment.clear, "terminal clear"); + const closeTerminal = useAtomCommand(terminalEnvironment.close, "terminal close"); + const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open"); const retryEnvironment = useAtomCommand(environmentCatalog.retryNow, "environment retry"); const appearanceScheme = useColorScheme() === "light" ? "light" : "dark"; const { state: workspaceState } = useWorkspaceState(); @@ -348,6 +354,64 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) }); const isRunning = terminal.status === "running" || terminal.status === "starting"; + // When the process ends while this screen is attached (e.g. typing `exit`), + // close the session and leave the screen, mirroring the web drawer's + // onSessionExited flow. Only react to a running -> exited transition + // observed on this screen so already-exited sessions can still be opened + // (they restart on attach). + const runningTerminalKeyRef = useRef(null); + const reopenedStaleTerminalKeyRef = useRef(null); + const pendingExitNavigationRef = useRef(null); + + // Attach subscriptions are cached with an idle TTL, so revisiting a + // terminal whose session ended while unobserved reuses the stale stream + // without a new attach RPC — the server never respawns anything. Detect + // that (dead status with processed events, never seen running here) and + // issue an explicit open; its snapshot flows into the live subscription. + useEffect(() => { + if (isRunning) { + reopenedStaleTerminalKeyRef.current = null; + return; + } + if ( + terminalAttachInput === null || + !selectedThread || + (terminal.status !== "closed" && terminal.status !== "exited") || + terminal.version === 0 || + runningTerminalKeyRef.current === terminalKey || + reopenedStaleTerminalKeyRef.current === terminalKey + ) { + return; + } + reopenedStaleTerminalKeyRef.current = terminalKey; + void openTerminal({ + environmentId: selectedThread.environmentId, + input: { + threadId: selectedThread.id, + terminalId, + cwd: terminalAttachInput.cwd, + worktreePath: terminalAttachInput.worktreePath, + cols: terminalAttachInput.cols, + rows: terminalAttachInput.rows, + ...(terminalAttachInput.env ? { env: terminalAttachInput.env } : {}), + }, + }).then((result) => { + // Release the guard on failure so a later render can retry the respawn. + if (result._tag === "Failure" && reopenedStaleTerminalKeyRef.current === terminalKey) { + reopenedStaleTerminalKeyRef.current = null; + } + }); + }, [ + isRunning, + openTerminal, + selectedThread, + terminal.status, + terminal.version, + terminalAttachInput, + terminalId, + terminalKey, + ]); + useEffect(() => { terminalDebugLog("surface:props", { terminalKey, @@ -739,6 +803,105 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) [navigation, selectedThread, terminalId], ); + const navigateAwayAfterExit = useCallback(() => { + // With other shells still live, fall through to the previous one instead + // of dropping the user back on the thread. + const fallbackTerminalId = previousLiveTerminalId({ + sessions: terminalMenuSessions, + exitedTerminalId: terminalId, + }); + if (fallbackTerminalId !== null && selectedThread) { + navigation.dispatch( + StackActions.replace("ThreadTerminal", { + environmentId: String(selectedThread.environmentId), + threadId: String(selectedThread.id), + terminalId: fallbackTerminalId, + }), + ); + return; + } + if (navigation.canGoBack()) { + navigation.goBack(); + return; + } + // Deep-linked/root mounts have nothing to pop; land on the thread + // instead of stranding the user on a dead terminal. + if (selectedThread) { + navigation.dispatch( + StackActions.replace("Thread", { + environmentId: String(selectedThread.environmentId), + threadId: String(selectedThread.id), + }), + ); + } + }, [navigation, selectedThread, terminalId, terminalMenuSessions]); + + useEffect(() => { + // Detached (hidden surface or environment drop): forget the running + // marker so a reattach takes the stale-reopen path instead of misreading + // the dead snapshot as an exit observed on this screen. A pending exit + // navigation stays armed — it only clears once the session runs again — + // so refocusing a dead screen still leaves it. + if (terminalAttachInput === null) { + runningTerminalKeyRef.current = null; + return; + } + if (isRunning) { + runningTerminalKeyRef.current = terminalKey; + // The session came back (e.g. respawned elsewhere) before the user + // returned; a stale pending exit must not eject a live terminal. + pendingExitNavigationRef.current = null; + return; + } + // The web drawer treats both exited and closed as session end. + const sessionEnded = terminal.status === "exited" || terminal.status === "closed"; + if (!sessionEnded || runningTerminalKeyRef.current !== terminalKey) { + return; + } + runningTerminalKeyRef.current = null; + // Mark this key handled so the stale-attach effect doesn't respawn the + // session the user just ended. + reopenedStaleTerminalKeyRef.current = terminalKey; + if (selectedThread) { + void closeTerminal({ + environmentId: selectedThread.environmentId, + input: { + threadId: selectedThread.id, + terminalId, + }, + }); + } + if (navigation.isFocused()) { + navigateAwayAfterExit(); + return; + } + // An unfocused screen can't navigate; leave when the user returns so + // they never land on the dead session. + pendingExitNavigationRef.current = terminalKey; + }, [ + closeTerminal, + isRunning, + navigateAwayAfterExit, + navigation, + selectedThread, + terminal.status, + terminalAttachInput, + terminalId, + terminalKey, + ]); + + useEffect( + () => + navigation.addListener("focus", () => { + if (pendingExitNavigationRef.current !== terminalKey) { + return; + } + pendingExitNavigationRef.current = null; + navigateAwayAfterExit(); + }), + [navigateAwayAfterExit, navigation, terminalKey], + ); + const handleOpenNewTerminal = useCallback(() => { if (!selectedThread) { return; @@ -764,6 +927,69 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) setTerminalFontSize(stepTerminalFontSize(fontSize, 1)); }, [fontSize, setTerminalFontSize]); + // Android mirror of the iOS NativeHeaderToolbar terminal menu below: text + // size, session switching, and "Open new terminal", rendered through the + // token-styled anchored menu (the native header items are iOS-only). + const androidTerminalMenuActions = useMemo( + () => [ + { + id: "text-size", + title: "Text size", + subactions: [ + { + id: "font-decrease", + title: `A- ${Math.max(MIN_TERMINAL_FONT_SIZE, fontSize - TERMINAL_FONT_SIZE_STEP).toFixed(1)} pt`, + attributes: fontSize <= MIN_TERMINAL_FONT_SIZE ? { disabled: true } : undefined, + }, + { + id: "font-increase", + title: `A+ ${Math.min(MAX_TERMINAL_FONT_SIZE, fontSize + TERMINAL_FONT_SIZE_STEP).toFixed(1)} pt`, + attributes: fontSize >= MAX_TERMINAL_FONT_SIZE ? { disabled: true } : undefined, + }, + ], + }, + ...terminalMenuSessions.map( + (session): MenuAction => ({ + id: `terminal-session:${session.terminalId}`, + title: session.displayLabel, + subtitle: [getTerminalStatusLabel({ status: session.status }), basename(session.cwd)] + .filter(Boolean) + .join(" · "), + state: session.terminalId === terminalId ? ("on" as const) : undefined, + }), + ), + { + id: "terminal-new", + title: "Open new terminal", + image: "plus", + subtitle: `Start another shell in ${basename(selectedThreadProject?.workspaceRoot ?? null) ?? "this workspace"}`, + }, + ], + [fontSize, selectedThreadProject?.workspaceRoot, terminalId, terminalMenuSessions], + ); + + const handleAndroidTerminalMenuAction = useCallback( + (event: { nativeEvent: { event: string } }) => { + const id = event.nativeEvent.event; + if (id === "font-decrease") { + handleDecreaseFontSize(); + return; + } + if (id === "font-increase") { + handleIncreaseFontSize(); + return; + } + if (id === "terminal-new") { + handleOpenNewTerminal(); + return; + } + if (id.startsWith("terminal-session:")) { + handleSelectTerminal(id.slice("terminal-session:".length)); + } + }, + [handleDecreaseFontSize, handleIncreaseFontSize, handleOpenNewTerminal, handleSelectTerminal], + ); + const handleClearTerminal = useCallback(() => { if (!selectedThread) { return; @@ -860,12 +1086,53 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) // Static header config lives in Stack.tsx (SOLID_HEADER_OPTIONS — the pty // scrolls internally, nothing for glass to sample). Default title/subtitle // styling, like every other page. + // Android draws its own in-flow header (AndroidScreenHeader below); + // the native stack header stays iOS-only. + headerShown: Platform.OS !== "android", title: "Terminal", unstable_headerSubtitle: usesNativeHeaderGlass && headerSubtitle.length > 0 ? headerSubtitle : undefined, }} /> + {Platform.OS === "android" ? ( + navigation.goBack() : undefined} + trailing={ + <> + {layout.usesSplitView ? ( + + ) : null} + {isEnvironmentReady ? ( + + + + ) : null} + + } + /> + ) : null} + {layout.usesSplitView ? ( ) : null} - + {!isEnvironmentReady ? ( ) : ( <> - + diff --git a/apps/mobile/src/features/terminal/terminalMenu.test.ts b/apps/mobile/src/features/terminal/terminalMenu.test.ts index 48c87e18dd4..1f176263ca5 100644 --- a/apps/mobile/src/features/terminal/terminalMenu.test.ts +++ b/apps/mobile/src/features/terminal/terminalMenu.test.ts @@ -9,9 +9,25 @@ import { buildTerminalMenuSessions, nextOpenTerminalId, nextTerminalId, + previousLiveTerminalId, resolveProjectScriptTerminalId, + type TerminalMenuSession, } from "./terminalMenu"; +function makeMenuSession(input: { + readonly terminalId: string; + readonly status: TerminalMenuSession["status"]; +}): TerminalMenuSession { + return { + terminalId: input.terminalId, + cwd: null, + status: input.status, + hasRunningSubprocess: false, + displayLabel: getTerminalLabel(input.terminalId), + updatedAt: null, + }; +} + function makeKnownSession(input: { readonly terminalId: string; readonly status: KnownTerminalSession["state"]["status"]; @@ -144,6 +160,60 @@ describe("nextOpenTerminalId", () => { }); }); +describe("previousLiveTerminalId", () => { + it("returns null when no other live session remains", () => { + expect( + previousLiveTerminalId({ + sessions: [ + makeMenuSession({ terminalId: "term-2", status: "exited" }), + makeMenuSession({ terminalId: "term-3", status: "closed" }), + ], + exitedTerminalId: "term-2", + }), + ).toBe(null); + }); + + it("prefers the nearest live session below the exited id", () => { + expect( + previousLiveTerminalId({ + sessions: [ + makeMenuSession({ terminalId: DEFAULT_TERMINAL_ID, status: "running" }), + makeMenuSession({ terminalId: "term-2", status: "running" }), + makeMenuSession({ terminalId: "term-3", status: "exited" }), + makeMenuSession({ terminalId: "term-4", status: "running" }), + ], + exitedTerminalId: "term-3", + }), + ).toBe("term-2"); + }); + + it("falls back to the nearest live session above when the exited id was lowest", () => { + expect( + previousLiveTerminalId({ + sessions: [ + makeMenuSession({ terminalId: DEFAULT_TERMINAL_ID, status: "exited" }), + makeMenuSession({ terminalId: "term-2", status: "starting" }), + makeMenuSession({ terminalId: "term-4", status: "running" }), + ], + exitedTerminalId: DEFAULT_TERMINAL_ID, + }), + ).toBe("term-2"); + }); + + it("ignores dead sessions when picking the fallback", () => { + expect( + previousLiveTerminalId({ + sessions: [ + makeMenuSession({ terminalId: DEFAULT_TERMINAL_ID, status: "running" }), + makeMenuSession({ terminalId: "term-2", status: "exited" }), + makeMenuSession({ terminalId: "term-3", status: "exited" }), + ], + exitedTerminalId: "term-3", + }), + ).toBe(DEFAULT_TERMINAL_ID); + }); +}); + describe("resolveProjectScriptTerminalId", () => { it("reuses the default shell when no terminal is running", () => { expect( diff --git a/apps/mobile/src/features/terminal/terminalMenu.ts b/apps/mobile/src/features/terminal/terminalMenu.ts index 29374bdda6d..06cb74e9467 100644 --- a/apps/mobile/src/features/terminal/terminalMenu.ts +++ b/apps/mobile/src/features/terminal/terminalMenu.ts @@ -114,6 +114,35 @@ export function buildTerminalMenuSessions(input: { return Arr.sort(sessionsById.values(), terminalMenuSessionOrder); } +/** + * Picks the session to show after a terminal exits: the nearest live session + * below the exited id (terminal n-1), falling back to the nearest one above. + * Returns null when no other live session remains and the terminal UI should + * be dismissed instead. + */ +export function previousLiveTerminalId(input: { + readonly sessions: ReadonlyArray; + readonly exitedTerminalId: string; +}): string | null { + const live = Arr.sort( + input.sessions.filter( + (session) => + session.terminalId !== input.exitedTerminalId && + (session.status === "running" || session.status === "starting"), + ), + terminalMenuSessionOrder, + ); + if (live.length === 0) { + return null; + } + + const below = live.filter( + (session) => + session.terminalId.localeCompare(input.exitedTerminalId, undefined, { numeric: true }) < 0, + ); + return (below[below.length - 1] ?? live[0])?.terminalId ?? null; +} + export function resolveProjectScriptTerminalId(input: { readonly existingTerminalIds: ReadonlyArray; readonly hasRunningTerminal: boolean; diff --git a/apps/mobile/src/features/threads/ComposerCommandPopover.tsx b/apps/mobile/src/features/threads/ComposerCommandPopover.tsx index 87ac2a6f951..ccf6a307122 100644 --- a/apps/mobile/src/features/threads/ComposerCommandPopover.tsx +++ b/apps/mobile/src/features/threads/ComposerCommandPopover.tsx @@ -1,7 +1,7 @@ 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 { SymbolView } from "../../components/AppSymbol"; import { memo } from "react"; import { Pressable, ScrollView, useColorScheme, View, type ViewStyle } from "react-native"; @@ -154,23 +154,11 @@ const CommandRow = memo(function CommandRow(props: { ) : iconName ? ( ) : null} - + {props.item.label} {props.item.description ? ( - + {props.item.description} ) : null} @@ -187,18 +175,15 @@ export const ComposerCommandPopover = memo(function ComposerCommandPopover( return ( {label ? ( - - + + {label} ) : null} {props.items.length > 0 ? ( @@ -212,7 +197,7 @@ export const ComposerCommandPopover = memo(function ComposerCommandPopover( ))} ) : ( - + {emptyText(props.triggerKind, props.isLoading)} diff --git a/apps/mobile/src/features/threads/GitActionProgressOverlay.tsx b/apps/mobile/src/features/threads/GitActionProgressOverlay.tsx index 93d929e5961..4c99c2c866c 100644 --- a/apps/mobile/src/features/threads/GitActionProgressOverlay.tsx +++ b/apps/mobile/src/features/threads/GitActionProgressOverlay.tsx @@ -1,5 +1,5 @@ import * as Haptics from "expo-haptics"; -import { SymbolView } from "expo-symbols"; +import { SymbolView } from "../../components/AppSymbol"; import { useCallback, useEffect, useRef } from "react"; import { ActivityIndicator, Pressable, View } from "react-native"; import Animated, { FadeIn, FadeOut } from "react-native-reanimated"; @@ -47,7 +47,8 @@ export function GitActionProgressOverlay(props: { diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 935776eac56..dd8216dcecf 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -1,10 +1,11 @@ import { NativeStackScreenOptions } from "../../native/StackHeader"; import { StackActions, useNavigation } from "@react-navigation/native"; -import { useCallback, useEffect, useMemo, useRef } from "react"; -import { Alert, InteractionManager, View, useColorScheme } from "react-native"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Alert, InteractionManager, Platform, View, useColorScheme } from "react-native"; import { KeyboardAvoidingView, useKeyboardState } from "react-native-keyboard-controller"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useThemeColor } from "../../lib/useThemeColor"; +import { useFontFamily } from "../../lib/useFontFamily"; import { EnvironmentId } from "@t3tools/contracts"; import { @@ -19,9 +20,11 @@ import { ComposerToolbarScroller, ComposerToolbarTrigger, } from "../../components/ComposerToolbarTrigger"; +import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip"; -import { ControlPillMenu } from "../../components/ControlPill"; +import { ControlPill, ControlPillMenu } from "../../components/ControlPill"; import { ProviderIcon } from "../../components/ProviderIcon"; +import { ComposerSurface } from "./ThreadComposer"; import { makeTurnCommandMetadata } from "../../lib/commandMetadata"; import { convertPastedImagesToAttachments, pickComposerImages } from "../../lib/composerImages"; @@ -78,6 +81,7 @@ export function NewTaskDraftScreen(props: { )?.connectionState === "connected"; const promptInputRef = useRef(null); const loadedBranchesProjectKeyRef = useRef(null); + const [isComposerFocused, setIsComposerFocused] = useState(false); const appliedInitialProjectKeyRef = useRef(null); useEffect(() => { return () => { @@ -112,7 +116,9 @@ export function NewTaskDraftScreen(props: { }; }, [props.pendingTaskId, cancelEditingPendingTask]); - const borderColor = useThemeColor("--color-border"); + const foregroundColor = useThemeColor("--color-foreground"); + const regularFontFamily = useFontFamily("regular"); + const bodyText = useScaledTextRole("body"); const headlineText = useScaledTextRole("headline"); const sheetFadeOpaque = colorScheme === "dark" ? "rgba(14,14,14,0.98)" : "rgba(242,242,247,0.98)"; const sheetFadeTransparent = colorScheme === "dark" ? "rgba(14,14,14,0)" : "rgba(242,242,247,0)"; @@ -193,7 +199,9 @@ export function NewTaskDraftScreen(props: { }, [flow.loadBranches, selectedProject]); useEffect(() => { - if (!selectedProject) { + // Android starts with the collapsed composer pill (like an open thread) + // and only expands/focuses when tapped. + if (!selectedProject || Platform.OS === "android") { return; } @@ -591,7 +599,198 @@ export function NewTaskDraftScreen(props: { if (!selectedProject) { return ( - + {Platform.OS === "android" ? ( + <> + + navigation.goBack()} /> + + ) : ( + + )} + + ); + } + + const isAndroid = Platform.OS === "android"; + const isDarkMode = colorScheme === "dark"; + // Android expansion follows native editor focus so relayout cannot race + // the touch gesture that opens the keyboard. + const isExpanded = !isAndroid || isComposerFocused; + const canStart = + Boolean(flow.selectedProject) && + Boolean(flow.selectedModel) && + flow.prompt.trim().length > 0 && + !flow.submitting && + !(flow.workspaceMode === "worktree" && !flow.selectedBranchName); + const promptEditor = ( + setIsComposerFocused(true)} + onBlur={() => setIsComposerFocused(false)} + onPasteImages={(uris) => void handleNativePasteImages(uris)} + placeholder={`Describe a coding task in ${selectedProject.title}`} + // Same collapsed centering as ThreadComposer: native vertical gravity + // in a pill-height box. + singleLineCentered={!isExpanded} + contentInsetVertical={isAndroid ? 0 : undefined} + style={ + isAndroid + ? isExpanded + ? { minHeight: 80, maxHeight: 160, paddingHorizontal: 4, paddingVertical: 4 } + : { height: 36 } + : { flex: 1, minHeight: 0 } + } + textStyle={ + isAndroid + ? { ...bodyText, color: foregroundColor, fontFamily: regularFontFamily } + : headlineText + } + /> + ); + + const toolbarPills = ( + <> + void handlePickImages()} + showChevron={false} + /> + handleModelMenuAction(nativeEvent.event)} + > + } + label={flow.selectedModelOption?.label ?? "Model"} + /> + + handleOptionsMenuAction(nativeEvent.event)} + > + + + handleEnvironmentMenuAction(nativeEvent.event)} + > + + + handleWorkspaceMenuAction(nativeEvent.event)} + > + + + + ); + + const startButton = ( + void handleStart()} + variant="primary" + showChevron={false} + disabled={!canStart} + /> + ); + + if (isAndroid) { + // The draft is a thread that doesn't exist yet, so it mirrors the thread + // page: in-screen header, empty feed canvas above, and the same floating + // composer chrome as ThreadComposer (collapsed pill → expanded card). + return ( + + + navigation.goBack()} /> + + + + + + + {isExpanded && flow.attachments.length > 0 ? ( + + + + ) : null} + {promptEditor} + {!isExpanded ? ( + void handleStart()} + /> + ) : null} + + + {isExpanded ? ( + + + {toolbarPills} + + {startButton} + + ) : null} + + ); } @@ -600,32 +799,12 @@ export function NewTaskDraftScreen(props: { - - - void handleNativePasteImages(uris)} - placeholder={`Describe a coding task in ${selectedProject.title}`} - style={{ flex: 1, minHeight: 0 }} - textStyle={headlineText} - /> - + + {promptEditor} - + {flow.attachments.length > 0 ? ( - + - void handlePickImages()} - showChevron={false} - /> - handleModelMenuAction(nativeEvent.event)} - > - - } - label={flow.selectedModelOption?.label ?? "Model"} - /> - - handleOptionsMenuAction(nativeEvent.event)} - > - - - handleEnvironmentMenuAction(nativeEvent.event)} - > - - - handleWorkspaceMenuAction(nativeEvent.event)} - > - - + {toolbarPills} - void handleStart()} - variant="primary" - showChevron={false} - disabled={ - !flow.selectedProject || - !flow.selectedModel || - flow.prompt.trim().length === 0 || - flow.submitting || - (flow.workspaceMode === "worktree" && !flow.selectedBranchName) - } - /> + {startButton} diff --git a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx index 896a43d636b..ca857f6786a 100644 --- a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx @@ -1,12 +1,14 @@ -import { NativeHeaderToolbar } from "../../native/StackHeader"; +import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; import { useNavigation } from "@react-navigation/native"; -import { SymbolView } from "expo-symbols"; +import { SymbolView } from "../../components/AppSymbol"; import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; import { useMemo } from "react"; -import { ActivityIndicator, Pressable, ScrollView, View } from "react-native"; +import { ActivityIndicator, Platform, Pressable, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useThemeColor } from "../../lib/useThemeColor"; +import { cn } from "../../lib/cn"; +import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; import { AppText as Text } from "../../components/AppText"; import { ProjectFavicon } from "../../components/ProjectFavicon"; import { useProjects, useThreadShells } from "../../state/entities"; @@ -79,7 +81,6 @@ export function NewTaskRouteScreen() { const insets = useSafeAreaInsets(); const chevronColor = useThemeColor("--color-chevron"); const accentColor = useThemeColor("--color-icon-muted"); - const borderSubtleColor = useThemeColor("--color-border-subtle"); const repositoryGroups = useMemo( () => groupProjectsByRepository({ projects, threads }), [projects, threads], @@ -111,26 +112,44 @@ export function NewTaskRouteScreen() { return ( - - {layout.usesSplitView ? ( + {Platform.OS === "android" ? ( + <> + {/* Android renders its own in-screen header instead of the native bar. */} + + navigation.goBack() : undefined} + actions={[ + { + accessibilityLabel: "Add project", + icon: "plus", + onPress: () => navigation.navigate("NewTaskSheet", { screen: "AddProject" }), + }, + ]} + /> + + ) : ( + + {layout.usesSplitView ? ( + navigation.goBack()} + separateBackground + /> + ) : null} navigation.goBack()} + icon="plus" + onPress={() => navigation.navigate("NewTaskSheet", { screen: "AddProject" })} separateBackground /> - ) : null} - navigation.navigate("NewTaskSheet", { screen: "AddProject" })} - separateBackground - /> - + + )} navigation.navigate("NewTaskSheet", { screen: "NewTaskDraft", @@ -186,16 +204,12 @@ export function NewTaskRouteScreen() { }, }) } - style={{ - paddingHorizontal: 16, - paddingVertical: 14, - borderTopWidth: isFirst ? 0 : 1, - borderTopColor: borderSubtleColor, - borderTopLeftRadius: isFirst ? 24 : 0, - borderTopRightRadius: isFirst ? 24 : 0, - borderBottomLeftRadius: isLast ? 24 : 0, - borderBottomRightRadius: isLast ? 24 : 0, - }} + className={cn( + "bg-card px-4 py-3.5", + !isFirst && "border-t border-border-subtle", + isFirst && "rounded-t-[24px]", + isLast && "rounded-b-[24px]", + )} > diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index eb3db00b026..b8fa86623a4 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -22,6 +22,7 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject import { ActivityIndicator, Image, + Platform, Pressable, useColorScheme, View, @@ -116,12 +117,18 @@ export interface ThreadComposerProps { /** * The pill / card container — renders as LiquidGlassView on supported * iOS 26+ devices (progressive blur, native morph), opaque View otherwise. + * Exported so NewTaskDraftScreen can render the same composer chrome. */ // One timing for every piece of the expanded↔compact morph so the surface, // toolbar, and siblings move together instead of popping between layouts. -const COMPOSER_LAYOUT_TRANSITION = LinearTransition.duration(220); - -function ComposerSurface(props: { +// Android gets NO layout transition: the composer rides the keyboard via +// KeyboardStickyView (frame-synced to the IME), and a time-based morph +// running alongside that translate reads as jitter. Snapping the layout and +// letting the keyboard-synced slide be the only motion looks native there. +const COMPOSER_LAYOUT_TRANSITION = + Platform.OS === "android" ? undefined : LinearTransition.duration(220); + +export function ComposerSurface(props: { readonly children: ReactNode; readonly style: ViewStyle; readonly isDarkMode: boolean; @@ -689,9 +696,9 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer return ( {composerTrigger && composerMenuItems.length > 0 ? ( - + 0 ? "pb-2.5" : undefined} entering={FadeIn.duration(160)} exiting={FadeOut.duration(120)} - style={{ paddingBottom: props.draftAttachments.length > 0 ? 10 : 0 }} > ) : null} - + {!isExpanded && props.draftAttachments.length > 0 ? ( - + {props.draftAttachments.slice(0, 3).map((image) => ( onPressImage(image.previewUri)}> ))} {props.draftAttachments.length > 3 ? ( - + +{props.draftAttachments.length - 3} @@ -852,8 +838,8 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer ) : null} - {/* Toolbar row — matches draft page layout (expanded only) */} {isExpanded ? ( + // Toolbar row — matches draft page layout (expanded only) void props.onPickDraftImages()} showChevron={false} @@ -889,6 +876,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer {showStopAction ? ( 0 ? ( - + {props.queueCount} queued message{props.queueCount === 1 ? "" : "s"} will send automatically. diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 0b9aa2fdfbc..0a6412ffe20 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -17,7 +17,7 @@ import type { import { formatElapsed } from "@t3tools/shared/orchestrationTiming"; import * as Haptics from "expo-haptics"; import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; -import { Platform, View, type GestureResponderEvent } from "react-native"; +import { Platform, View, type GestureResponderEvent, type LayoutChangeEvent } from "react-native"; import { KeyboardController, KeyboardStickyView } from "react-native-keyboard-controller"; import Animated, { FadeInDown, FadeOut, LinearTransition } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -178,6 +178,10 @@ function useStreamingHaptics(threadId: ThreadId, feed: ReadonlyArray @@ -251,6 +254,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const composerOverlapHeight = composerChrome + composerBottomInset; const activeWorkIndicatorHeight = props.activeWorkStartedAt ? WORKING_INDICATOR_HEIGHT : 0; const estimatedOverlayHeight = composerOverlapHeight + activeWorkIndicatorHeight; + const [composerOverlayHeight, setComposerOverlayHeight] = useState(estimatedOverlayHeight); // The overlay's measured height includes the home-indicator inset (the // composer pads it), but contentInsetAdjustmentBehavior="automatic" makes // UIKit add the safe-area bottom to the content inset AGAIN — leaving a @@ -260,11 +264,22 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread // its end-scroll math matches the real resting position. const nativeInsetOvercount = props.usesAutomaticContentInsets === true && Platform.OS === "ios" ? insets.bottom : 0; + // heightAdjustment is added to measured composer height. Keep the safe-area + // under-report (UIKit automatic insets) and add breathing room above chrome. + const composerEndHeightAdjustment = THREAD_FEED_COMPOSER_GAP - nativeInsetOvercount; const { contentInsetEndAdjustment, onComposerLayout } = useKeyboardChatComposerInset( listRef, composerOverlayRef, - Math.max(0, estimatedOverlayHeight - nativeInsetOvercount), - -nativeInsetOvercount, + Math.max(0, estimatedOverlayHeight + composerEndHeightAdjustment), + composerEndHeightAdjustment, + ); + const handleComposerLayout = useCallback( + (event: LayoutChangeEvent) => { + onComposerLayout(event); + const height = event.nativeEvent.layout.height; + setComposerOverlayHeight((current) => (Math.abs(current - height) > 1 ? height : current)); + }, + [onComposerLayout], ); const { freeze, scrollMessageToEnd } = useKeyboardScrollToEnd({ listRef }); const showContent = props.showContent ?? true; @@ -392,7 +407,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread {showContent ? ( ) : ( - + )} {/* Floating composer — sticks to keyboard via KeyboardStickyView */} @@ -440,10 +456,11 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread {/* No paddingTop here: the overlay's measured height becomes the list's bottom inset, so any padding above the pill/composer pushes the resting content floor up by the same amount. */} - + {props.activeWorkStartedAt ? ( @@ -456,10 +473,9 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread {props.activePendingApproval || props.activePendingUserInput ? ( {props.activePendingApproval ? ( ; readonly anchorMessageId: MessageId | null; readonly contentInsetEndAdjustment: SharedValue; + /** Effective resting end inset: measured composer overlay plus feed gap. */ + readonly contentInsetEndEstimate: number; readonly contentTopInset?: number; readonly contentBottomInset?: number; readonly topAccessory?: ReactNode; @@ -239,18 +243,26 @@ function MessageAttachmentImage(props: { attachmentId: props.attachmentId, }); - if (uri === null) { - return ( - - - - ); - } - return ( - props.onPressImage(uri)}> - - + + {uri === null ? ( + + ) : ( + props.onPressImage(uri)} + style={({ pressed }) => ({ height: "100%", opacity: pressed ? 0.7 : 1, width: "100%" })} + > + + + )} + ); } @@ -291,6 +303,12 @@ const MARKDOWN_COLORS = { }, } as const; +const MARKDOWN_MONO_FONT = Platform.select({ + ios: "ui-monospace", + android: "monospace", + default: "monospace", +}); + interface MarkdownStyleSets { readonly user: MarkdownStyleSet; readonly assistant: MarkdownStyleSet; @@ -323,10 +341,6 @@ const markdownLinkStyles = StyleSheet.create({ favicon: { borderRadius: 3, }, - file: { - fontFamily: "DMSans_700Bold", - fontWeight: "700", - }, }); const MarkdownExternalLink = memo(function MarkdownExternalLink(props: { @@ -339,12 +353,12 @@ const MarkdownExternalLink = memo(function MarkdownExternalLink(props: { return ( { void Linking.openURL(props.href); }} style={{ color: props.color, - fontFamily: "DMSans_400Regular", textDecorationLine: "none", }} > @@ -367,6 +381,122 @@ const MarkdownExternalLink = memo(function MarkdownExternalLink(props: { ); }); +function MarkdownCodeBlock(props: { + readonly backgroundColor: string; + readonly borderColor: string; + readonly content: string; + readonly copyTintColor: ColorValue; + readonly headerTextColor: string; + readonly fontSize: number; + readonly highlightCode: boolean; + readonly language?: string | null; + readonly lineHeight: number; + readonly textColor: string; + readonly theme: ReviewDiffTheme; +}) { + const content = props.content.replace(/\n$/, ""); + const languageLabel = props.language?.trim() || "text"; + const highlighted = useMarkdownCodeHighlight({ + code: content, + enabled: props.highlightCode && Boolean(props.language?.trim()), + language: props.language, + theme: props.theme, + }); + let tokenOffset = 0; + + return ( + + + + {languageLabel} + + + + + + {highlighted + ? highlighted.map((line, lineIndex) => { + const lineStartOffset = tokenOffset; + const lineText = line.map((token) => token.content).join(""); + const renderedLine = ( + + {line.map((token) => { + const startOffset = tokenOffset; + tokenOffset += token.content.length; + const fontStyle = + token.fontStyle !== null && (token.fontStyle & 1) === 1 + ? ("italic" as const) + : ("normal" as const); + const fontWeight = + token.fontStyle !== null && (token.fontStyle & 2) === 2 + ? ("700" as const) + : ("400" as const); + + return ( + + {token.content} + + ); + })} + {lineIndex + 1 < highlighted.length ? "\n" : ""} + + ); + if (lineIndex + 1 < highlighted.length) { + tokenOffset += 1; + } + return renderedLine; + }) + : content} + + + + ); +} + function useReviewCommentColors(): ReviewCommentColors { const colorScheme = useColorScheme(); const isDark = colorScheme === "dark"; @@ -401,8 +531,13 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe () => resolveNativeMarkdownTypography(appearance.baseFontSize), [appearance.baseFontSize], ); - const colors = MARKDOWN_COLORS[colorScheme === "dark" ? "dark" : "light"]; + const themeMode = colorScheme === "dark" ? "dark" : "light"; + const colors = MARKDOWN_COLORS[themeMode]; + const iconSubtleColor = String(useThemeColor("--color-icon-subtle")); const inlineSkillForeground = String(useThemeColor("--color-inline-skill-foreground")); + const userBubbleForegroundMuted = String(useThemeColor("--color-user-bubble-foreground-muted")); + const regularFontFamily = useFontFamily("regular"); + const boldFontFamily = useFontFamily("bold"); return useMemo(() => { const markdownBodyColor = colors.body; @@ -428,6 +563,7 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe link: markdownLinkColor, blockquote: markdownBlockquoteBorder, border: markdownHrColor, + surface: "transparent", surfaceLight: markdownBlockquoteBg, accent: markdownLinkColor, tableBorder: markdownHrColor, @@ -454,9 +590,9 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe h6: markdownFontSizes.h6, }, fontFamilies: { - regular: "DMSans_400Regular", - heading: "DMSans_700Bold", - mono: "ui-monospace", + regular: regularFontFamily, + heading: boldFontFamily, + mono: MARKDOWN_MONO_FONT, }, headingWeight: "700", borderRadius: { @@ -477,7 +613,7 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe bold: { fontWeight: "700", color: markdownStrongColor, - fontFamily: "DMSans_700Bold", + fontFamily: boldFontFamily, }, italic: { fontStyle: "italic" }, link: { @@ -493,7 +629,7 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe marginVertical: 10, }, heading: { - fontFamily: "DMSans_700Bold", + fontFamily: boldFontFamily, color: markdownStrongColor, marginTop: 18, marginBottom: 8, @@ -510,15 +646,18 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe inlineCodeTextColor: string, blockBackgroundColor: string, blockTextColor: string, + copyTintColor: ColorValue, preserveSoftBreaks: boolean, + highlightCode: boolean, ): CustomRenderers => ({ link: ({ children, href = "" }) => { const presentation = resolveMarkdownLinkPresentation(href); if (presentation.kind === "file") { return ( onLinkPress(href)} - style={[markdownLinkStyles.file, { color: inlineTextColor }]} + style={{ color: inlineTextColor }} > void): MarkdownStyleSe const linkHref = presentation.href; return ( { @@ -549,17 +689,14 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe } : undefined } - style={{ - color: markdownLinkColor, - textDecorationLine: "underline", - }} + style={{ color: markdownLinkColor }} > {children} ); }, list: ({ node, Renderer, ordered = false, start = 1 }) => ( - + {node.children?.map((child, index) => { const childKey = `${child.type}:${child.beg ?? "unknown"}:${child.end ?? "unknown"}`; if (child.type === "task_list_item") { @@ -568,20 +705,13 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe ); } return ( - + void): MarkdownStyleSe > {ordered ? `${start + index}.` : "•"} - + @@ -601,9 +731,9 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe const value = content ?? ""; return ( void): MarkdownStyleSe soft_break: () => {"\n"}, } : {}), - code_block: ({ content, language }) => ( - - {language ? ( - - - {language} - - - ) : null} - - - {content} - - - + code_block: ({ content = "", language }) => ( + ), }); @@ -690,7 +782,7 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe bold: { fontWeight: "700", color: markdownUserBodyColor, - fontFamily: "DMSans_700Bold", + fontFamily: boldFontFamily, }, heading: { ...baseStyles.heading, @@ -726,7 +818,9 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe markdownUserInlineCodeText, markdownUserFenceBg, markdownUserFenceText, + userBubbleForegroundMuted, true, + false, ), nativeTextStyle: { color: markdownUserBodyColor, @@ -744,9 +838,9 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe fontSize: nativeMarkdownTypography.fontSize, lineHeight: nativeMarkdownTypography.lineHeight, headingFontSizes: nativeMarkdownTypography.headingFontSizes, - fontFamily: "DMSans_400Regular", - headingFontFamily: "DMSans_700Bold", - boldFontFamily: "DMSans_700Bold", + fontFamily: regularFontFamily, + headingFontFamily: boldFontFamily, + boldFontFamily, }, }, assistant: { @@ -757,7 +851,9 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe markdownInlineCodeText, markdownCodeBg, markdownCodeText, + iconSubtleColor, false, + true, ), nativeTextStyle: { color: markdownBodyColor, @@ -775,13 +871,24 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe fontSize: nativeMarkdownTypography.fontSize, lineHeight: nativeMarkdownTypography.lineHeight, headingFontSizes: nativeMarkdownTypography.headingFontSizes, - fontFamily: "DMSans_400Regular", - headingFontFamily: "DMSans_700Bold", - boldFontFamily: "DMSans_700Bold", + fontFamily: regularFontFamily, + headingFontFamily: boldFontFamily, + boldFontFamily, }, }, }; - }, [colors, inlineSkillForeground, markdownFontSizes, nativeMarkdownTypography, onLinkPress]); + }, [ + boldFontFamily, + colors, + iconSubtleColor, + inlineSkillForeground, + markdownFontSizes, + nativeMarkdownTypography, + onLinkPress, + regularFontFamily, + themeMode, + userBubbleForegroundMuted, + ]); } function renderFeedEntry( @@ -924,6 +1031,7 @@ function renderFeedEntry( hasNativeSelectableMarkdownText() ? ( + {props.loading ? : null} {props.title} {props.detail} @@ -1263,6 +1372,10 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const copyFeedbackTimeoutRef = useRef | null>(null); const foldSettleFrameRef = useRef(null); const foldSettleSecondFrameRef = useRef(null); + const initialEndCorrectionFrameRef = useRef(null); + const underflowCorrectionFrameRef = useRef(null); + const initialEndCorrectionKeyRef = useRef(null); + const previousContentUnderflowsViewportRef = useRef(false); const previousLatestTurnRef = useRef(props.latestRun); const disclosureAnchorKeyRef = useRef(null); const headerMaterialVisibleRef = useRef(false); @@ -1271,6 +1384,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { props.layoutVariant === "split" ? 0 : windowWidth, ); const [viewportHeight, setViewportHeight] = useState(0); + const [contentHeight, setContentHeight] = useState(0); const [disclosureToggleSettling, setDisclosureToggleSettling] = useState(false); const [interactionState, setInteractionState] = useState<{ readonly copiedRowId: string | null; @@ -1395,6 +1509,51 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { setViewportHeight((current) => (Math.abs(current - nextHeight) > 1 ? nextHeight : current)); }, []); + const handleContentSizeChange = useCallback((_width: number, height: number) => { + setContentHeight((current) => (Math.abs(current - height) > 1 ? height : current)); + }, []); + // UIKit's automatic top inset reduces the visible list area without adding + // to contentHeight. Non-automatic layouts render their header spacer inside + // the list, so their contentHeight already accounts for it. + const usableViewportHeight = viewportHeight - (usesNativeAutomaticInsets ? anchorTopInset : 0); + const contentUnderflowsViewport = + contentHeight > 0 && + viewportHeight > 0 && + contentHeight + props.contentInsetEndEstimate < usableViewportHeight - CONTENT_FIT_EPSILON; + + useEffect(() => { + if (underflowCorrectionFrameRef.current !== null) { + cancelAnimationFrame(underflowCorrectionFrameRef.current); + underflowCorrectionFrameRef.current = null; + } + + const previouslyUnderflowed = previousContentUnderflowsViewportRef.current; + previousContentUnderflowsViewportRef.current = contentUnderflowsViewport; + if (contentUnderflowsViewport) { + underflowCorrectionFrameRef.current = requestAnimationFrame(() => { + underflowCorrectionFrameRef.current = null; + void props.listRef.current?.scrollToOffset({ + animated: false, + offset: -anchorTopInset, + }); + }); + return; + } + + // A disclosure transition owns its visible-content anchor, so consuming + // the underflow transition without an end scroll is intentional there. + if (previouslyUnderflowed && !disclosureToggleSettling) { + void props.listRef.current?.scrollToEnd({ animated: true }); + } + }, [ + anchorTopInset, + contentHeight, + contentUnderflowsViewport, + disclosureToggleSettling, + props.listRef, + viewportHeight, + ]); + useEffect(() => { reportHeaderMaterialVisibility(false); }, [props.threadId, reportHeaderMaterialVisibility]); @@ -1404,14 +1563,23 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { [expandedTurnIds, props.feed, props.latestRun], ); - // The empty↔filled key below remounts the list, which resets its imperative - // content-inset override — and useKeyboardChatComposerInset (mounted above + // A hydrating thread reserves the filled identity so detail arrival does not + // replace the native list during the draft-to-thread transition. An already + // open empty thread still remounts when its first message arrives. That + // remount resets its imperative content-inset override — and + // useKeyboardChatComposerInset (mounted above // the remount boundary) deduplicates by height, so it never re-reports the // composer inset to the fresh instance. Without this, the remounted list's // initial scroll-to-end computes with a zero end inset and rests one // composer-height short of the end. Layout effect: it must land before the // list's first positioning tick or the one-shot initial scroll misses it. - const listMountKey = `${props.threadId}:${props.feed.length === 0 ? "empty" : "filled"}`; + const listMountState = + props.contentPresentation.kind === "loading" + ? "filled" + : props.feed.length === 0 + ? "empty" + : "filled"; + const listMountKey = `${props.threadId}:${listMountState}`; useLayoutEffect(() => { const bottom = props.contentInsetEndAdjustment.value; if (bottom > 0) { @@ -1419,6 +1587,41 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { } }, [listMountKey, props.contentInsetEndAdjustment, props.listRef]); + useEffect(() => { + if (initialEndCorrectionFrameRef.current !== null) { + cancelAnimationFrame(initialEndCorrectionFrameRef.current); + initialEndCorrectionFrameRef.current = null; + } + + if ( + initialEndCorrectionKeyRef.current === listMountKey || + props.contentPresentation.kind !== "ready" || + contentHeight <= 0 || + viewportHeight <= 0 + ) { + return; + } + + initialEndCorrectionKeyRef.current = listMountKey; + if (contentUnderflowsViewport) { + return; + } + + initialEndCorrectionFrameRef.current = requestAnimationFrame(() => { + initialEndCorrectionFrameRef.current = requestAnimationFrame(() => { + initialEndCorrectionFrameRef.current = null; + void props.listRef.current?.scrollToEnd({ animated: false }); + }); + }); + }, [ + contentHeight, + contentUnderflowsViewport, + listMountKey, + props.contentPresentation.kind, + props.listRef, + viewportHeight, + ]); + const anchoredEndSpace = useMemo( () => resolveChatListAnchoredEndSpace( @@ -1481,6 +1684,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { if (foldSettleSecondFrameRef.current !== null) { cancelAnimationFrame(foldSettleSecondFrameRef.current); } + if (underflowCorrectionFrameRef.current !== null) { + cancelAnimationFrame(underflowCorrectionFrameRef.current); + } + if (initialEndCorrectionFrameRef.current !== null) { + cancelAnimationFrame(initialEndCorrectionFrameRef.current); + } }; }, []); @@ -1646,8 +1855,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { return ( <> - - + + ) : null} + {props.contentPresentation.kind === "loading" ? ( + + + + ) : null} setExpandedImage(null)} + presentationStyle="overFullScreen" swipeToCloseEnabled doubleTapToZoomEnabled /> diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index c639bd60f53..6e0e243aad6 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -5,7 +5,6 @@ import type { } from "@t3tools/client-runtime/state/shell"; import { LegendList } from "@legendapp/list/react-native"; import type { MenuAction } from "@react-native-menu/menu"; -import { SymbolView } from "expo-symbols"; import { useCallback, useMemo, useRef, useState, type ReactNode } from "react"; import type { LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent } from "react-native"; import { Platform, StyleSheet, TextInput, View, useColorScheme } from "react-native"; @@ -17,6 +16,7 @@ import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg"; import { AppText as Text } from "../../components/AppText"; import { ControlPillMenu } from "../../components/ControlPill"; +import { SymbolView } from "../../components/AppSymbol"; import { NativeStackScreenOptions } from "../../native/StackHeader"; import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities"; import { useThemeColor } from "../../lib/useThemeColor"; @@ -97,8 +97,6 @@ function SidebarHeaderButtonGroup(props: { const SIDEBAR_STICKY_HEADER_HEIGHT = 106; const SIDEBAR_STICKY_HEADER_FADE_HEIGHT = 44; -const IOS_SEARCH_FILL_DARK = "rgba(118, 118, 128, 0.24)"; -const IOS_SEARCH_FILL_LIGHT = "rgba(118, 118, 128, 0.12)"; const SIDEBAR_HEADER_WASH_OPACITY = { dark: [0.22, 0.14, 0.04], light: [0.46, 0.3, 0.08], @@ -140,15 +138,13 @@ function NativeSidebarContainer(props: ThreadNavigationSidebarProps) { return ( @@ -338,11 +334,8 @@ function ThreadNavigationSidebarPane( const backgroundColor = useThemeColor("--color-drawer"); const borderColor = useThemeColor("--color-border"); - const foregroundColor = useThemeColor("--color-foreground"); const mutedColor = useThemeColor("--color-foreground-muted"); const placeholderColor = useThemeColor("--color-placeholder"); - const searchBackgroundColor = - colorScheme === "dark" ? IOS_SEARCH_FILL_DARK : IOS_SEARCH_FILL_LIGHT; const headerFadeColor = String(backgroundColor); const headerWashOpacity = SIDEBAR_HEADER_WASH_OPACITY[colorScheme]; const [measuredHeaderHeight, setMeasuredHeaderHeight] = useState(null); @@ -532,7 +525,7 @@ function ThreadNavigationSidebarPane( [filterIcon, filterMenu, props.onOpenSettings], ); const listEmpty = ( - + {catalogState.isLoadingConnections ? "Loading threads…" : props.searchQuery.trim().length > 0 @@ -566,7 +559,7 @@ function ThreadNavigationSidebarPane( unstable_headerRightItems: () => nativeHeaderItems, }} /> - + + - + @@ -704,12 +687,8 @@ function ThreadNavigationSidebarPane( - - + + Threads @@ -724,14 +703,7 @@ function ThreadNavigationSidebarPane( - + {showsConnectionStatus ? ( - + - immediateThreadRelationships(graph, props.threadId).toSorted( + copySorted( + immediateThreadRelationships(graph, props.threadId), (left, right) => Number(right.threadId === mergeTargetThreadId) - Number(left.threadId === mergeTargetThreadId), diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index b45f350aafe..dbdf1560343 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -10,18 +10,21 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } fro import * as Option from "effect/Option"; import { EnvironmentId, ThreadId, type ProjectScript } from "@t3tools/contracts"; import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; -import { Platform, Pressable, ScrollView, Text as RNText, View } from "react-native"; +import { Platform, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useWorkspaceState } from "../../state/workspace"; -import { useThemeColor } from "../../lib/useThemeColor"; import { useEnvironmentQuery } from "../../state/query"; import { dismissGitActionResult, useGitActionProgress } from "../../state/use-vcs-action-state"; import { vcsEnvironment } from "../../state/vcs"; import { EmptyState } from "../../components/EmptyState"; +import { + AndroidScreenHeader, + type AndroidHeaderAction, +} from "../../components/AndroidScreenHeader"; import { LoadingScreen } from "../../components/LoadingScreen"; +import { addBreadcrumb } from "../../lib/breadcrumbs"; import { scopedThreadKey } from "../../lib/scopedEntities"; -import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; import { connectionTone } from "../connection/connectionTone"; import { @@ -30,7 +33,7 @@ import { useRemoteEnvironmentRuntime, } from "../../state/use-remote-environment-registry"; import { useKnownTerminalSessions } from "../../state/use-terminal-session"; -import { useSelectedThreadDetailState } from "../../state/use-thread-detail"; +import { useSelectedThreadDetailQuery } from "../../state/use-thread-detail"; import { useThreadSelection } from "../../state/use-thread-selection"; import { GitActionProgressOverlay } from "./GitActionProgressOverlay"; import { @@ -69,7 +72,6 @@ import { ThreadInspectorContentStack, type ThreadInspectorMode, } from "./thread-inspector-content-stack"; -import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; interface ThreadInspectorSelection { readonly routeThreadIdentity: string | null; @@ -78,6 +80,11 @@ interface ThreadInspectorSelection { type NativeHeaderItems = ReadonlyArray>; +const THREAD_DETAIL_STALL_RETRY_DELAYS_MS = [8_000, 12_000] as const; +const THREAD_DETAIL_STALL_ERROR_DELAY_MS = 20_000; +const THREAD_DETAIL_STALL_ERROR = + "The conversation did not finish loading. Close and reopen the thread to retry."; + function InspectorPaneRoleActivation() { useAdaptiveWorkspacePaneRole("inspector"); return null; @@ -144,7 +151,91 @@ export function ThreadRouteScreen(props: ThreadRouteScreenProps) { selectedThread === null ? null : scopedThreadKey(selectedThread.environmentId, selectedThread.id); - const selectedThreadDetailState = useSelectedThreadDetailState(); + const selectedThreadDetailQuery = useSelectedThreadDetailQuery(); + const selectedThreadDetailState = selectedThreadDetailQuery.state; + const selectedThreadDetail = Option.getOrNull(selectedThreadDetailState.data); + const detailRefreshAttemptsRef = useRef(new Map()); + const refreshSelectedThreadDetailRef = useRef(selectedThreadDetailQuery.refresh); + const [stalledDetailKey, setStalledDetailKey] = useState(null); + + useEffect(() => { + refreshSelectedThreadDetailRef.current = selectedThreadDetailQuery.refresh; + }, [selectedThreadDetailQuery.refresh]); + + useEffect( + () => () => { + if (routeThreadKey !== null) { + detailRefreshAttemptsRef.current.delete(routeThreadKey); + } + }, + [routeThreadKey], + ); + + useEffect(() => { + if (routeThreadKey === null) { + return; + } + + if (selectedThreadKey !== routeThreadKey) { + return; + } + + if (selectedThreadDetail !== null || selectedThreadDetailState.status === "deleted") { + detailRefreshAttemptsRef.current.delete(routeThreadKey); + setStalledDetailKey((current) => (current === routeThreadKey ? null : current)); + return; + } + + if (routeConnectionState !== "connected") { + detailRefreshAttemptsRef.current.delete(routeThreadKey); + setStalledDetailKey((current) => (current === routeThreadKey ? null : current)); + return; + } + + if (stalledDetailKey === routeThreadKey) { + return; + } + + let cancelled = false; + let timer: ReturnType | null = null; + const scheduleRetry = () => { + const attempts = detailRefreshAttemptsRef.current.get(routeThreadKey) ?? 0; + const delayMs = + THREAD_DETAIL_STALL_RETRY_DELAYS_MS[attempts] ?? THREAD_DETAIL_STALL_ERROR_DELAY_MS; + timer = setTimeout(() => { + if (cancelled) { + return; + } + const currentAttempts = detailRefreshAttemptsRef.current.get(routeThreadKey) ?? 0; + if (currentAttempts !== attempts) { + return; + } + if (attempts >= THREAD_DETAIL_STALL_RETRY_DELAYS_MS.length) { + setStalledDetailKey(routeThreadKey); + return; + } + detailRefreshAttemptsRef.current.set(routeThreadKey, attempts + 1); + refreshSelectedThreadDetailRef.current(); + scheduleRetry(); + }, delayMs); + }; + + scheduleRetry(); + + return () => { + cancelled = true; + if (timer !== null) { + clearTimeout(timer); + } + }; + }, [ + routeThreadKey, + routeConnectionState, + selectedThreadKey, + selectedThreadDetail, + selectedThreadDetailState.status, + stalledDetailKey, + ]); if (environmentId === null || threadIdRaw === null) { return ; @@ -155,7 +246,13 @@ export function ThreadRouteScreen(props: ThreadRouteScreenProps) { // loading placeholder while messages fetch, and the composer's connection // pill reports connecting/reconnecting/syncing status. if (selectedThread !== null && selectedThreadKey === routeThreadKey) { - return ; + return ( + + ); } const stillHydrating = @@ -172,7 +269,8 @@ export function ThreadRouteScreen(props: ThreadRouteScreenProps) { function ThreadRouteContent( props: ThreadRouteScreenProps & { - readonly selectedThreadDetailState: ReturnType; + readonly detailLoadError: string | null; + readonly selectedThreadDetailState: ReturnType["state"]; }, ) { const { @@ -278,7 +376,6 @@ function ThreadRouteContent( ); /* ─── Native header theming ──────────────────────────────────────── */ - const foregroundColor = String(useThemeColor("--color-foreground")); const usesNativeHeaderGlass = Platform.OS === "ios"; const headerSubtitle = [ selectedThreadProject?.title ?? null, @@ -326,11 +423,28 @@ function ThreadRouteContent( const gitActionProgress = useGitActionProgress(gitActionProgressTarget); const handleOpenGitInspector = useCallback(() => { + if (!fileInspector.supported) { + if (selectedThread === null) { + return; + } + navigation.navigate("GitOverview", { + environmentId: String(selectedThread.environmentId), + threadId: String(selectedThread.id), + }); + return; + } setInspectorSelection({ routeThreadIdentity, mode: "git" }); showAuxiliaryPane("inspector"); - }, [routeThreadIdentity, showAuxiliaryPane]); + }, [fileInspector.supported, navigation, routeThreadIdentity, selectedThread, showAuxiliaryPane]); const handleOpenFilesInspector = useCallback(() => { - if (!fileInspector.supported || selectedThread === null || selectedThreadCwd === null) { + if (selectedThread === null || selectedThreadCwd === null) { + return; + } + if (!fileInspector.supported) { + navigation.navigate("ThreadFiles", { + environmentId: String(selectedThread.environmentId), + threadId: String(selectedThread.id), + }); return; } setInspectorSelection({ @@ -340,6 +454,7 @@ function ThreadRouteContent( showAuxiliaryPane("inspector"); }, [ fileInspector.supported, + navigation, props.renderInspector, routeThreadIdentity, selectedThread, @@ -447,6 +562,11 @@ function ThreadRouteContent( if (!selectedThread || !runtime || !threadRuntimeIsActive(runtime)) { return; } + addBreadcrumb("thread.stop", { + environmentId: selectedThread.environmentId, + threadId: selectedThread.id, + runId: runtime.activeRunId ?? null, + }); return interruptThreadTurn({ environmentId: selectedThread.environmentId, input: { @@ -637,6 +757,55 @@ function ThreadRouteContent( ], [panes.primarySidebarVisible, props.onReturnToThread, navigation, togglePrimarySidebar], ); + const androidHeaderActions = useMemo>(() => { + if (Platform.OS !== "android") return []; + + const actions: AndroidHeaderAction[] = []; + if (props.onReturnToThread) { + actions.push({ + accessibilityLabel: "Return to chat", + icon: "chevron.left", + onPress: props.onReturnToThread, + }); + } + if (selectedThreadCwd !== null) { + actions.push({ + accessibilityLabel: "Open files", + icon: "folder", + onPress: handleOpenFilesInspector, + }); + } + if (selectedThreadProject?.workspaceRoot) { + actions.push({ + accessibilityLabel: "Open terminal", + icon: "terminal", + onPress: () => handleOpenTerminal(null), + }); + } + actions.push({ + accessibilityLabel: "Open git controls", + icon: "point.topleft.down.curvedto.point.bottomright.up", + onPress: handleOpenGitInspector, + }); + if (fileInspector.supported && selectedThreadCwd !== null) { + actions.push({ + accessibilityLabel: "Toggle inspector", + icon: "sidebar.right", + onPress: handleToggleInspector, + }); + } + return actions; + }, [ + fileInspector.supported, + handleOpenFilesInspector, + handleOpenTerminal, + handleOpenGitInspector, + handleToggleInspector, + props.onReturnToThread, + selectedThreadCwd, + selectedThreadProject?.workspaceRoot, + ]); + // Deep links / cold starts land with Thread as the ONLY route, where the // native back button does not render. Provide an explicit Home escape for // that case; when history exists the native back button is used instead. @@ -654,6 +823,56 @@ function ThreadRouteContent( [navigation], ); + // Keep hooks above early returns so hook order stays stable if route params + // or selectedThread go null on a re-render of this mounted instance. + const selectedThreadTitle = selectedThread?.title ?? ""; + const threadScreenOptions = useMemo( + () => ({ + // Android draws its own in-flow header (AndroidScreenHeader below); + // the native stack header stays iOS-only. + headerShown: Platform.OS !== "android", + headerTitle: selectedThreadTitle, + headerTitleStyle: usesNativeHeaderGlass + ? ({ + fontSize: 17, + fontWeight: "800" as const, + } as const) + : undefined, + title: selectedThreadTitle, + headerBackVisible: !layout.usesSplitView, + // Compact uses the NATIVE back button when a previous route exists; + // deep links / cold starts get an explicit Home button instead. + // Split view always uses its custom left items. + unstable_headerLeftItems: + Platform.OS === "ios" + ? layout.usesSplitView + ? () => splitLeftHeaderItems + : canGoBack + ? undefined + : () => compactHomeHeaderItems + : undefined, + // Search lives in the persistent sidebar, so the split header keeps + // the git controls on the RIGHT (no center items — center space is + // reserved for future breadcrumbs/status). + unstable_headerRightItems: + Platform.OS === "ios" + ? () => (layout.usesSplitView ? threadCenterHeaderItems : compactRightHeaderItems) + : undefined, + unstable_headerSubtitle: usesNativeHeaderGlass ? headerSubtitle : undefined, + }), + [ + canGoBack, + compactHomeHeaderItems, + compactRightHeaderItems, + headerSubtitle, + layout.usesSplitView, + selectedThreadTitle, + splitLeftHeaderItems, + threadCenterHeaderItems, + usesNativeHeaderGlass, + ], + ); + if (!environmentId || !threadId) { return ; } @@ -662,10 +881,9 @@ function ThreadRouteContent( return ; } - const selectedThreadKey = scopedThreadKey(selectedThread.environmentId, selectedThread.id); const contentPresentation = projectThreadContentPresentation({ hasDetail: selectedThreadDetail !== null, - detailError: Option.getOrNull(selectedThreadDetailState.error), + detailError: Option.getOrNull(selectedThreadDetailState.error) ?? props.detailLoadError, detailDeleted: selectedThreadDetailState.status === "deleted", connectionState: routeConnectionState, }); @@ -726,40 +944,22 @@ function ThreadRouteContent( return ( <> {activeInspectorRenderer ? : null} - splitLeftHeaderItems - : canGoBack - ? undefined - : () => compactHomeHeaderItems - : undefined, - // Search lives in the persistent sidebar, so the split header keeps - // the git controls on the RIGHT (no center items — center space is - // reserved for future breadcrumbs/status). - unstable_headerRightItems: - Platform.OS === "ios" - ? () => (layout.usesSplitView ? threadCenterHeaderItems : compactRightHeaderItems) - : undefined, - unstable_headerSubtitle: usesNativeHeaderGlass ? headerSubtitle : undefined, - }} - /> + + + {Platform.OS === "android" ? ( + navigation.goBack()} + actions={androidHeaderActions} + /> + ) : null} - {renderThreadRouteBody(!layout.usesSplitView && !usesNativeHeaderGlass)} + {/* Android surfaces the git/files/inspector actions in its in-flow + header above, so the fallback action toolbar stays iOS-only. */} + {renderThreadRouteBody( + Platform.OS !== "android" && !layout.usesSplitView && !usesNativeHeaderGlass, + )} ); } diff --git a/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx b/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx index 2acf641753d..58c13ae7432 100644 --- a/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx @@ -3,9 +3,9 @@ import { useNavigation, type StaticScreenProps } from "@react-navigation/native" 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 { cn } from "../../../lib/cn"; import { useEnvironmentQuery } from "../../../state/query"; import { useThreadSelection } from "../../../state/use-thread-selection"; import { useSelectedThreadGitActions } from "../../../state/use-selected-thread-git-actions"; @@ -27,12 +27,6 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) { 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 = useEnvironmentQuery( selectedThread !== null && selectedThreadCwd !== null ? vcsEnvironment.status({ @@ -66,30 +60,17 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) { - + New branch - + New worktree - + Existing branches {branchesLoading ? ( @@ -185,12 +148,11 @@ export function GitBranchesSheet(_props: GitBranchesSheetProps) { return ( { void gitActions.onCheckoutSelectedThreadBranch(branch.name).then(() => { navigation.goBack(); diff --git a/apps/mobile/src/features/threads/git/GitCommitSheet.tsx b/apps/mobile/src/features/threads/git/GitCommitSheet.tsx index bf2d187af0d..2f158013046 100644 --- a/apps/mobile/src/features/threads/git/GitCommitSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitCommitSheet.tsx @@ -1,10 +1,10 @@ import { useNavigation, type StaticScreenProps } from "@react-navigation/native"; import { useCallback, useState } from "react"; -import { Pressable, ScrollView, View, useColorScheme } from "react-native"; +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 { cn } from "../../../lib/cn"; import { useEnvironmentQuery } from "../../../state/query"; import { useThreadSelection } from "../../../state/use-thread-selection"; import { useSelectedThreadGitActions } from "../../../state/use-selected-thread-git-actions"; @@ -21,18 +21,11 @@ type GitCommitSheetProps = StaticScreenProps<{ export function GitCommitSheet(_props: GitCommitSheetProps) { const navigation = useNavigation(); 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 = useEnvironmentQuery( selectedThread !== null && selectedThreadCwd !== null ? vcsEnvironment.status({ @@ -76,11 +69,7 @@ export function GitCommitSheet(_props: GitCommitSheetProps) { keyboardShouldPersistTaps="handled" showsVerticalScrollIndicator={false} contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }} - contentContainerStyle={{ - paddingHorizontal: 20, - paddingTop: 8, - gap: 16, - }} + contentContainerClassName="gap-4 px-5 pt-2" > @@ -90,10 +79,7 @@ export function GitCommitSheet(_props: GitCommitSheetProps) { {isDefaultRef ? ( - + Warning: this is the default branch. ) : null} @@ -138,12 +124,8 @@ export function GitCommitSheet(_props: GitCommitSheetProps) { {file.path} - - +{file.insertions} - - - -{file.deletions} - + +{file.insertions} + -{file.deletions} ))} {selectedFiles.length > selectedFilePreview.length ? ( @@ -159,10 +141,10 @@ export function GitCommitSheet(_props: GitCommitSheetProps) { return ( { setExcludedFiles((current) => { const next = new Set(current); @@ -193,12 +175,10 @@ export function GitCommitSheet(_props: GitCommitSheetProps) { ) : null} - + +{file.insertions} - - -{file.deletions} - + -{file.deletions} @@ -216,14 +196,7 @@ export function GitCommitSheet(_props: GitCommitSheetProps) { onChangeText={setDialogCommitMessage} placeholder="Leave empty to auto-generate" textAlignVertical="top" - className="min-h-[128px] rounded-[20px] px-4 py-3.5 font-sans text-base" - style={{ - minHeight: 128, - borderWidth: 1, - borderColor: inputBorderColor, - backgroundColor: inputBg, - color: foregroundColor, - }} + className="min-h-[128px] rounded-[20px] px-4 py-3.5" /> diff --git a/apps/mobile/src/features/threads/git/GitConfirmSheet.tsx b/apps/mobile/src/features/threads/git/GitConfirmSheet.tsx index b4f78742141..e5bf3e6bb32 100644 --- a/apps/mobile/src/features/threads/git/GitConfirmSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitConfirmSheet.tsx @@ -5,6 +5,7 @@ import * as Result from "effect/Result"; import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; 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"; @@ -101,13 +102,10 @@ export function GitConfirmSheet(props: GitConfirmSheetProps) { return ( - + - + Confirm @@ -118,10 +116,7 @@ export function GitConfirmSheet(props: GitConfirmSheetProps) { - + {sheetMenuItems.map(({ item, disabledReason }, index) => ( - {index > 0 ? ( - - ) : null} + {index > 0 ? : null} 0 ? ( <> - + ) : null} - + navigation.navigate("ThreadReview", { environmentId, threadId })} /> - + - - void gitActions.refreshSelectedThreadGitStatus()} - > - - - - {isInspector ? "Repository" : "Branch"} - - - {currentBranchLabel} - - - {currentStatusSummary} - - + {isInspector ? ( + + void gitActions.refreshSelectedThreadGitStatus()} + > + + + + Repository + + {currentBranchLabel} + + {currentStatusSummary} + + + ) : ( + // Compact header row: labeled branch on the left, status summary at + // the trailing end. Horizontal padding lines the text up with the + // rows' icon column inside the card below (20 screen + 16 card + 4 + // row). The sheet relies on pull-to-refresh instead of a corner + // refresh button. + + + + Branch + + + {currentBranchLabel} + + + + {currentStatusSummary} + + + )} {content} diff --git a/apps/mobile/src/features/threads/git/gitSheetComponents.tsx b/apps/mobile/src/features/threads/git/gitSheetComponents.tsx index 8045de78228..61346fcef0f 100644 --- a/apps/mobile/src/features/threads/git/gitSheetComponents.tsx +++ b/apps/mobile/src/features/threads/git/gitSheetComponents.tsx @@ -1,8 +1,9 @@ -import { SymbolView } from "expo-symbols"; +import { SymbolView } from "../../../components/AppSymbol"; import type { ComponentProps } from "react"; import { Pressable, View } from "react-native"; import { useThemeColor } from "../../../lib/useThemeColor"; import { AppText as Text } from "../../../components/AppText"; +import { cn } from "../../../lib/cn"; /* ─── Shared sheet components ──────────────────────────────────────── */ @@ -13,51 +14,36 @@ export function SheetActionButton(props: { 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, - }; + const textColor = tone === "primary" ? primaryFg : tone === "danger" ? dangerFg : secondaryFg; return ( - + {props.label} @@ -68,10 +54,7 @@ export function SheetActionButton(props: { export function MetaCard(props: { readonly label: string; readonly value: string }) { return ( - + {props.label} @@ -93,9 +76,8 @@ export function SheetListRow(props: { return ( diff --git a/apps/mobile/src/features/threads/markdownCodeHighlightState.ts b/apps/mobile/src/features/threads/markdownCodeHighlightState.ts new file mode 100644 index 00000000000..c415cf73980 --- /dev/null +++ b/apps/mobile/src/features/threads/markdownCodeHighlightState.ts @@ -0,0 +1,87 @@ +import { useAtomValue } from "@effect/atom-react"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; +import { useMemo } from "react"; + +import { + highlightCodeSnippet, + type ReviewDiffTheme, + type ReviewHighlightedToken, +} from "../review/shikiReviewHighlighter"; + +const MARKDOWN_CODE_HIGHLIGHT_IDLE_TTL_MS = 5 * 60_000; + +export type MarkdownHighlightedCode = ReadonlyArray>; + +export interface MarkdownCodeHighlightInput { + readonly code: string; + readonly enabled: boolean; + readonly language: string; + readonly theme: ReviewDiffTheme; +} + +type MarkdownCodeHighlighter = ( + input: MarkdownCodeHighlightInput, +) => Promise; + +class MarkdownCodeHighlightCacheKey extends Data.Class {} + +class MarkdownCodeHighlightError extends Data.TaggedError("MarkdownCodeHighlightError")<{ + readonly cause: unknown; +}> {} + +export function createMarkdownCodeHighlightAtomFamily(options?: { + readonly highlight?: MarkdownCodeHighlighter; + readonly idleTtlMs?: number; +}) { + const highlight = + options?.highlight ?? + ((input: MarkdownCodeHighlightInput) => + input.enabled + ? highlightCodeSnippet({ + code: input.code, + language: input.language, + theme: input.theme, + }) + : Promise.resolve(null)); + const idleTtlMs = options?.idleTtlMs ?? MARKDOWN_CODE_HIGHLIGHT_IDLE_TTL_MS; + const family = Atom.family((request: MarkdownCodeHighlightCacheKey) => + Atom.make( + Effect.tryPromise({ + try: () => highlight(request), + catch: (cause) => new MarkdownCodeHighlightError({ cause }), + }), + ).pipe( + Atom.setIdleTTL(idleTtlMs), + Atom.withLabel(`mobile:thread-markdown-code-highlight:${request.theme}:${request.language}`), + ), + ); + + return (input: MarkdownCodeHighlightInput) => family(new MarkdownCodeHighlightCacheKey(input)); +} + +export const markdownCodeHighlightAtom = createMarkdownCodeHighlightAtomFamily(); + +export function useMarkdownCodeHighlight(input: { + readonly code: string; + readonly enabled: boolean; + readonly language: string | null | undefined; + readonly theme: ReviewDiffTheme; +}): MarkdownHighlightedCode | null { + const normalizedLanguage = input.language?.trim() || "text"; + const enabled = input.enabled && Boolean(input.language?.trim()); + const atomLanguage = enabled ? normalizedLanguage : "text"; + const highlightAtom = useMemo( + () => + markdownCodeHighlightAtom({ + code: enabled ? input.code : "", + enabled, + language: atomLanguage, + theme: input.theme, + }), + [atomLanguage, enabled, input.code, input.theme], + ); + const result = useAtomValue(highlightAtom); + return AsyncResult.isSuccess(result) ? result.value : null; +} diff --git a/apps/mobile/src/features/threads/sidebar-filter-button.tsx b/apps/mobile/src/features/threads/sidebar-filter-button.tsx index 642a4a957bb..b1afe594f96 100644 --- a/apps/mobile/src/features/threads/sidebar-filter-button.tsx +++ b/apps/mobile/src/features/threads/sidebar-filter-button.tsx @@ -1,4 +1,4 @@ -import { SymbolView } from "expo-symbols"; +import { SymbolView } from "../../components/AppSymbol"; import { Pressable, StyleSheet, useColorScheme } from "react-native"; import { useThemeColor } from "../../lib/useThemeColor"; @@ -22,16 +22,17 @@ export function SidebarFilterButton(props: { return ( [ - styles.button, props.grouped ? { backgroundColor: pressed ? pressedBackgroundColor : "transparent", borderWidth: 0 } : { backgroundColor: pressed ? pressedBackgroundColor : idleBackgroundColor, borderColor, + borderWidth: StyleSheet.hairlineWidth, }, ]} > @@ -39,17 +40,3 @@ export function SidebarFilterButton(props: { ); } - -const styles = StyleSheet.create({ - button: { - // Match the native glass UIBarButtonItem group metrics (~50pt slots, - // 44pt bar height, label-colored ~20pt glyphs). - width: 50, - height: 44, - borderRadius: 22, - borderWidth: StyleSheet.hairlineWidth, - alignItems: "center", - justifyContent: "center", - cursor: "pointer", - }, -}); diff --git a/apps/mobile/src/features/threads/sidebar-header-actions.android.tsx b/apps/mobile/src/features/threads/sidebar-header-actions.android.tsx new file mode 100644 index 00000000000..1321c82c0d8 --- /dev/null +++ b/apps/mobile/src/features/threads/sidebar-header-actions.android.tsx @@ -0,0 +1,16 @@ +import { View } from "react-native"; + +import { T3HeaderButton } from "../../native/T3HeaderButton.android"; +import type { SidebarHeaderActionsProps } from "./sidebar-header-actions"; + +export function SidebarHeaderActions(props: SidebarHeaderActionsProps) { + return ( + + + + ); +} diff --git a/apps/mobile/src/features/threads/sidebar-header-actions.tsx b/apps/mobile/src/features/threads/sidebar-header-actions.tsx index e193f1f2d9b..b8c8525b0a3 100644 --- a/apps/mobile/src/features/threads/sidebar-header-actions.tsx +++ b/apps/mobile/src/features/threads/sidebar-header-actions.tsx @@ -1,4 +1,4 @@ -import { SymbolView } from "expo-symbols"; +import { SymbolView } from "../../components/AppSymbol"; import { Pressable, StyleSheet, View, useColorScheme } from "react-native"; import { useThemeColor } from "../../lib/useThemeColor"; @@ -24,17 +24,18 @@ function FallbackHeaderButton(props: { return ( [ - styles.button, props.grouped ? { backgroundColor: pressed ? pressedBackgroundColor : "transparent", borderWidth: 0 } : { backgroundColor: pressed ? pressedBackgroundColor : idleBackgroundColor, borderColor, + borderWidth: StyleSheet.hairlineWidth, }, ]} > @@ -45,7 +46,7 @@ function FallbackHeaderButton(props: { export function SidebarHeaderActions(props: SidebarHeaderActionsProps) { return ( - + ); } - -const styles = StyleSheet.create({ - actions: { - flexDirection: "row", - alignItems: "center", - gap: 2, - }, - button: { - // Match the native glass UIBarButtonItem group metrics. - width: 50, - height: 44, - borderRadius: 22, - borderWidth: StyleSheet.hairlineWidth, - alignItems: "center", - justifyContent: "center", - }, -}); diff --git a/apps/mobile/src/features/threads/thread-inspector-content-stack.tsx b/apps/mobile/src/features/threads/thread-inspector-content-stack.tsx index 2c8ec73342d..9b41ed4e362 100644 --- a/apps/mobile/src/features/threads/thread-inspector-content-stack.tsx +++ b/apps/mobile/src/features/threads/thread-inspector-content-stack.tsx @@ -75,7 +75,7 @@ export function ThreadInspectorContentStack(props: { const Route = props.Route; return ( - + , +) { + const dark = colorScheme === "dark"; + switch (state) { + case "open": + return dark ? "#34d399" : "#059669"; + case "merged": + return dark ? "#a78bfa" : "#7c3aed"; + case "closed": + return dark ? "#a1a1aa" : "#71717a"; + } +} + +function PullRequestIcon(props: { readonly size: number; readonly color: string }) { + return ( + + + + + + + ); +} + /* ─── Project group header ───────────────────────────────────────────── */ export const ThreadListGroupHeader = memo(function ThreadListGroupHeader(props: { @@ -94,6 +131,7 @@ export const ThreadListGroupHeader = memo(function ThreadListGroupHeader(props: > {props.title} @@ -236,7 +273,6 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: { const compact = props.variant === "compact"; const separatorColor = useThemeColor("--color-separator"); const iconSubtleColor = useThemeColor("--color-icon-subtle"); - const foregroundColor = useThemeColor("--color-foreground"); const mutedColor = useThemeColor("--color-foreground-muted"); const pressedBackgroundColor = useThemeColor("--color-subtle"); @@ -254,17 +290,14 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: { ); const statusPill = ( - + Pending ); const subtitleRow = subtitleParts.length > 0 ? ( - + {subtitleParts.join(" · ")} @@ -311,12 +347,7 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: { {statusPill} - - {timestamp} - + {timestamp} - + - + {pendingTask.title} {statusPill} - + {timestamp} @@ -408,6 +431,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: { >["simultaneousWithExternalGesture"]; }) { const { width: windowWidth } = useWindowDimensions(); + const colorScheme = useColorScheme(); const compact = props.variant === "compact"; const selected = props.selected === true; // Recycling-safe: resets when the list container is reused for another @@ -418,12 +442,8 @@ export const ThreadListRow = memo(function ThreadListRow(props: { const iconSubtleColor = useThemeColor("--color-icon-subtle"); const screenColor = useThemeColor("--color-screen"); const drawerColor = useThemeColor("--color-drawer"); - const foregroundColor = useThemeColor("--color-foreground"); - const mutedColor = useThemeColor("--color-foreground-muted"); const pressedBackgroundColor = useThemeColor("--color-subtle"); const selectedBackgroundColor = useThemeColor("--color-user-bubble"); - const selectedForegroundColor = useThemeColor("--color-user-bubble-foreground"); - const selectedMutedColor = useThemeColor("--color-user-bubble-foreground-muted"); const { thread, onSelectThread, onArchiveThread, onDeleteThread } = props; const status = resolveThreadStatus(thread); @@ -431,13 +451,12 @@ export const ThreadListRow = memo(function ThreadListRow(props: { const timestamp = relativeTime( thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt, ); + const threadAccessibilityLabel = pr ? `${thread.title}, ${pr.accessibilityLabel}` : thread.title; const subtitleParts = [props.environmentLabel, thread.branch].filter((part): part is string => Boolean(part), ); const backgroundColor = compact ? screenColor : drawerColor; - const effectiveForeground = selected ? selectedForegroundColor : foregroundColor; - const effectiveMuted = selected ? selectedMutedColor : mutedColor; const effectivePressedBackground = selected ? "rgba(255,255,255,0.16)" : pressedBackgroundColor; const effectiveStatus = selected && status @@ -464,10 +483,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: { ); const statusPill = effectiveStatus ? ( - + {effectiveStatus.label} @@ -476,32 +492,36 @@ export const ThreadListRow = memo(function ThreadListRow(props: { const subtitleRow = subtitleParts.length > 0 || pr !== null ? ( - + {subtitleParts.length > 0 ? ( <> - {subtitleParts.join(" · ")} ) : null} {pr !== null ? ( - - {pr.label} - + + + + {pr.label} + + ) : null} ) : null; @@ -510,7 +530,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: { compact ? ( { @@ -540,12 +560,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: { {statusPill} - - {timestamp} - + {timestamp} setHovered(true)} @@ -584,21 +599,25 @@ export const ThreadListRow = memo(function ThreadListRow(props: { paddingVertical: 10, })} > - + {thread.title} {statusPill} {timestamp} @@ -626,12 +645,14 @@ export const ThreadListRow = memo(function ThreadListRow(props: { threadTitle={thread.title} > {(close) => ( - // Messages-style row actions: a real UIContextMenuInteraction on - // long-press / pointer right-click, with the row as the zoom preview. - // Requires the patched @react-native-menu (see - // patches/@react-native-menu__menu@2.0.0.patch): in long-press mode - // the interaction is hosted by the component view and the underlying - // UIButton passes touches through, so row taps keep working. + // Messages-style row actions on long-press. iOS: a real + // UIContextMenuInteraction with the row as the zoom preview (needs the + // patched @react-native-menu, see + // patches/@react-native-menu__menu@2.0.0.patch — in long-press mode the + // interaction is hosted by the component view and the underlying + // UIButton passes touches through, so row taps keep working). Android: + // ControlPillMenu injects onLongPress into the row and anchors the + // token-styled dropdown to it; taps and swipes are untouched. 0 ? cleaned : null; } -function workRowSymbolName(icon: ThreadFeedActivity["icon"]): SFSymbol { +function workRowSymbolName(icon: ThreadFeedActivity["icon"]): AppSymbolName { switch (icon) { case "agent": - return "sparkles"; + return { ios: "sparkles", android: "auto_awesome" }; case "alert": - return "exclamationmark.triangle"; + return { ios: "exclamationmark.triangle", android: "error" }; case "check": - return "checkmark"; + return { ios: "checkmark", android: "check" }; case "command": - return "terminal"; + return { ios: "terminal", android: "terminal" }; case "edit": - return "square.and.pencil"; + return { ios: "square.and.pencil", android: "edit" }; case "eye": - return "eye"; + return { ios: "eye", android: "visibility" }; case "globe": - return "globe"; + return { ios: "globe", android: "public" }; case "hammer": - return "hammer"; + return { ios: "hammer", android: "construction" }; case "message": - return "bubble.left"; + return { ios: "bubble.left", android: "chat_bubble" }; case "warning": - return "xmark"; + return { ios: "xmark", android: "close" }; case "wrench": - return "wrench"; + return { ios: "wrench", android: "build" }; case "zap": - return "bolt"; + return { ios: "bolt", android: "bolt" }; } } @@ -240,7 +240,11 @@ export function ThreadWorkLog(props: { {canExpand ? ( (); diff --git a/apps/mobile/src/lib/breadcrumbPersist.ts b/apps/mobile/src/lib/breadcrumbPersist.ts new file mode 100644 index 00000000000..2c6a63c48c9 --- /dev/null +++ b/apps/mobile/src/lib/breadcrumbPersist.ts @@ -0,0 +1,102 @@ +import { Directory, File, Paths } from "expo-file-system"; +import { requireNativeModule } from "expo"; + +import { getBreadcrumbs, setBreadcrumbPersistHook } from "./breadcrumbs"; + +const CRASH_LOG_DIRECTORY = "crash-logs"; +const LAST_BREADCRUMBS_FILE = "last-breadcrumbs.json"; +/** Quiet period before a trailing flush. */ +const FLUSH_DEBOUNCE_MS = 400; +/** While activity continues, still flush at least this often. */ +const FLUSH_MAX_WAIT_MS = 400; + +let debounceTimer: ReturnType | null = null; +let maxWaitTimer: ReturnType | null = null; +let installed = false; + +export function installBreadcrumbPersistence(): void { + if (installed) { + return; + } + installed = true; + setBreadcrumbPersistHook(() => { + scheduleBreadcrumbFlush(); + }); +} + +/** Immediate flush for fatal paths; ignore errors. */ +export function flushBreadcrumbsSync(): void { + clearFlushTimers(); + writeBreadcrumbsToDisk(); +} + +function clearFlushTimers(): void { + if (debounceTimer !== null) { + clearTimeout(debounceTimer); + debounceTimer = null; + } + if (maxWaitTimer !== null) { + clearTimeout(maxWaitTimer); + maxWaitTimer = null; + } +} + +function scheduleBreadcrumbFlush(): void { + // Trailing debounce: reset on each breadcrumb. + if (debounceTimer !== null) { + clearTimeout(debounceTimer); + } + debounceTimer = setTimeout(() => { + debounceTimer = null; + // Quiet period elapsed; drop max-wait so the next burst starts a new cap. + if (maxWaitTimer !== null) { + clearTimeout(maxWaitTimer); + maxWaitTimer = null; + } + writeBreadcrumbsToDisk(); + }, FLUSH_DEBOUNCE_MS); + + // Max-wait: do not reset while activity continues, so continuous bursts still + // flush about every FLUSH_MAX_WAIT_MS (needed when a native kill skips sync flush). + if (maxWaitTimer === null) { + maxWaitTimer = setTimeout(() => { + maxWaitTimer = null; + if (debounceTimer !== null) { + clearTimeout(debounceTimer); + debounceTimer = null; + } + writeBreadcrumbsToDisk(); + }, FLUSH_MAX_WAIT_MS); + } +} + +function writeBreadcrumbsToDisk(): void { + const payload = JSON.stringify({ + breadcrumbs: getBreadcrumbs(), + updatedAt: new Date().toISOString(), + }); + const relativePath = `${CRASH_LOG_DIRECTORY}/${LAST_BREADCRUMBS_FILE}`; + + try { + const native = requireNativeModule("T3NativeControls") as { + writeSyncText?: (relativePath: string, contents: string) => boolean; + }; + if (typeof native.writeSyncText === "function") { + native.writeSyncText(relativePath, payload); + } + } catch { + // fall through to Expo FS + } + + try { + const directory = new Directory(Paths.document, CRASH_LOG_DIRECTORY); + directory.create({ idempotent: true, intermediates: true }); + const file = new File(directory, LAST_BREADCRUMBS_FILE); + if (!file.exists) { + file.create({ intermediates: true, overwrite: true }); + } + file.write(payload); + } catch { + // Best-effort only. + } +} diff --git a/apps/mobile/src/lib/breadcrumbs.test.ts b/apps/mobile/src/lib/breadcrumbs.test.ts new file mode 100644 index 00000000000..7a0f9894391 --- /dev/null +++ b/apps/mobile/src/lib/breadcrumbs.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { addBreadcrumb, breadcrumbCount, clearBreadcrumbs, getBreadcrumbs } from "./breadcrumbs"; + +describe("breadcrumbs", () => { + it("records entries in order and exposes a copy", () => { + clearBreadcrumbs(); + addBreadcrumb("nav", { path: "Home" }); + addBreadcrumb("outbox.dispatch", { messageId: "m1" }); + + expect(getBreadcrumbs()).toEqual([ + expect.objectContaining({ type: "nav", data: { path: "Home" } }), + expect.objectContaining({ type: "outbox.dispatch", data: { messageId: "m1" } }), + ]); + expect(breadcrumbCount()).toBe(2); + }); + + it("caps ring size and drops the oldest entries", () => { + clearBreadcrumbs(); + for (let index = 0; index < 100; index += 1) { + addBreadcrumb("tick", { index }); + } + + expect(breadcrumbCount()).toBe(80); + expect(getBreadcrumbs()[0]?.data?.index).toBe(20); + expect(getBreadcrumbs().at(-1)?.data?.index).toBe(99); + }); + + it("truncates long string values", () => { + clearBreadcrumbs(); + addBreadcrumb("msg", { text: "x".repeat(250) }); + + const text = getBreadcrumbs()[0]?.data?.text; + expect(typeof text).toBe("string"); + expect(String(text).length).toBeLessThanOrEqual(201); + expect(String(text).endsWith("…")).toBe(true); + }); +}); diff --git a/apps/mobile/src/lib/breadcrumbs.ts b/apps/mobile/src/lib/breadcrumbs.ts new file mode 100644 index 00000000000..7dd1c2b9cba --- /dev/null +++ b/apps/mobile/src/lib/breadcrumbs.ts @@ -0,0 +1,55 @@ +/** Compact trail kept in memory and flushed with fatal/error crash records. */ + +export type BreadcrumbData = Readonly>; + +export type Breadcrumb = { + readonly t: string; + readonly type: string; + readonly data?: BreadcrumbData; +}; + +const MAX_BREADCRUMBS = 80; + +const ring: Breadcrumb[] = []; +let onBreadcrumbAdded: (() => void) | null = null; + +/** Optional disk flusher installed by crashLog / breadcrumbPersist. */ +export function setBreadcrumbPersistHook(hook: (() => void) | null): void { + onBreadcrumbAdded = hook; +} + +export function addBreadcrumb(type: string, data?: BreadcrumbData): void { + const entry: Breadcrumb = + data === undefined + ? { t: new Date().toISOString(), type } + : { t: new Date().toISOString(), type, data: sanitizeBreadcrumbData(data) }; + ring.push(entry); + if (ring.length > MAX_BREADCRUMBS) { + ring.splice(0, ring.length - MAX_BREADCRUMBS); + } + onBreadcrumbAdded?.(); +} + +export function getBreadcrumbs(): ReadonlyArray { + return ring.slice(); +} + +export function clearBreadcrumbs(): void { + ring.length = 0; +} + +export function breadcrumbCount(): number { + return ring.length; +} + +function sanitizeBreadcrumbData(data: BreadcrumbData): BreadcrumbData { + const out: Record = {}; + for (const [key, value] of Object.entries(data)) { + if (typeof value === "string") { + out[key] = value.length > 200 ? `${value.slice(0, 200)}…` : value; + } else { + out[key] = value; + } + } + return out; +} diff --git a/apps/mobile/src/lib/composerImages.test.ts b/apps/mobile/src/lib/composerImages.test.ts index 40e00a271f7..21f2edaf52f 100644 --- a/apps/mobile/src/lib/composerImages.test.ts +++ b/apps/mobile/src/lib/composerImages.test.ts @@ -36,7 +36,37 @@ vi.mock("./uuid", () => ({ uuidv4: () => "attachment-id", })); -import { convertPastedImagesToAttachments, isOwnedPastedImageUri } from "./composerImages"; +import { + convertPastedImagesToAttachments, + isOwnedPastedImageUri, + toUploadChatImageAttachments, +} from "./composerImages"; + +describe("toUploadChatImageAttachments", () => { + it("strips client draft id and previewUri for the startTurn wire shape", () => { + expect( + toUploadChatImageAttachments([ + { + id: "client-draft-id", + type: "image", + name: "pasted-image.png", + mimeType: "image/png", + sizeBytes: 12, + dataUrl: "data:image/png;base64,AA==", + previewUri: "file:///tmp/preview.png", + }, + ]), + ).toEqual([ + { + type: "image", + name: "pasted-image.png", + mimeType: "image/png", + sizeBytes: 12, + dataUrl: "data:image/png;base64,AA==", + }, + ]); + }); +}); describe("native pasted image cleanup", () => { beforeEach(() => { diff --git a/apps/mobile/src/lib/composerImages.ts b/apps/mobile/src/lib/composerImages.ts index 13b53af724e..8aa6d2a3fa8 100644 --- a/apps/mobile/src/lib/composerImages.ts +++ b/apps/mobile/src/lib/composerImages.ts @@ -10,6 +10,19 @@ export interface DraftComposerImageAttachment extends UploadChatImageAttachment readonly previewUri: string; } +/** Wire shape for startTurn: pure uploads without client draft id / previewUri. */ +export function toUploadChatImageAttachments( + attachments: ReadonlyArray, +): ReadonlyArray { + return attachments.map((attachment) => ({ + type: attachment.type, + name: attachment.name, + mimeType: attachment.mimeType, + sizeBytes: attachment.sizeBytes, + dataUrl: attachment.dataUrl, + })); +} + const OWNED_PASTED_IMAGE_DIRECTORY = "t3-composer-paste"; function estimateBase64ByteSize(base64: string): number { diff --git a/apps/mobile/src/lib/crashLog.test.ts b/apps/mobile/src/lib/crashLog.test.ts new file mode 100644 index 00000000000..219815d8655 --- /dev/null +++ b/apps/mobile/src/lib/crashLog.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { addBreadcrumb, clearBreadcrumbs } from "./breadcrumbs"; +import { buildCrashRecord, buildMinimalCrashRecord, shouldPersistNonFatal } from "./crashLogRecord"; + +describe("crashLog records", () => { + it("captures message, stack, and breadcrumbs for fatals", () => { + clearBreadcrumbs(); + addBreadcrumb("nav", { path: "Thread" }); + addBreadcrumb("outbox.dispatch", { messageId: "m1" }); + + const error = new Error("boom"); + const record = buildCrashRecord(error, true, 7); + + expect(record.isFatal).toBe(true); + expect(record.message).toBe("boom"); + expect(record.name).toBe("Error"); + expect(record.handlerInvocation).toBe(7); + expect(record.source).toBe("error-utils"); + expect(record.stack).toContain("boom"); + expect(record.breadcrumbs.map((entry) => entry.type)).toEqual(["nav", "outbox.dispatch"]); + }); + + it("builds a minimal record without breadcrumbs for the first write", () => { + clearBreadcrumbs(); + addBreadcrumb("nav", { path: "Thread" }); + + const record = buildMinimalCrashRecord(new Error("early"), true, 1); + expect(record.breadcrumbs).toEqual([]); + expect(record.message).toBe("early"); + expect(record.source).toBe("error-utils"); + expect(record.isFatal).toBe(true); + }); + + it("stringifies non-Error values", () => { + clearBreadcrumbs(); + const record = buildCrashRecord("string-throw", false, 1); + expect(record.message).toBe("string-throw"); + expect(record.name).toBeNull(); + expect(record.stack).toBeNull(); + expect(record.isFatal).toBe(false); + }); + + it("truncates huge messages and stacks", () => { + const huge = "x".repeat(20_000); + const error = new Error(huge); + error.stack = "y".repeat(60_000); + const record = buildMinimalCrashRecord(error, true, 1); + expect(record.message.length).toBeLessThanOrEqual(8_001); + expect(record.stack?.length).toBeLessThanOrEqual(48_001); + }); + + it("only persists non-fatals that look like real Errors with stacks", () => { + expect(shouldPersistNonFatal(new Error("x"))).toBe(true); + expect(shouldPersistNonFatal("x")).toBe(false); + expect(shouldPersistNonFatal({ message: "x" })).toBe(false); + }); +}); diff --git a/apps/mobile/src/lib/crashLog.ts b/apps/mobile/src/lib/crashLog.ts new file mode 100644 index 00000000000..d1d84e27243 --- /dev/null +++ b/apps/mobile/src/lib/crashLog.ts @@ -0,0 +1,307 @@ +import { Directory, File, Paths } from "expo-file-system"; +import { requireNativeModule } from "expo"; +import type { ErrorUtils as ErrorUtilsInterface } from "react-native"; + +import { getBreadcrumbs } from "./breadcrumbs"; +import { flushBreadcrumbsSync, installBreadcrumbPersistence } from "./breadcrumbPersist"; +import { + buildCrashRecord, + buildMinimalCrashRecord, + shouldPersistNonFatal, + type CrashLogRecord, +} from "./crashLogRecord"; + +const CRASH_LOG_DIRECTORY = "crash-logs"; +const LAST_CRASH_FILE = "last-crash.json"; +const LAST_BREADCRUMBS_FILE = "last-breadcrumbs.json"; +const MAX_PERSISTED_FATAL_LOGS = 20; +const MAX_PERSISTED_NONFATAL_LOGS = 10; +const REPORTED_ON_LAUNCH_COUNT = 5; + +let crashSequence = 0; +let handlerInvocationCount = 0; +let installed = false; +let cachedNative: NativeCrashLogModule | null | undefined; + +type NativeCrashLogModule = { + installFatalHandler?: () => boolean; + writeSyncText?: (relativePath: string, contents: string) => boolean; +}; + +export type { CrashLogRecord } from "./crashLogRecord"; +export { buildCrashRecord, buildMinimalCrashRecord, shouldPersistNonFatal } from "./crashLogRecord"; + +export function installCrashLogger(): void { + if (installed) { + return; + } + installed = true; + + installBreadcrumbPersistence(); + // Re-assert native RCTFatal hooks after JS boot (covers expo-updates temp replace). + tryNativeInstallFatalHandler(); + + const errorUtils = (globalThis as { ErrorUtils?: ErrorUtilsInterface }).ErrorUtils; + if (errorUtils !== undefined) { + const previousHandler = errorUtils.getGlobalHandler(); + errorUtils.setGlobalHandler((error: unknown, isFatal?: boolean) => { + handlerInvocationCount += 1; + const fatal = isFatal === true; + const shouldPersist = fatal || shouldPersistNonFatal(error); + if (shouldPersist) { + // Write first, before any work that can throw (console, breadcrumbs, rich JSON). + persistCrashRecordBestEffort(error, fatal, handlerInvocationCount); + } + try { + emitConsoleMarker(error, fatal, handlerInvocationCount, shouldPersist); + } catch { + // Never let the logger mask the original error. + } + previousHandler(error, isFatal); + }); + } + + installUnhandledRejectionHandler(); + + // Deferred so install (which runs before the app module graph) does no + // file IO on the startup path. + setTimeout(() => { + reportAndPrunePreviousCrashes(); + }, 0); +} + +function emitConsoleMarker( + error: unknown, + isFatal: boolean, + handlerInvocation: number, + willPersist: boolean, +): void { + const cause = error instanceof Error ? error : null; + const marker = { + breadcrumbs: getBreadcrumbs().slice(-12), + handlerInvocation, + isFatal, + message: cause?.message ?? String(error), + name: cause?.name ?? null, + persist: willPersist, + stack: cause?.stack?.split("\n").slice(0, 12) ?? null, + }; + console.error("[crash-log] handler", JSON.stringify(marker)); +} + +/** + * Minimal record first (message/stack only), then enrich with breadcrumbs. + * Native RCTFatal hook is the backstop if this never runs. + */ +function persistCrashRecordBestEffort( + error: unknown, + isFatal: boolean, + handlerInvocation: number, +): void { + crashSequence += 1; + const sequence = crashSequence; + const prefix = isFatal ? "crash" : "error"; + const stampedName = `${prefix}-${Date.now()}-${sequence}.json`; + + // 1) Tiny payload that should succeed even under memory pressure. + try { + const minimal = buildMinimalCrashRecord(error, isFatal, handlerInvocation); + const encoded = stableStringify(minimal); + if (encoded !== null) { + tryNativeSyncWrite(`${CRASH_LOG_DIRECTORY}/${LAST_CRASH_FILE}`, encoded); + tryNativeSyncWrite(`${CRASH_LOG_DIRECTORY}/${stampedName}`, encoded); + } + } catch { + // continue to richer attempt + } + + // 2) Breadcrumbs + full record (best effort). + try { + flushBreadcrumbsSync(); + const full = buildCrashRecord(error, isFatal, handlerInvocation); + const encoded = stableStringify(full); + if (encoded === null) { + return; + } + tryNativeSyncWrite(`${CRASH_LOG_DIRECTORY}/${LAST_CRASH_FILE}`, encoded); + tryNativeSyncWrite(`${CRASH_LOG_DIRECTORY}/${stampedName}`, encoded); + writeExpoFsRecord(stampedName, encoded); + } catch { + // Native write may still have succeeded. + } +} + +function writeExpoFsRecord(stampedName: string, encoded: string): void { + try { + const directory = new Directory(Paths.document, CRASH_LOG_DIRECTORY); + directory.create({ idempotent: true, intermediates: true }); + const stamped = new File(directory, stampedName); + stamped.create({ intermediates: true, overwrite: true }); + stamped.write(encoded); + const last = new File(directory, LAST_CRASH_FILE); + if (!last.exists) { + last.create({ intermediates: true, overwrite: true }); + } + last.write(encoded); + } catch { + // Native write may still have succeeded. + } +} + +function stableStringify(value: unknown): string | null { + try { + return JSON.stringify(value); + } catch { + try { + const fallback: CrashLogRecord = { + breadcrumbs: [], + capturedAt: new Date().toISOString(), + handlerInvocation: 0, + isFatal: true, + message: "crash-log-stringify-failed", + name: "Error", + source: "error-utils", + stack: null, + }; + return JSON.stringify(fallback); + } catch { + return null; + } + } +} + +function getNativeModule(): NativeCrashLogModule | null { + if (cachedNative !== undefined) { + return cachedNative; + } + try { + cachedNative = requireNativeModule("T3NativeControls") as NativeCrashLogModule; + return cachedNative; + } catch { + cachedNative = null; + return null; + } +} + +function tryNativeInstallFatalHandler(): void { + try { + const native = getNativeModule(); + if (typeof native?.installFatalHandler === "function") { + native.installFatalHandler(); + } + } catch { + // Optional; AppDelegate installs the primary hook. + } +} + +function tryNativeSyncWrite(relativePath: string, contents: string): boolean { + try { + const native = getNativeModule(); + if (typeof native?.writeSyncText !== "function") { + return false; + } + return native.writeSyncText(relativePath, contents) === true; + } catch { + return false; + } +} + +function installUnhandledRejectionHandler(): void { + const target = globalThis as typeof globalThis & { + onunhandledrejection?: ((event: { reason?: unknown }) => void) | null; + addEventListener?: (type: string, listener: (event: { reason?: unknown }) => void) => void; + }; + + const onRejection = (reason: unknown): void => { + handlerInvocationCount += 1; + try { + const error = + reason instanceof Error + ? reason + : new Error(typeof reason === "string" ? reason : String(reason)); + persistCrashRecordBestEffort(error, false, handlerInvocationCount); + emitConsoleMarker(error, false, handlerInvocationCount, true); + } catch { + // Ignore logger failures. + } + }; + + if (typeof target.addEventListener === "function") { + target.addEventListener("unhandledrejection", (event) => { + onRejection(event.reason); + }); + return; + } + + const previous = target.onunhandledrejection; + target.onunhandledrejection = (event) => { + onRejection(event?.reason); + if (typeof previous === "function") { + previous.call(target, event); + } + }; +} + +function reportAndPrunePreviousCrashes(): void { + try { + try { + const last = new File(new Directory(Paths.document, CRASH_LOG_DIRECTORY), LAST_CRASH_FILE); + if (last.exists) { + console.warn("[crash-log] last-crash.json:", last.textSync()); + } + } catch { + // continue + } + + try { + const crumbs = new File( + new Directory(Paths.document, CRASH_LOG_DIRECTORY), + LAST_BREADCRUMBS_FILE, + ); + if (crumbs.exists) { + console.warn("[crash-log] last-breadcrumbs.json:", crumbs.textSync()); + } + } catch { + // optional + } + + const directory = new Directory(Paths.document, CRASH_LOG_DIRECTORY); + if (!directory.exists) { + return; + } + const files = directory + .list() + .filter( + (entry): entry is File => + entry instanceof File && + (entry.name.startsWith("crash-") || entry.name.startsWith("error-")) && + entry.name.endsWith(".json"), + ) + .sort((a, b) => a.name.localeCompare(b.name)); + + for (const file of files.slice(-REPORTED_ON_LAUNCH_COUNT)) { + try { + console.warn(`[crash-log] previous record (${file.name}):`, file.textSync()); + } catch { + // Skip unreadable records. + } + } + + const fatals = files.filter((file) => file.name.startsWith("crash-")); + const nonFatals = files.filter((file) => file.name.startsWith("error-")); + pruneOldest(fatals, MAX_PERSISTED_FATAL_LOGS); + pruneOldest(nonFatals, MAX_PERSISTED_NONFATAL_LOGS); + } catch { + // Reporting past crashes must never affect startup. + } +} + +function pruneOldest(files: ReadonlyArray, keep: number): void { + for (const file of files.slice(0, Math.max(0, files.length - keep))) { + try { + file.delete(); + } catch { + // Leave undeletable records for the next prune. + } + } +} diff --git a/apps/mobile/src/lib/crashLogRecord.ts b/apps/mobile/src/lib/crashLogRecord.ts new file mode 100644 index 00000000000..b4ca72f200d --- /dev/null +++ b/apps/mobile/src/lib/crashLogRecord.ts @@ -0,0 +1,97 @@ +import { getBreadcrumbs, type Breadcrumb } from "./breadcrumbs"; + +export type CrashLogRecord = { + readonly breadcrumbs: ReadonlyArray; + readonly capturedAt: string; + readonly handlerInvocation: number; + readonly isFatal: boolean; + readonly message: string; + readonly name: string | null; + readonly source?: string; + readonly stack: string | null; +}; + +const MAX_MESSAGE_CHARS = 8_000; +const MAX_STACK_CHARS = 48_000; + +export function buildMinimalCrashRecord( + error: unknown, + isFatal: boolean, + handlerInvocation: number, +): CrashLogRecord { + return { + breadcrumbs: [], + capturedAt: new Date().toISOString(), + handlerInvocation, + isFatal, + message: truncate(safeMessage(error), MAX_MESSAGE_CHARS), + name: safeName(error), + source: "error-utils", + stack: truncateNullable(safeStack(error), MAX_STACK_CHARS), + }; +} + +export function buildCrashRecord( + error: unknown, + isFatal: boolean, + handlerInvocation: number, +): CrashLogRecord { + return { + breadcrumbs: getBreadcrumbs(), + capturedAt: new Date().toISOString(), + handlerInvocation, + isFatal, + message: truncate(safeMessage(error), MAX_MESSAGE_CHARS), + name: safeName(error), + source: "error-utils", + stack: truncateNullable(safeStack(error), MAX_STACK_CHARS), + }; +} + +export function shouldPersistNonFatal(error: unknown): boolean { + return ( + error instanceof Error && + typeof error.stack === "string" && + error.stack.length > 0 && + error.message.length > 0 + ); +} + +function safeMessage(error: unknown): string { + if (error instanceof Error) { + return error.message.length > 0 ? error.message : error.name || "Error"; + } + if (typeof error === "string") { + return error; + } + try { + return String(error); + } catch { + return "unknown-error"; + } +} + +function safeName(error: unknown): string | null { + if (error instanceof Error) { + return error.name || null; + } + return null; +} + +function safeStack(error: unknown): string | null { + if (error instanceof Error && typeof error.stack === "string") { + return error.stack; + } + return null; +} + +function truncate(text: string, max: number): string { + return text.length > max ? `${text.slice(0, max)}…` : text; +} + +function truncateNullable(text: string | null, max: number): string | null { + if (text === null) { + return null; + } + return truncate(text, max); +} diff --git a/apps/mobile/src/lib/installCrashLog.ts b/apps/mobile/src/lib/installCrashLog.ts new file mode 100644 index 00000000000..ac3d1b76558 --- /dev/null +++ b/apps/mobile/src/lib/installCrashLog.ts @@ -0,0 +1,3 @@ +import { installCrashLogger } from "./crashLog"; + +installCrashLogger(); diff --git a/apps/mobile/src/lib/navigationBreadcrumb.test.ts b/apps/mobile/src/lib/navigationBreadcrumb.test.ts new file mode 100644 index 00000000000..ffa35664f71 --- /dev/null +++ b/apps/mobile/src/lib/navigationBreadcrumb.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { routePathFromNavigationState } from "./navigationBreadcrumb"; + +describe("routePathFromNavigationState", () => { + it("joins nested active routes", () => { + expect( + routePathFromNavigationState({ + index: 0, + routes: [ + { + key: "root", + name: "Root", + state: { + index: 1, + routes: [ + { key: "home", name: "Home" }, + { key: "thread", name: "Thread" }, + ], + }, + }, + ], + }), + ).toBe("Root/Thread"); + }); + + it("returns empty for missing state", () => { + expect(routePathFromNavigationState(undefined)).toBe(""); + }); +}); diff --git a/apps/mobile/src/lib/navigationBreadcrumb.ts b/apps/mobile/src/lib/navigationBreadcrumb.ts new file mode 100644 index 00000000000..6b23e7b4073 --- /dev/null +++ b/apps/mobile/src/lib/navigationBreadcrumb.ts @@ -0,0 +1,27 @@ +import type { NavigationState, PartialState } from "@react-navigation/native"; + +import { addBreadcrumb } from "./breadcrumbs"; + +type NavState = NavigationState | PartialState | undefined; + +/** Best-effort route path for crash breadcrumbs (e.g. Root/Thread). */ +export function routePathFromNavigationState(state: NavState): string { + if (state === undefined || !("routes" in state) || state.routes === undefined) { + return ""; + } + const index = "index" in state && typeof state.index === "number" ? state.index : 0; + const route = state.routes[index]; + if (route === undefined) { + return ""; + } + const nested = routePathFromNavigationState(route.state as NavState); + return nested.length > 0 ? `${route.name}/${nested}` : String(route.name); +} + +export function recordNavigationBreadcrumb(state: NavState): void { + const path = routePathFromNavigationState(state); + if (path.length === 0) { + return; + } + addBreadcrumb("nav", { path }); +} diff --git a/apps/mobile/src/lib/projectThreadStartTurn.ts b/apps/mobile/src/lib/projectThreadStartTurn.ts index 1330ffddbff..310f1818590 100644 --- a/apps/mobile/src/lib/projectThreadStartTurn.ts +++ b/apps/mobile/src/lib/projectThreadStartTurn.ts @@ -8,7 +8,7 @@ import { type RuntimeMode, } from "@t3tools/contracts"; -import type { DraftComposerImageAttachment } from "./composerImages"; +import { toUploadChatImageAttachments, type DraftComposerImageAttachment } from "./composerImages"; export function deriveThreadTitleFromPrompt(value: string): string { const trimmed = value.trim(); @@ -56,7 +56,7 @@ export function buildProjectThreadStartTurnInput(spec: ProjectThreadStartTurnSpe messageId: MessageId.make(spec.messageId), role: "user" as const, text: spec.text, - attachments: spec.attachments, + attachments: toUploadChatImageAttachments(spec.attachments), }, modelSelection: spec.modelSelection, titleSeed: title, diff --git a/apps/mobile/src/lib/runtime.ts b/apps/mobile/src/lib/runtime.ts index 51a4885562c..98730edfbfc 100644 --- a/apps/mobile/src/lib/runtime.ts +++ b/apps/mobile/src/lib/runtime.ts @@ -8,6 +8,7 @@ import { cryptoLayer } from "../features/cloud/dpop"; import { managedRelayClientLayer } from "../features/cloud/managedRelayLayer"; import { resolveCloudPublicConfig } from "../features/cloud/publicConfig"; import { tracingLayer } from "../features/observability/tracing"; +import * as Persistence from "../persistence/layer"; function configuredRelayUrl(): string { return resolveCloudPublicConfig().relay.url ?? "http://relay.invalid"; @@ -20,6 +21,7 @@ type RuntimeLayerSource = | typeof Socket.layerWebSocketConstructorGlobal | typeof cryptoLayer | typeof httpClientLayer + | typeof Persistence.layer | typeof tracingLayer; const runtimeLayer = Layer.merge( @@ -29,6 +31,7 @@ const runtimeLayer = Layer.merge( Layer.provideMerge(cryptoLayer), Layer.provideMerge(httpClientLayer), Layer.provideMerge(tracingLayer.pipe(Layer.provide(httpClientLayer))), + Layer.provideMerge(Persistence.layer), ); export const runtime: ManagedRuntime.ManagedRuntime< diff --git a/apps/mobile/src/lib/storage.test.ts b/apps/mobile/src/lib/storage.test.ts index 084f9430d08..a97252c7b72 100644 --- a/apps/mobile/src/lib/storage.test.ts +++ b/apps/mobile/src/lib/storage.test.ts @@ -3,34 +3,103 @@ import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; const mocks = vi.hoisted(() => { const values = new Map(); + let preferencesJson: string | null = null; + let preferencesUpdatedAt = 0; + let loadPreferencesFails = false; + let savePreferencesFails = false; return { - clear: () => values.clear(), + clear: () => { + values.clear(); + preferencesJson = null; + preferencesUpdatedAt = 0; + loadPreferencesFails = false; + savePreferencesFails = false; + }, + getStoredValue: (key: string) => values.get(key) ?? null, + getPreferencesJson: () => preferencesJson, + setPreferencesJson: (value: string, updatedAt: number) => { + preferencesJson = value; + preferencesUpdatedAt = updatedAt; + }, + setDatabaseFailures: (load: boolean, save: boolean) => { + loadPreferencesFails = load; + savePreferencesFails = save; + }, getItemAsync: vi.fn((key: string) => Promise.resolve(values.get(key) ?? null)), setItemAsync: vi.fn((key: string, value: string) => { values.set(key, value); return Promise.resolve(); }), + deleteItemAsync: vi.fn((key: string) => { + values.delete(key); + return Promise.resolve(); + }), + database: { + closeAsync: vi.fn(() => Promise.resolve()), + execAsync: vi.fn(() => Promise.resolve()), + withExclusiveTransactionAsync: vi.fn( + (run: (transaction: { execAsync: () => Promise }) => Promise) => + run({ execAsync: () => Promise.resolve() }), + ), + getFirstAsync: vi.fn((sql: string) => { + if (sql.includes("PRAGMA user_version")) { + return Promise.resolve({ user_version: 1 }); + } + if (loadPreferencesFails) { + return Promise.reject(new Error("database unavailable")); + } + return Promise.resolve( + preferencesJson === null + ? null + : { payload: preferencesJson, updatedAt: preferencesUpdatedAt }, + ); + }), + runAsync: vi.fn((_sql: string, payload?: unknown, updatedAt?: unknown) => { + if (savePreferencesFails) { + return Promise.reject(new Error("database unavailable")); + } + if (typeof payload === "string") { + preferencesJson = payload; + } + if (typeof updatedAt === "number") { + preferencesUpdatedAt = updatedAt; + } + return Promise.resolve(); + }), + }, }; }); vi.mock("expo-secure-store", () => ({ + deleteItemAsync: mocks.deleteItemAsync, getItemAsync: mocks.getItemAsync, setItemAsync: mocks.setItemAsync, })); +vi.mock("expo-sqlite", () => ({ + openDatabaseAsync: vi.fn(() => Promise.resolve(mocks.database)), +})); + +vi.mock("expo-crypto", () => ({ + getRandomBytes: vi.fn(() => new Uint8Array(16)), +})); + +vi.mock("expo-constants", () => ({ + default: { expoConfig: { extra: {} } }, +})); + vi.mock("react-native", () => ({ Platform: { OS: "ios", }, })); -vi.mock("./runtime", () => ({ - runtime: { - runPromise: vi.fn(), - }, -})); - -import { loadSavedConnections, saveConnection } from "./storage"; +import { + loadPreferences, + loadSavedConnections, + saveConnection, + savePreferencesPatch, +} from "../persistence/imperative"; import { toStableSavedRemoteConnection } from "./connection"; const managedConnection = { @@ -100,4 +169,75 @@ describe("mobile connection storage", () => { warn.mockRestore(); }); + + it("loads legacy preferences when SQLite is unavailable", async () => { + mocks.setDatabaseFailures(true, true); + await mocks.setItemAsync("t3code.preferences", JSON.stringify({ baseFontSize: 17 })); + + await expect(loadPreferences()).resolves.toEqual({ baseFontSize: 17 }); + }); + + it("falls back to secure storage when SQLite cannot save preferences", async () => { + mocks.setDatabaseFailures(true, true); + await expect(savePreferencesPatch({ baseFontSize: 19 })).resolves.toEqual({ baseFontSize: 19 }); + const fallback = JSON.parse(mocks.getStoredValue("t3code.preferences.fallback") ?? "") as { + readonly payload: string; + readonly updatedAt: number; + }; + expect(JSON.parse(fallback.payload)).toEqual({ baseFontSize: 19 }); + expect(fallback.updatedAt).toEqual(expect.any(Number)); + }); + + it("reconciles fallback preferences after SQLite recovers", async () => { + mocks.setPreferencesJson(JSON.stringify({ baseFontSize: 15 }), 10); + await mocks.setItemAsync( + "t3code.preferences.fallback", + JSON.stringify({ + payload: JSON.stringify({ baseFontSize: 19 }), + updatedAt: 20, + }), + ); + + await expect(loadPreferences()).resolves.toEqual({ baseFontSize: 19 }); + expect(JSON.parse(mocks.getPreferencesJson() ?? "")).toEqual({ baseFontSize: 19 }); + expect(mocks.getStoredValue("t3code.preferences.fallback")).toBeNull(); + }); + + it("ignores a stale fallback when its previous deletion failed", async () => { + mocks.setPreferencesJson(JSON.stringify({ baseFontSize: 21 }), 30); + await mocks.setItemAsync( + "t3code.preferences.fallback", + JSON.stringify({ + payload: JSON.stringify({ baseFontSize: 19 }), + updatedAt: 20, + }), + ); + + await expect(loadPreferences()).resolves.toEqual({ baseFontSize: 21 }); + expect(JSON.parse(mocks.getPreferencesJson() ?? "")).toEqual({ baseFontSize: 21 }); + expect(mocks.getStoredValue("t3code.preferences.fallback")).toBeNull(); + }); + + it("ignores an invalid fallback even when it has a newer timestamp", async () => { + mocks.setPreferencesJson(JSON.stringify({ baseFontSize: 21 }), 30); + await mocks.setItemAsync( + "t3code.preferences.fallback", + JSON.stringify({ payload: "{", updatedAt: 40 }), + ); + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + + await expect(loadPreferences()).resolves.toEqual({ baseFontSize: 21 }); + expect(JSON.parse(mocks.getPreferencesJson() ?? "")).toEqual({ baseFontSize: 21 }); + expect(mocks.getStoredValue("t3code.preferences.fallback")).toBeNull(); + + warn.mockRestore(); + }); + + it("keeps SQLite authoritative when stale legacy preferences remain", async () => { + mocks.setPreferencesJson(JSON.stringify({ baseFontSize: 21 }), 30); + await mocks.setItemAsync("t3code.preferences", JSON.stringify({ baseFontSize: 19 })); + + await expect(loadPreferences()).resolves.toEqual({ baseFontSize: 21 }); + expect(JSON.parse(mocks.getPreferencesJson() ?? "")).toEqual({ baseFontSize: 21 }); + }); }); diff --git a/apps/mobile/src/lib/storage.ts b/apps/mobile/src/lib/storage.ts deleted file mode 100644 index ae73ac7b4d9..00000000000 --- a/apps/mobile/src/lib/storage.ts +++ /dev/null @@ -1,291 +0,0 @@ -import * as Arr from "effect/Array"; -import { pipe } from "effect/Function"; -import * as Schema from "effect/Schema"; -import * as SecureStore from "expo-secure-store"; -import { EnvironmentId } from "@t3tools/contracts"; - -import { - isRelayManagedConnection, - type SavedRemoteConnection, - toStableSavedRemoteConnection, -} from "./connection"; - -const CONNECTIONS_KEY = "t3code.connections"; -const PREFERENCES_KEY = "t3code.preferences"; -const AGENT_AWARENESS_DEVICE_ID_KEY = "t3code.agent-awareness.device-id"; -const AGENT_AWARENESS_REGISTRATION_KEY = "t3code.agent-awareness.registration"; -const MobileStorageKey = Schema.Literals([ - CONNECTIONS_KEY, - PREFERENCES_KEY, - AGENT_AWARENESS_DEVICE_ID_KEY, - AGENT_AWARENESS_REGISTRATION_KEY, -]); -type MobileStorageKeyValue = typeof MobileStorageKey.Type; - -export class MobileSecureStorageError extends Schema.TaggedErrorClass()( - "MobileSecureStorageError", - { - operation: Schema.Literals(["read", "write", "generate-device-id"]), - key: MobileStorageKey, - cause: Schema.Defect(), - }, -) { - override get message(): string { - return `Mobile secure storage operation ${this.operation} failed for key ${this.key}.`; - } -} - -export class MobileStorageDecodeError extends Schema.TaggedErrorClass()( - "MobileStorageDecodeError", - { - key: MobileStorageKey, - cause: Schema.Defect(), - }, -) { - override get message(): string { - return `Failed to decode mobile storage value for key ${this.key}.`; - } -} - -export class MobileStorageEncodeError extends Schema.TaggedErrorClass()( - "MobileStorageEncodeError", - { - key: MobileStorageKey, - cause: Schema.Defect(), - }, -) { - override get message(): string { - return `Failed to encode mobile storage value for key ${this.key}.`; - } -} - -export interface Preferences { - readonly liveActivitiesEnabled?: boolean; - readonly baseFontSize?: number; - /** Terminal font size override; null/absent means derived from baseFontSize. */ - readonly terminalFontSize?: number | null; - /** Legacy key predating baseFontSize; read once for migration. */ - readonly markdownFontSize?: number; - /** Code/diff font size override; null/absent means derived from baseFontSize. */ - readonly codeFontSize?: number | null; - readonly codeWordBreak?: boolean; - /** Cloud account ids that opted out of the T3 Connect onboarding sheet. */ - readonly connectOnboardingOptOutAccounts?: ReadonlyArray; -} - -async function readStorageItem(key: MobileStorageKeyValue): Promise { - try { - return await SecureStore.getItemAsync(key); - } catch (cause) { - throw new MobileSecureStorageError({ operation: "read", key, cause }); - } -} - -async function writeStorageItem(key: MobileStorageKeyValue, value: string): Promise { - try { - await SecureStore.setItemAsync(key, value); - } catch (cause) { - throw new MobileSecureStorageError({ operation: "write", key, cause }); - } -} - -async function readJsonStorageItem(key: MobileStorageKeyValue): Promise { - const raw = (await readStorageItem(key)) ?? ""; - if (!raw.trim()) { - return null; - } - - try { - return JSON.parse(raw) as T; - } catch (cause) { - console.warn( - "[mobile-storage] ignored invalid JSON", - new MobileStorageDecodeError({ key, cause }), - ); - return null; - } -} - -async function writeJsonStorageItem(key: MobileStorageKeyValue, value: unknown) { - let encoded: string; - try { - encoded = JSON.stringify(value); - } catch (cause) { - throw new MobileStorageEncodeError({ key, cause }); - } - await writeStorageItem(key, encoded); -} - -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() || isRelayManagedConnection(c)), - ), - ); -} - -export async function saveConnection(connection: SavedRemoteConnection): Promise { - const current = await loadSavedConnections(); - const stableConnection = toStableSavedRemoteConnection(connection); - const next = current.some((entry) => entry.environmentId === connection.environmentId) - ? pipe( - current, - Arr.map((entry) => - entry.environmentId === connection.environmentId ? stableConnection : entry, - ), - ) - : pipe(current, Arr.append(stableConnection)); - - await writeJsonStorageItem(CONNECTIONS_KEY, { connections: next }); -} - -export async function clearSavedConnection(environmentId: EnvironmentId): Promise { - const current = await loadSavedConnections(); - const next = pipe( - current, - Arr.filter((entry) => entry.environmentId !== environmentId), - ); - await writeJsonStorageItem(CONNECTIONS_KEY, { connections: next }); -} - -export async function loadPreferences(): Promise { - const parsed = await readJsonStorageItem(PREFERENCES_KEY); - if (!parsed || typeof parsed !== "object") { - return {}; - } - - const preferences: { - liveActivitiesEnabled?: boolean; - baseFontSize?: number; - terminalFontSize?: number | null; - markdownFontSize?: number; - codeFontSize?: number | null; - codeWordBreak?: boolean; - connectOnboardingOptOutAccounts?: ReadonlyArray; - } = {}; - - if (typeof parsed.liveActivitiesEnabled === "boolean") { - preferences.liveActivitiesEnabled = parsed.liveActivitiesEnabled; - } - if (typeof parsed.baseFontSize === "number") { - preferences.baseFontSize = parsed.baseFontSize; - } - if (typeof parsed.terminalFontSize === "number" || parsed.terminalFontSize === null) { - preferences.terminalFontSize = parsed.terminalFontSize; - } - if (typeof parsed.markdownFontSize === "number") { - preferences.markdownFontSize = parsed.markdownFontSize; - } - if (typeof parsed.codeFontSize === "number" || parsed.codeFontSize === null) { - preferences.codeFontSize = parsed.codeFontSize; - } - if (typeof parsed.codeWordBreak === "boolean") { - preferences.codeWordBreak = parsed.codeWordBreak; - } - if (Array.isArray(parsed.connectOnboardingOptOutAccounts)) { - preferences.connectOnboardingOptOutAccounts = parsed.connectOnboardingOptOutAccounts.filter( - (account): account is string => typeof account === "string", - ); - } - - return preferences; -} - -// Preference writes are read-modify-write over one JSON blob; concurrent -// writers would drop each other's fields, so all writes are serialized here. -let preferencesWriteQueue: Promise = Promise.resolve(); - -export async function updatePreferences( - update: (current: Preferences) => Partial, -): Promise { - const task = preferencesWriteQueue.then(async () => { - const current = await loadPreferences(); - const next: Preferences = { - ...current, - ...update(current), - }; - await writeJsonStorageItem(PREFERENCES_KEY, next); - return next; - }); - preferencesWriteQueue = task.catch(() => undefined); - return task; -} - -export async function savePreferencesPatch(patch: Partial): Promise { - return updatePreferences(() => patch); -} - -export async function loadOrCreateAgentAwarenessDeviceId(): Promise { - const existing = await readStorageItem(AGENT_AWARENESS_DEVICE_ID_KEY); - if (existing?.trim()) { - return existing; - } - - const deviceId = await import("./uuid") - .then(({ uuidv4 }) => uuidv4()) - .catch((cause) => { - throw new MobileSecureStorageError({ - operation: "generate-device-id", - key: AGENT_AWARENESS_DEVICE_ID_KEY, - cause, - }); - }); - await writeStorageItem(AGENT_AWARENESS_DEVICE_ID_KEY, deviceId); - return deviceId; -} - -export async function loadAgentAwarenessDeviceId(): Promise { - const existing = await readStorageItem(AGENT_AWARENESS_DEVICE_ID_KEY); - return existing?.trim() ? existing : null; -} - -export interface AgentAwarenessRegistrationRecord { - readonly identity: string; - readonly signature: string; - // Last push-to-start token the relay accepted. Registrations triggered - // without a token event merge it back in so token absence never reads as a - // change (which would defeat the register-once skip every launch). - readonly pushToStartToken?: string; -} - -// Remembers the account identity and payload signature the relay last accepted -// so the app does not re-register on every launch while nothing has changed. -// Cleared only on sign-out. -export async function loadAgentAwarenessRegistrationRecord(): Promise { - const parsed = await readJsonStorageItem( - AGENT_AWARENESS_REGISTRATION_KEY, - ); - if ( - !parsed || - typeof parsed !== "object" || - typeof parsed.identity !== "string" || - typeof parsed.signature !== "string" - ) { - return null; - } - return { - identity: parsed.identity, - signature: parsed.signature, - ...(typeof parsed.pushToStartToken === "string" && parsed.pushToStartToken - ? { pushToStartToken: parsed.pushToStartToken } - : {}), - }; -} - -export async function saveAgentAwarenessRegistrationRecord( - record: AgentAwarenessRegistrationRecord, -): Promise { - await writeJsonStorageItem(AGENT_AWARENESS_REGISTRATION_KEY, record); -} - -export async function clearAgentAwarenessRegistrationRecord(): Promise { - await writeStorageItem(AGENT_AWARENESS_REGISTRATION_KEY, ""); -} diff --git a/apps/mobile/src/lib/useFontFamily.ts b/apps/mobile/src/lib/useFontFamily.ts new file mode 100644 index 00000000000..09805ae1154 --- /dev/null +++ b/apps/mobile/src/lib/useFontFamily.ts @@ -0,0 +1,15 @@ +import { useCSSVariable } from "uniwind"; + +const FONT_FAMILY_VARIABLES = { + regular: "--font-sans", + medium: "--font-medium", + bold: "--font-bold", +} as const; + +/** + * Resolves a font family for APIs that require a style object or native prop. + * Prefer Uniwind font classes when the target component accepts `className`. + */ +export function useFontFamily(weight: keyof typeof FONT_FAMILY_VARIABLES): string { + return useCSSVariable(FONT_FAMILY_VARIABLES[weight]) as string; +} diff --git a/apps/mobile/src/native/SelectableMarkdownText.ios.tsx b/apps/mobile/src/native/SelectableMarkdownText.ios.tsx index 488766f3695..a614247a4ed 100644 --- a/apps/mobile/src/native/SelectableMarkdownText.ios.tsx +++ b/apps/mobile/src/native/SelectableMarkdownText.ios.tsx @@ -2,10 +2,13 @@ import { SelectableMarkdownText as T3SelectableMarkdownText, type SelectableMarkdownTextProps, } from "@t3tools/mobile-markdown-text/renderer"; +import { View } from "react-native"; import { highlightCodeSnippet } from "../features/review/shikiReviewHighlighter"; -type MobileSelectableMarkdownTextProps = Omit; +type MobileSelectableMarkdownTextProps = Omit & { + readonly fillWidth?: boolean; +}; export type { NativeMarkdownTextStyle, @@ -16,6 +19,21 @@ export function hasNativeSelectableMarkdownText(): boolean { return true; } -export function SelectableMarkdownText(props: MobileSelectableMarkdownTextProps) { - return ; +export function SelectableMarkdownText({ + fillWidth = false, + ...props +}: MobileSelectableMarkdownTextProps) { + const content = ( + + ); + + if (!fillWidth) { + return content; + } + + return {content}; } diff --git a/apps/mobile/src/native/SelectableMarkdownText.tsx b/apps/mobile/src/native/SelectableMarkdownText.tsx index 403f32a1de4..e536048a241 100644 --- a/apps/mobile/src/native/SelectableMarkdownText.tsx +++ b/apps/mobile/src/native/SelectableMarkdownText.tsx @@ -1,6 +1,8 @@ import type { SelectableMarkdownTextProps } from "@t3tools/mobile-markdown-text/renderer"; -type MobileSelectableMarkdownTextProps = Omit; +type MobileSelectableMarkdownTextProps = Omit & { + readonly fillWidth?: boolean; +}; export type { NativeMarkdownTextStyle, diff --git a/apps/mobile/src/native/StackHeader.tsx b/apps/mobile/src/native/StackHeader.tsx index e2708757995..bc26f38ec32 100644 --- a/apps/mobile/src/native/StackHeader.tsx +++ b/apps/mobile/src/native/StackHeader.tsx @@ -11,17 +11,21 @@ import { useEffect, useLayoutEffect, useMemo, + useRef, type ReactElement, type ReactNode, } from "react"; import type { ColorValue } from "react-native"; +import { buildNativeStackOptionsSignature } from "./nativeStackOptionsSignature"; + export { nativeHeaderScrollEdgeEffects, nativeTopScrollEdgeEffect, type NativeHeaderScrollEdgeEffects, type NativeTopScrollEdgeEffect, } from "./scrollEdgeEffects"; +export { buildNativeStackOptionsSignature } from "./nativeStackOptionsSignature"; export type AppNativeStackNavigationOptions = Omit< NativeStackNavigationOptions, @@ -67,14 +71,86 @@ export function NativeStackScreenOptions(props: { readonly name?: string; }) { const navigation = useNativeStackNavigation(); - const normalizedOptions = useMemo(() => normalizeScreenOptions(props.options), [props.options]); + const optionsRef = useRef(props.options); + optionsRef.current = props.options; + const lastSignatureRef = useRef(null); + const signature = buildNativeStackOptionsSignature(props.options); + + // Stable factories so navigation keeps one function identity while always + // reading the latest items from optionsRef. + const leftItemsFactory = useMemo( + () => () => { + const factory = optionsRef.current?.unstable_headerLeftItems; + return typeof factory === "function" ? factory() : []; + }, + [], + ); + const rightItemsFactory = useMemo( + () => () => { + const factory = optionsRef.current?.unstable_headerRightItems; + return typeof factory === "function" ? factory() : []; + }, + [], + ); + const centerItemsFactory = useMemo( + () => () => { + const factory = optionsRef.current?.unstable_headerCenterItems; + return typeof factory === "function" ? factory() : []; + }, + [], + ); + const toolbarItemsFactory = useMemo( + () => () => { + const factory = optionsRef.current?.unstable_headerToolbarItems; + return typeof factory === "function" ? factory() : []; + }, + [], + ); useLayoutEffect(() => { - if (!navigation || !normalizedOptions) { + if (!navigation || props.options === undefined) { + return; + } + if (lastSignatureRef.current === signature) { return; } - navigation.setOptions(normalizedOptions); - }, [navigation, normalizedOptions]); + lastSignatureRef.current = signature; + + const normalized = normalizeScreenOptions(props.options); + if (!normalized) { + return; + } + + const nextOptions = { ...normalized } as NativeStackNavigationOptions & { + unstable_headerCenterItems?: unknown; + unstable_headerLeftItems?: unknown; + unstable_headerRightItems?: unknown; + unstable_headerToolbarItems?: unknown; + }; + + if (typeof props.options.unstable_headerLeftItems === "function") { + nextOptions.unstable_headerLeftItems = leftItemsFactory; + } + if (typeof props.options.unstable_headerRightItems === "function") { + nextOptions.unstable_headerRightItems = rightItemsFactory; + } + if (typeof props.options.unstable_headerCenterItems === "function") { + nextOptions.unstable_headerCenterItems = centerItemsFactory; + } + if (typeof props.options.unstable_headerToolbarItems === "function") { + nextOptions.unstable_headerToolbarItems = toolbarItemsFactory; + } + + navigation.setOptions(nextOptions); + }, [ + centerItemsFactory, + leftItemsFactory, + navigation, + props.options, + rightItemsFactory, + signature, + toolbarItemsFactory, + ]); useEffect(() => { if (!navigation || !props.listeners) { diff --git a/apps/mobile/src/native/T3ComposerEditor.android.tsx b/apps/mobile/src/native/T3ComposerEditor.android.tsx new file mode 100644 index 00000000000..ccf88c2de4e --- /dev/null +++ b/apps/mobile/src/native/T3ComposerEditor.android.tsx @@ -0,0 +1,6 @@ +export { ComposerEditor } from "./T3ComposerEditor.native"; +export type { + ComposerEditorHandle, + ComposerEditorProps, + ComposerEditorSelection, +} from "./T3ComposerEditor.types"; diff --git a/apps/mobile/src/native/T3ComposerEditor.ios.tsx b/apps/mobile/src/native/T3ComposerEditor.ios.tsx index 1089ab3ac1f..4e9d62ad2c2 100644 --- a/apps/mobile/src/native/T3ComposerEditor.ios.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.ios.tsx @@ -15,6 +15,7 @@ import { Image, StyleSheet } from "react-native"; import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons"; import { resolveMarkdownFileIcon } from "@t3tools/mobile-markdown-text/links"; import { useThemeColor } from "../lib/useThemeColor"; +import { useFontFamily } from "../lib/useFontFamily"; import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole"; import { acknowledgeComposerNativeEvent, @@ -117,6 +118,7 @@ export function ComposerEditor({ const skillBorder = useThemeColor("--color-inline-skill-border"); const skillText = useThemeColor("--color-inline-skill-foreground"); const fileTint = useThemeColor("--color-icon-muted"); + const fontFamily = useFontFamily("regular"); useImperativeHandle( ref, @@ -226,9 +228,7 @@ export function ComposerEditor({ themeJson={themeJson} placeholder={props.placeholder ?? ""} fontFamily={ - typeof resolvedTextStyle.fontFamily === "string" - ? resolvedTextStyle.fontFamily - : "DMSans_400Regular" + typeof resolvedTextStyle.fontFamily === "string" ? resolvedTextStyle.fontFamily : fontFamily } fontSize={ typeof resolvedTextStyle.fontSize === "number" diff --git a/apps/mobile/src/native/T3ComposerEditor.native.tsx b/apps/mobile/src/native/T3ComposerEditor.native.tsx new file mode 100644 index 00000000000..84d1c22084f --- /dev/null +++ b/apps/mobile/src/native/T3ComposerEditor.native.tsx @@ -0,0 +1,286 @@ +import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens"; +import { requireNativeView } from "expo"; +import { + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, + type Ref, +} from "react"; +import type { NativeSyntheticEvent, StyleProp, ViewProps, ViewStyle } from "react-native"; +import { Image, StyleSheet } from "react-native"; + +import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons"; +import { resolveMarkdownFileIcon } from "@t3tools/mobile-markdown-text/links"; +import { MOBILE_TYPOGRAPHY } from "../lib/typography"; +import { useFontFamily } from "../lib/useFontFamily"; +import { useThemeColor } from "../lib/useThemeColor"; +import { + acknowledgeComposerNativeEvent, + isComposerNativeEcho, + pruneAcknowledgedComposerNativeEvents, + resolveComposerControlledEventCount, + type ComposerNativeEventSnapshot, +} from "./composerEditorRevision"; +import type { ComposerEditorProps, ComposerEditorSelection } from "./T3ComposerEditor.types"; + +const NATIVE_MODULE_NAME = "T3ComposerEditor"; +const EMPTY_SKILLS: NonNullable = []; + +type NativeEditorEvent = NativeSyntheticEvent<{ + readonly value: string; + readonly selection: ComposerEditorSelection; + readonly eventCount: number; +}>; + +type NativeSelectionEvent = NativeSyntheticEvent<{ + readonly value: string; + readonly selection: ComposerEditorSelection; + readonly eventCount: number; +}>; + +type NativePasteImagesEvent = NativeSyntheticEvent<{ + readonly uris: ReadonlyArray; +}>; + +interface NativeComposerEditorRef { + focus: () => Promise; + blur: () => Promise; + setSelection: (start: number, end: number) => Promise; +} + +interface NativeComposerEditorProps extends ViewProps { + readonly ref?: Ref; + readonly controlledDocumentJson: string; + readonly themeJson: string; + readonly placeholder: string; + readonly fontFamily: string; + readonly fontSize: number; + readonly lineHeight: number; + readonly contentInsetVertical: number; + readonly singleLineCentered: boolean; + readonly editable: boolean; + readonly scrollEnabled: boolean; + readonly autoFocus: boolean; + readonly autoCorrect: boolean; + readonly spellCheck: boolean; + readonly onComposerChange: (event: NativeEditorEvent) => void; + readonly onComposerSelectionChange?: (event: NativeSelectionEvent) => void; + readonly onComposerPasteImages?: (event: NativePasteImagesEvent) => void; + readonly onComposerFocus?: () => void; + readonly onComposerBlur?: () => void; +} + +const NativeView = requireNativeView(NATIVE_MODULE_NAME); + +function basename(path: string): string { + const separator = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")); + return separator >= 0 ? path.slice(separator + 1) : path; +} + +function fileIconUri(path: string): string { + return Image.resolveAssetSource(markdownFileIconSource(resolveMarkdownFileIcon(path))).uri; +} + +export function ComposerEditor({ + ref, + skills = EMPTY_SKILLS, + selection, + style, + textStyle, + onChangeText, + onSelectionChange, + onPasteImages, + onFocus, + onBlur, + contentInsetVertical = 0, + ...props +}: ComposerEditorProps) { + const nativeRef = useRef(null); + const mostRecentEventCountRef = useRef(0); + const [mostRecentEventCount, setMostRecentEventCount] = useState(0); + const [nativeEventSequence, setNativeEventSequence] = useState(0); + const previousRenderedEventSequenceRef = useRef(0); + const nativeEventSnapshotsRef = useRef([ + { eventCount: 0, value: props.value, selection: selection ?? null }, + ]); + const [initialConfirmedTokens] = useState(() => collectComposerInlineTokens(props.value)); + const confirmedTokensRef = useRef(initialConfirmedTokens); + const textColor = useThemeColor("--color-foreground"); + const placeholderColor = useThemeColor("--color-placeholder"); + const chipBackground = useThemeColor("--color-subtle"); + const chipBorder = useThemeColor("--color-border"); + const chipText = useThemeColor("--color-foreground"); + const skillBackground = useThemeColor("--color-inline-skill-background"); + const skillBorder = useThemeColor("--color-inline-skill-border"); + const skillText = useThemeColor("--color-inline-skill-foreground"); + const fileTint = useThemeColor("--color-icon-muted"); + + useImperativeHandle( + ref, + () => ({ + focus: () => void nativeRef.current?.focus(), + blur: () => void nativeRef.current?.blur(), + setSelection: (nextSelection) => + void nativeRef.current?.setSelection(nextSelection.start, nextSelection.end), + }), + [], + ); + + const skillLabels = useMemo( + () => new Map(skills.map((skill) => [skill.name, skill.displayName?.trim() || skill.name])), + [skills], + ); + const tokensJson = useMemo(() => { + const tokens = collectComposerInlineTokens(props.value, { + preserveTrailingFrom: confirmedTokensRef.current, + }); + confirmedTokensRef.current = tokens; + return JSON.stringify( + tokens.map((token) => ({ + type: token.type, + source: token.source, + start: token.start, + end: token.end, + label: + token.type === "skill" + ? (skillLabels.get(token.value) ?? token.value) + : basename(token.value), + iconUri: token.type === "mention" ? fileIconUri(token.value) : null, + })), + ); + }, [props.value, skillLabels]); + const includesNativeEvent = nativeEventSequence !== previousRenderedEventSequenceRef.current; + const controlledEventCount = includesNativeEvent + ? resolveComposerControlledEventCount( + props.value, + selection ?? null, + mostRecentEventCount, + nativeEventSnapshotsRef.current, + ) + : mostRecentEventCount; + const acknowledgesLatestNativeEvent = isComposerNativeEcho( + props.value, + selection ?? null, + mostRecentEventCount, + nativeEventSnapshotsRef.current, + ); + const isNativeEcho = + includesNativeEvent && + controlledEventCount === mostRecentEventCount && + acknowledgesLatestNativeEvent; + const controlledDocumentJson = JSON.stringify({ + value: props.value, + selection: isNativeEcho ? null : (selection ?? null), + tokensJson, + mostRecentEventCount: controlledEventCount, + isNativeEcho, + }); + useEffect(() => { + previousRenderedEventSequenceRef.current = nativeEventSequence; + }, [nativeEventSequence]); + useEffect(() => { + if (!acknowledgesLatestNativeEvent) return; + nativeEventSnapshotsRef.current = pruneAcknowledgedComposerNativeEvents( + nativeEventSnapshotsRef.current, + mostRecentEventCount, + ); + }, [acknowledgesLatestNativeEvent, mostRecentEventCount]); + const acceptNativeEvent = useCallback( + (eventCount: number, value: string, nextSelection: ComposerEditorSelection) => { + const acknowledgedEventCount = acknowledgeComposerNativeEvent( + mostRecentEventCountRef.current, + eventCount, + ); + if (acknowledgedEventCount === null) { + return false; + } + mostRecentEventCountRef.current = acknowledgedEventCount; + nativeEventSnapshotsRef.current.push({ + eventCount: acknowledgedEventCount, + value, + selection: nextSelection, + }); + return acknowledgedEventCount; + }, + [], + ); + const themeJson = JSON.stringify({ + text: String(textColor), + placeholder: String(placeholderColor), + chipBackground: String(chipBackground), + chipBorder: String(chipBorder), + chipText: String(chipText), + skillBackground: String(skillBackground), + skillBorder: String(skillBorder), + skillText: String(skillText), + fileTint: String(fileTint), + }); + const resolvedTextStyle = StyleSheet.flatten(textStyle) ?? {}; + const regularFontFamily = useFontFamily("regular"); + return ( + } + onComposerChange={(event) => { + const acknowledgedEventCount = acceptNativeEvent( + event.nativeEvent.eventCount, + event.nativeEvent.value, + event.nativeEvent.selection, + ); + if (acknowledgedEventCount === false) return; + onChangeText(event.nativeEvent.value); + onSelectionChange?.(event.nativeEvent.selection); + setMostRecentEventCount(acknowledgedEventCount); + setNativeEventSequence((sequence) => sequence + 1); + }} + onComposerSelectionChange={(event) => { + const acknowledgedEventCount = acceptNativeEvent( + event.nativeEvent.eventCount, + event.nativeEvent.value, + event.nativeEvent.selection, + ); + if (acknowledgedEventCount === false) return; + onSelectionChange?.(event.nativeEvent.selection); + setMostRecentEventCount(acknowledgedEventCount); + setNativeEventSequence((sequence) => sequence + 1); + }} + onComposerPasteImages={(event) => onPasteImages?.(event.nativeEvent.uris)} + onComposerFocus={onFocus} + onComposerBlur={onBlur} + /> + ); +} + +export type { + ComposerEditorHandle, + ComposerEditorProps, + ComposerEditorSelection, +} from "./T3ComposerEditor.types"; diff --git a/apps/mobile/src/native/T3ComposerEditor.tsx b/apps/mobile/src/native/T3ComposerEditor.tsx index 27e61e1b99c..e082d3892ad 100644 --- a/apps/mobile/src/native/T3ComposerEditor.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.tsx @@ -3,6 +3,7 @@ import { useImperativeHandle, useRef } from "react"; import { TextInput, type TextInput as RNTextInput } from "react-native"; import { useThemeColor } from "../lib/useThemeColor"; +import { useFontFamily } from "../lib/useFontFamily"; import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole"; import { useNativePaste } from "../lib/useNativePaste"; import type { ComposerEditorProps } from "./T3ComposerEditor.types"; @@ -15,12 +16,14 @@ export function ComposerEditor({ style, textStyle, contentInsetVertical = 0, + singleLineCentered: _singleLineCentered, ...props }: ComposerEditorProps) { const inputRef = useRef(null); const bodyText = useScaledTextRole("body"); const foregroundColor = useThemeColor("--color-foreground"); const placeholderColor = useThemeColor("--color-placeholder"); + const fontFamily = useFontFamily("regular"); const handlePaste = useNativePaste((uris) => onPasteImages?.(uris)); useImperativeHandle( @@ -48,7 +51,7 @@ export function ComposerEditor({ flex: 1, minHeight: 0, color: foregroundColor, - fontFamily: "DMSans_400Regular", + fontFamily, ...bodyText, paddingVertical: contentInsetVertical, }, diff --git a/apps/mobile/src/native/T3ComposerEditor.types.ts b/apps/mobile/src/native/T3ComposerEditor.types.ts index f7760c395ea..bfc47ed367b 100644 --- a/apps/mobile/src/native/T3ComposerEditor.types.ts +++ b/apps/mobile/src/native/T3ComposerEditor.types.ts @@ -28,6 +28,8 @@ export interface ComposerEditorProps { readonly spellCheck?: boolean; readonly multiline?: boolean; readonly contentInsetVertical?: number; + /** Android: center a single line vertically (collapsed pill); no-op on iOS. */ + readonly singleLineCentered?: boolean; readonly style?: StyleProp; readonly textStyle?: StyleProp; readonly onChangeText: (value: string) => void; diff --git a/apps/mobile/src/native/T3HeaderButton.android.tsx b/apps/mobile/src/native/T3HeaderButton.android.tsx new file mode 100644 index 00000000000..74908abd16c --- /dev/null +++ b/apps/mobile/src/native/T3HeaderButton.android.tsx @@ -0,0 +1,26 @@ +import { requireNativeView } from "expo"; +import type { NativeSyntheticEvent, StyleProp, ViewProps, ViewStyle } from "react-native"; + +interface NativeHeaderButtonProps extends ViewProps { + readonly label: string; + readonly systemImage: "gearshape" | "square.and.pencil"; + readonly onTriggered: (event: NativeSyntheticEvent>) => void; +} + +const NativeHeaderButton = requireNativeView("T3NativeControls"); + +export function T3HeaderButton(props: { + readonly accessibilityLabel: string; + readonly icon: NativeHeaderButtonProps["systemImage"]; + readonly onPress: () => void; + readonly style?: StyleProp; +}) { + return ( + + ); +} diff --git a/apps/mobile/src/native/T3KeyboardCommands.tsx b/apps/mobile/src/native/T3KeyboardCommands.tsx index 87629e6d676..1f49dd172c5 100644 --- a/apps/mobile/src/native/T3KeyboardCommands.tsx +++ b/apps/mobile/src/native/T3KeyboardCommands.tsx @@ -9,5 +9,5 @@ export function T3KeyboardCommands( readonly onCommand: (command: HardwareKeyboardCommand) => void; }>, ) { - return {props.children}; + return {props.children}; } diff --git a/apps/mobile/src/native/nativeStackOptionsSignature.test.ts b/apps/mobile/src/native/nativeStackOptionsSignature.test.ts new file mode 100644 index 00000000000..31032361878 --- /dev/null +++ b/apps/mobile/src/native/nativeStackOptionsSignature.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { buildNativeStackOptionsSignature } from "./nativeStackOptionsSignature"; + +describe("buildNativeStackOptionsSignature", () => { + it("treats fresh object/function identities with the same content as equal", () => { + const items = [ + { + identifier: "thread-right-git", + label: "Git", + type: "button", + }, + ]; + + const first = buildNativeStackOptionsSignature({ + headerTitle: "Catch-up thread", + title: "Catch-up thread", + headerBackVisible: true, + unstable_headerRightItems: () => items, + unstable_headerSubtitle: "proj · env", + }); + const second = buildNativeStackOptionsSignature({ + headerTitle: "Catch-up thread", + title: "Catch-up thread", + headerBackVisible: true, + unstable_headerRightItems: () => items, + unstable_headerSubtitle: "proj · env", + }); + + expect(first).toBe(second); + expect(first.length).toBeGreaterThan(0); + }); + + it("changes when title or item content changes", () => { + const base = buildNativeStackOptionsSignature({ + headerTitle: "A", + unstable_headerRightItems: () => [{ identifier: "a", type: "button" }], + }); + const renamed = buildNativeStackOptionsSignature({ + headerTitle: "B", + unstable_headerRightItems: () => [{ identifier: "a", type: "button" }], + }); + const reitemed = buildNativeStackOptionsSignature({ + headerTitle: "A", + unstable_headerRightItems: () => [{ identifier: "b", type: "button" }], + }); + + expect(renamed).not.toBe(base); + expect(reitemed).not.toBe(base); + }); + + it("ignores function-only differences inside header items", () => { + const withPressA = buildNativeStackOptionsSignature({ + unstable_headerRightItems: () => [ + { + identifier: "x", + onPress: () => "a", + type: "button", + }, + ], + }); + const withPressB = buildNativeStackOptionsSignature({ + unstable_headerRightItems: () => [ + { + identifier: "x", + onPress: () => "b", + type: "button", + }, + ], + }); + + expect(withPressA).toBe(withPressB); + }); +}); diff --git a/apps/mobile/src/native/nativeStackOptionsSignature.ts b/apps/mobile/src/native/nativeStackOptionsSignature.ts new file mode 100644 index 00000000000..e39ec462e86 --- /dev/null +++ b/apps/mobile/src/native/nativeStackOptionsSignature.ts @@ -0,0 +1,52 @@ +/** + * Structural signature for native stack screen options so callers can skip + * setOptions when they pass a fresh object/function identity with the same + * content. Unstable setOptions loops re-enter PreventRemoveProvider and crash + * Release builds with "Maximum update depth exceeded". + */ +export function buildNativeStackOptionsSignature(options: unknown): string { + if (options === undefined || options === null || typeof options !== "object") { + return ""; + } + const record = options as Record; + return stableJsonStringify({ + headerBackVisible: record.headerBackVisible ?? null, + headerSearchBarOptions: record.headerSearchBarOptions ?? null, + headerTintColor: record.headerTintColor === undefined ? null : String(record.headerTintColor), + headerTitle: record.headerTitle ?? null, + headerTitleStyle: record.headerTitleStyle ?? null, + title: record.title ?? null, + unstable_headerCenterItems: invokeHeaderItemsFactory(record.unstable_headerCenterItems), + unstable_headerLeftItems: invokeHeaderItemsFactory(record.unstable_headerLeftItems), + unstable_headerRightItems: invokeHeaderItemsFactory(record.unstable_headerRightItems), + unstable_headerSubtitle: record.unstable_headerSubtitle ?? null, + unstable_headerToolbarItems: invokeHeaderItemsFactory(record.unstable_headerToolbarItems), + }); +} + +function invokeHeaderItemsFactory(value: unknown): unknown { + if (typeof value !== "function") { + return value ?? null; + } + try { + return (value as () => unknown)(); + } catch { + return "[header-items-threw]"; + } +} + +function stableJsonStringify(value: unknown): string { + try { + return JSON.stringify(value, (_key, entry) => { + if (typeof entry === "function") { + return "[fn]"; + } + if (typeof entry === "symbol") { + return String(entry); + } + return entry; + }); + } catch { + return "[unserializable]"; + } +} diff --git a/apps/mobile/src/persistence/imperative.ts b/apps/mobile/src/persistence/imperative.ts new file mode 100644 index 00000000000..b66f6597c15 --- /dev/null +++ b/apps/mobile/src/persistence/imperative.ts @@ -0,0 +1,53 @@ +import * as Effect from "effect/Effect"; + +import { runtime } from "../lib/runtime"; +import * as MobilePreferences from "./mobile-preferences"; +import * as MobileStorage from "./mobile-storage"; + +export type { Preferences } from "./mobile-preferences"; +export type { AgentAwarenessRegistrationRecord, RecentThreadShortcut } from "./mobile-storage"; +export { MobilePreferencesLoadError, MobilePreferencesSaveError } from "./mobile-preferences"; +export { + MobileDeviceIdGenerationError, + MobileStorageDecodeError, + MobileStorageEncodeError, +} from "./mobile-storage"; +export { MobileSecureStorageError } from "./mobile-secure-storage"; + +const runStorage = ( + use: (storage: MobileStorage.MobileStorage["Service"]) => Effect.Effect, +) => runtime.runPromise(MobileStorage.MobileStorage.pipe(Effect.flatMap(use))); + +const runPreferences = ( + use: (store: MobilePreferences.MobilePreferencesStore["Service"]) => Effect.Effect, +) => runtime.runPromise(MobilePreferences.MobilePreferencesStore.pipe(Effect.flatMap(use))); + +export const loadSavedConnections = () => runStorage((storage) => storage.loadSavedConnections); +export const saveConnection = ( + connection: Parameters[0], +) => runStorage((storage) => storage.saveConnection(connection)); + +export const loadPreferences = () => runPreferences((store) => store.load); +export const savePreferencesPatch = (patch: Partial) => + runPreferences((store) => store.savePatch(patch)); +export const updatePreferences = ( + transform: (current: MobilePreferences.Preferences) => Partial, +) => runPreferences((store) => store.update(transform)); + +export const loadOrCreateAgentAwarenessDeviceId = () => + runStorage((storage) => storage.loadOrCreateAgentAwarenessDeviceId); +export const loadAgentAwarenessDeviceId = () => + runStorage((storage) => storage.loadAgentAwarenessDeviceId); +export const loadAgentAwarenessRegistrationRecord = () => + runStorage((storage) => storage.loadAgentAwarenessRegistrationRecord); +export const saveAgentAwarenessRegistrationRecord = ( + record: MobileStorage.AgentAwarenessRegistrationRecord, +) => runStorage((storage) => storage.saveAgentAwarenessRegistrationRecord(record)); +export const clearAgentAwarenessRegistrationRecord = () => + runStorage((storage) => storage.clearAgentAwarenessRegistrationRecord); + +export const loadRecentThreadShortcuts = () => + runStorage((storage) => storage.loadRecentThreadShortcuts); +export const saveRecentThreadShortcuts = ( + threads: ReadonlyArray, +) => runStorage((storage) => storage.saveRecentThreadShortcuts(threads)); diff --git a/apps/mobile/src/persistence/layer.ts b/apps/mobile/src/persistence/layer.ts new file mode 100644 index 00000000000..5a9d1dacff3 --- /dev/null +++ b/apps/mobile/src/persistence/layer.ts @@ -0,0 +1,16 @@ +import * as Layer from "effect/Layer"; + +import * as EnvironmentCacheStore from "../connection/environment-cache-store"; +import * as MobileDatabase from "./mobile-database"; +import * as MobilePreferences from "./mobile-preferences"; +import * as MobileSecureStorage from "./mobile-secure-storage"; +import * as MobileStorage from "./mobile-storage"; + +const baseLayer = Layer.merge(MobileDatabase.layer, MobileSecureStorage.layer); +const dependentLayer = Layer.mergeAll( + MobilePreferences.layer, + MobileStorage.layer, + EnvironmentCacheStore.layer, +).pipe(Layer.provide(baseLayer)); + +export const layer = Layer.merge(baseLayer, dependentLayer); diff --git a/apps/mobile/src/persistence/mobile-database.test.ts b/apps/mobile/src/persistence/mobile-database.test.ts new file mode 100644 index 00000000000..56c3e31657e --- /dev/null +++ b/apps/mobile/src/persistence/mobile-database.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { vi } from "vite-plus/test"; + +const openDatabaseAsync = vi.hoisted(() => vi.fn()); + +vi.mock("expo-sqlite", () => ({ openDatabaseAsync })); + +import { decodeLegacyCacheRecord, make } from "./mobile-database"; + +describe("mobile database legacy cache migration", () => { + it.effect("keeps acquisition failures typed on database operations", () => + Effect.scoped( + Effect.gen(function* () { + openDatabaseAsync.mockRejectedValueOnce(new Error("SQLite unavailable")); + + const database = yield* make; + const result = yield* Effect.result(database.loadPreferencesJson); + + expect(result).toMatchObject({ + _tag: "Failure", + failure: { _tag: "MobileDatabaseError", operation: "open" }, + }); + }), + ), + ); + + it("maps legacy thread records to their SQLite identity", () => { + const payload = JSON.stringify({ + schemaVersion: 2, + environmentId: "environment-1", + threadId: "thread-1", + snapshot: {}, + }); + + expect(decodeLegacyCacheRecord("connection-thread-snapshots", payload)).toEqual({ + environmentId: "environment-1", + kind: "thread", + cacheKey: "thread-1", + schemaVersion: 2, + payload, + }); + }); + + it("preserves the old shell payload for schema decoding after migration", () => { + const payload = JSON.stringify({ + schemaVersion: 1, + environmentId: "environment-1", + snapshotReceivedAt: "2026-07-01T00:00:00.000Z", + snapshot: {}, + }); + + expect(decodeLegacyCacheRecord("shell-snapshots", payload)).toEqual({ + environmentId: "environment-1", + kind: "shell", + cacheKey: "snapshot", + schemaVersion: 1, + payload, + }); + }); + + it("skips malformed legacy records", () => { + expect(decodeLegacyCacheRecord("connection-vcs-refs", "{not-json")).toBeNull(); + expect( + decodeLegacyCacheRecord( + "connection-vcs-refs", + JSON.stringify({ schemaVersion: 1, environmentId: "environment-1" }), + ), + ).toBeNull(); + }); +}); diff --git a/apps/mobile/src/persistence/mobile-database.ts b/apps/mobile/src/persistence/mobile-database.ts new file mode 100644 index 00000000000..33eb50f640c --- /dev/null +++ b/apps/mobile/src/persistence/mobile-database.ts @@ -0,0 +1,409 @@ +import type { EnvironmentId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import type { SQLiteDatabase } from "expo-sqlite"; + +const DATABASE_NAME = "t3code-client.db"; +const DATABASE_SCHEMA_VERSION = 1; +const LEGACY_CACHE_DIRECTORIES = [ + "connection-shell-snapshots", + "shell-snapshots", + "connection-thread-snapshots", + "connection-server-configs", + "connection-vcs-refs", +] as const; + +export const ClientCacheKind = Schema.Literals(["shell", "thread", "server-config", "vcs-refs"]); +export type ClientCacheKind = typeof ClientCacheKind.Type; + +export interface ClientCacheSummaryRow { + readonly environmentId: EnvironmentId; + readonly kind: ClientCacheKind; + readonly recordCount: number; + readonly payloadBytes: number; +} + +export interface StoredPreferencesJson { + readonly payload: string; + readonly updatedAt: number; +} + +const ClientCacheSummaryRows = Schema.Array( + Schema.Struct({ + environmentId: Schema.String, + kind: ClientCacheKind, + recordCount: Schema.Number, + payloadBytes: Schema.Number, + }), +); + +const MobileDatabaseOperation = Schema.Literals([ + "open", + "migrate", + "load-cache", + "save-cache", + "remove-cache", + "clear-environment-cache", + "clear-all-caches", + "inspect-caches", + "load-preferences", + "save-preferences", +]); + +export class MobileDatabaseError extends Schema.TaggedErrorClass()( + "MobileDatabaseError", + { + operation: MobileDatabaseOperation, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Mobile database operation failed: ${this.operation}.`; + } +} + +function databaseError(operation: typeof MobileDatabaseOperation.Type) { + return (cause: unknown) => new MobileDatabaseError({ operation, cause }); +} + +interface LegacyCacheRecord { + readonly environmentId: string; + readonly kind: ClientCacheKind; + readonly cacheKey: string; + readonly schemaVersion: number; + readonly payload: string; +} + +function objectRecord(value: unknown): Record | null { + return typeof value === "object" && value !== null ? (value as Record) : null; +} + +export function decodeLegacyCacheRecord( + directoryName: (typeof LEGACY_CACHE_DIRECTORIES)[number], + payload: string, +): LegacyCacheRecord | null { + let parsed: Record | null; + try { + parsed = objectRecord(JSON.parse(payload)); + } catch { + return null; + } + if ( + parsed === null || + typeof parsed.environmentId !== "string" || + typeof parsed.schemaVersion !== "number" + ) { + return null; + } + + switch (directoryName) { + case "connection-shell-snapshots": + case "shell-snapshots": + return { + environmentId: parsed.environmentId, + kind: "shell", + cacheKey: "snapshot", + schemaVersion: parsed.schemaVersion, + payload, + }; + case "connection-thread-snapshots": + return typeof parsed.threadId === "string" + ? { + environmentId: parsed.environmentId, + kind: "thread", + cacheKey: parsed.threadId, + schemaVersion: parsed.schemaVersion, + payload, + } + : null; + case "connection-server-configs": + return { + environmentId: parsed.environmentId, + kind: "server-config", + cacheKey: "config", + schemaVersion: parsed.schemaVersion, + payload, + }; + case "connection-vcs-refs": + return typeof parsed.cwd === "string" + ? { + environmentId: parsed.environmentId, + kind: "vcs-refs", + cacheKey: parsed.cwd, + schemaVersion: parsed.schemaVersion, + payload, + } + : null; + } +} + +async function migrateLegacyFileCaches(database: SQLiteDatabase): Promise { + try { + const { Directory, File, Paths } = await import("expo-file-system"); + let complete = true; + const listFiles = ( + directory: InstanceType, + ): Array> => + directory.list().flatMap((entry) => (entry instanceof File ? [entry] : listFiles(entry))); + + for (const directoryName of LEGACY_CACHE_DIRECTORIES) { + try { + const directory = new Directory(Paths.document, directoryName); + if (!directory.exists) continue; + for (const file of listFiles(directory)) { + const payload = await file.text(); + const record = decodeLegacyCacheRecord(directoryName, payload); + if (record === null) continue; + await database.runAsync( + `INSERT INTO client_cache + (environment_id, kind, cache_key, schema_version, payload, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT (environment_id, kind, cache_key) DO NOTHING`, + record.environmentId, + record.kind, + record.cacheKey, + record.schemaVersion, + record.payload, + Date.now(), + ); + } + directory.delete(); + } catch (cause) { + complete = false; + console.warn(`[mobile-database] could not migrate legacy cache ${directoryName}`, cause); + } + } + return complete; + } catch (cause) { + console.warn("[mobile-database] could not load legacy cache migration", cause); + return false; + } +} + +export class MobileDatabase extends Context.Service< + MobileDatabase, + { + readonly loadCache: ( + environmentId: EnvironmentId, + kind: ClientCacheKind, + cacheKey: string, + ) => Effect.Effect, MobileDatabaseError>; + readonly saveCache: ( + environmentId: EnvironmentId, + kind: ClientCacheKind, + cacheKey: string, + schemaVersion: number, + payload: string, + ) => Effect.Effect; + readonly removeCache: ( + environmentId: EnvironmentId, + kind: ClientCacheKind, + cacheKey: string, + ) => Effect.Effect; + readonly clearEnvironmentCache: ( + environmentId: EnvironmentId, + ) => Effect.Effect; + readonly clearAllCaches: Effect.Effect; + readonly inspectCaches: Effect.Effect< + ReadonlyArray, + MobileDatabaseError + >; + readonly loadPreferencesJson: Effect.Effect< + Option.Option, + MobileDatabaseError + >; + readonly savePreferencesJson: ( + payload: string, + updatedAt: number, + ) => Effect.Effect; + } +>()("@t3tools/mobile/persistence/MobileDatabase") {} + +const makeAvailable = Effect.gen(function* () { + const database = yield* Effect.acquireRelease( + Effect.tryPromise({ + try: async () => { + const SQLite = await import("expo-sqlite"); + return SQLite.openDatabaseAsync(DATABASE_NAME); + }, + catch: databaseError("open"), + }), + (openDatabase) => Effect.promise(() => openDatabase.closeAsync()).pipe(Effect.ignore), + ); + + yield* Effect.tryPromise({ + try: async () => { + await database.execAsync("PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;"); + const schema = await database.getFirstAsync<{ readonly user_version: number }>( + "PRAGMA user_version", + ); + await database.withExclusiveTransactionAsync(async (transaction) => { + await transaction.execAsync(` + CREATE TABLE IF NOT EXISTS client_cache ( + environment_id TEXT NOT NULL, + kind TEXT NOT NULL, + cache_key TEXT NOT NULL, + schema_version INTEGER NOT NULL, + payload TEXT NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (environment_id, kind, cache_key) + ) WITHOUT ROWID; + + CREATE INDEX IF NOT EXISTS client_cache_environment_updated + ON client_cache (environment_id, updated_at DESC); + + CREATE TABLE IF NOT EXISTS client_preferences ( + singleton INTEGER PRIMARY KEY NOT NULL CHECK (singleton = 1), + payload TEXT NOT NULL, + updated_at INTEGER NOT NULL + ); + `); + }); + if ((schema?.user_version ?? 0) < DATABASE_SCHEMA_VERSION) { + const migrated = await migrateLegacyFileCaches(database); + if (migrated) { + await database.execAsync(`PRAGMA user_version = ${DATABASE_SCHEMA_VERSION};`); + } + } + }, + catch: databaseError("migrate"), + }); + + return MobileDatabase.of({ + loadCache: Effect.fn("MobileDatabase.loadCache")((environmentId, kind, cacheKey) => + Effect.tryPromise({ + try: () => + database.getFirstAsync<{ readonly payload: string }>( + `SELECT payload + FROM client_cache + WHERE environment_id = ? AND kind = ? AND cache_key = ?`, + environmentId, + kind, + cacheKey, + ), + catch: databaseError("load-cache"), + }).pipe(Effect.map((row) => Option.fromNullishOr(row?.payload))), + ), + saveCache: Effect.fn("MobileDatabase.saveCache")( + (environmentId, kind, cacheKey, schemaVersion, payload) => + Effect.tryPromise({ + try: () => + database.runAsync( + `INSERT INTO client_cache + (environment_id, kind, cache_key, schema_version, payload, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT (environment_id, kind, cache_key) DO UPDATE SET + schema_version = excluded.schema_version, + payload = excluded.payload, + updated_at = excluded.updated_at`, + environmentId, + kind, + cacheKey, + schemaVersion, + payload, + Date.now(), + ), + catch: databaseError("save-cache"), + }).pipe(Effect.asVoid), + ), + removeCache: Effect.fn("MobileDatabase.removeCache")((environmentId, kind, cacheKey) => + Effect.tryPromise({ + try: () => + database.runAsync( + `DELETE FROM client_cache + WHERE environment_id = ? AND kind = ? AND cache_key = ?`, + environmentId, + kind, + cacheKey, + ), + catch: databaseError("remove-cache"), + }).pipe(Effect.asVoid), + ), + clearEnvironmentCache: Effect.fn("MobileDatabase.clearEnvironmentCache")((environmentId) => + Effect.tryPromise({ + try: () => + database.runAsync("DELETE FROM client_cache WHERE environment_id = ?", environmentId), + catch: databaseError("clear-environment-cache"), + }).pipe(Effect.asVoid), + ), + clearAllCaches: Effect.tryPromise({ + try: () => database.runAsync("DELETE FROM client_cache"), + catch: databaseError("clear-all-caches"), + }).pipe(Effect.asVoid), + inspectCaches: Effect.tryPromise({ + try: () => + database.getAllAsync(` + SELECT + environment_id AS environmentId, + kind, + COUNT(*) AS recordCount, + COALESCE(SUM(LENGTH(CAST(payload AS BLOB))), 0) AS payloadBytes + FROM client_cache + GROUP BY environment_id, kind + ORDER BY environment_id, kind + `), + catch: databaseError("inspect-caches"), + }).pipe( + Effect.flatMap(Schema.decodeUnknownEffect(ClientCacheSummaryRows)), + Effect.mapError(databaseError("inspect-caches")), + Effect.map( + (rows): ReadonlyArray => + rows.map((row) => ({ + environmentId: row.environmentId as EnvironmentId, + kind: row.kind, + recordCount: row.recordCount, + payloadBytes: row.payloadBytes, + })), + ), + ), + loadPreferencesJson: Effect.tryPromise({ + try: () => + database.getFirstAsync( + `SELECT payload, updated_at AS updatedAt + FROM client_preferences + WHERE singleton = 1`, + ), + catch: databaseError("load-preferences"), + }).pipe(Effect.map(Option.fromNullishOr)), + savePreferencesJson: Effect.fn("MobileDatabase.savePreferencesJson")((payload, updatedAt) => + Effect.tryPromise({ + try: () => + database.runAsync( + `INSERT INTO client_preferences (singleton, payload, updated_at) + VALUES (1, ?, ?) + ON CONFLICT (singleton) DO UPDATE SET + payload = excluded.payload, + updated_at = excluded.updated_at`, + payload, + updatedAt, + ), + catch: databaseError("save-preferences"), + }).pipe(Effect.asVoid), + ), + }); +}); + +function makeUnavailable(error: MobileDatabaseError): MobileDatabase["Service"] { + const fail = Effect.fail(error); + return MobileDatabase.of({ + loadCache: () => fail, + saveCache: () => fail, + removeCache: () => fail, + clearEnvironmentCache: () => fail, + clearAllCaches: fail, + inspectCaches: fail, + loadPreferencesJson: fail, + savePreferencesJson: () => fail, + }); +} + +export const make = Effect.result(makeAvailable).pipe( + Effect.map((result) => + result._tag === "Success" ? result.success : makeUnavailable(result.failure), + ), +); + +export const layer = Layer.effect(MobileDatabase, make); diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts new file mode 100644 index 00000000000..6971570b0e6 --- /dev/null +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -0,0 +1,305 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; + +import * as MobileDatabase from "./mobile-database"; +import * as MobileSecureStorage from "./mobile-secure-storage"; +import { MobileStorageDecodeError, MobileStorageEncodeError } from "./mobile-storage"; + +const PREFERENCES_KEY = "t3code.preferences"; +const PREFERENCES_FALLBACK_KEY = "t3code.preferences.fallback"; + +export interface Preferences { + readonly liveActivitiesEnabled?: boolean; + readonly baseFontSize?: number; + readonly terminalFontSize?: number | null; + readonly markdownFontSize?: number; + readonly codeFontSize?: number | null; + readonly codeWordBreak?: boolean; + readonly connectOnboardingOptOutAccounts?: ReadonlyArray; + readonly collapsedProjectGroups?: readonly string[]; +} + +export class MobilePreferencesLoadError extends Schema.TaggedErrorClass()( + "MobilePreferencesLoadError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Failed to load mobile preferences."; + } +} + +export class MobilePreferencesSaveError extends Schema.TaggedErrorClass()( + "MobilePreferencesSaveError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Failed to save mobile preferences."; + } +} + +interface PreferencesFallback { + readonly payload: string; + readonly updatedAt: number; + readonly preferences: Preferences; +} + +export class MobilePreferencesStore extends Context.Service< + MobilePreferencesStore, + { + readonly load: Effect.Effect; + readonly savePatch: ( + patch: Partial, + ) => Effect.Effect; + readonly update: ( + transform: (current: Preferences) => Partial, + ) => Effect.Effect; + } +>()("@t3tools/mobile/persistence/MobilePreferencesStore") {} + +function sanitizePreferences(parsed: Preferences): Preferences { + const preferences: { + liveActivitiesEnabled?: boolean; + baseFontSize?: number; + terminalFontSize?: number | null; + markdownFontSize?: number; + codeFontSize?: number | null; + codeWordBreak?: boolean; + connectOnboardingOptOutAccounts?: ReadonlyArray; + collapsedProjectGroups?: readonly string[]; + } = {}; + + if (typeof parsed.liveActivitiesEnabled === "boolean") { + preferences.liveActivitiesEnabled = parsed.liveActivitiesEnabled; + } + if (typeof parsed.baseFontSize === "number") preferences.baseFontSize = parsed.baseFontSize; + if (typeof parsed.terminalFontSize === "number" || parsed.terminalFontSize === null) { + preferences.terminalFontSize = parsed.terminalFontSize; + } + if (typeof parsed.markdownFontSize === "number") { + preferences.markdownFontSize = parsed.markdownFontSize; + } + if (typeof parsed.codeFontSize === "number" || parsed.codeFontSize === null) { + preferences.codeFontSize = parsed.codeFontSize; + } + if (typeof parsed.codeWordBreak === "boolean") preferences.codeWordBreak = parsed.codeWordBreak; + if (Array.isArray(parsed.connectOnboardingOptOutAccounts)) { + preferences.connectOnboardingOptOutAccounts = parsed.connectOnboardingOptOutAccounts.filter( + (account): account is string => typeof account === "string", + ); + } + if (Array.isArray(parsed.collapsedProjectGroups)) { + preferences.collapsedProjectGroups = parsed.collapsedProjectGroups.filter( + (key): key is string => typeof key === "string", + ); + } + return preferences; +} + +export const make = Effect.fn("MobilePreferencesStore.make")(function* () { + const database = yield* MobileDatabase.MobileDatabase; + const secureStorage = yield* MobileSecureStorage.MobileSecureStorage; + const lock = yield* Semaphore.make(1); + const lastUpdatedAt = yield* Ref.make(0); + + const parsePayload = (raw: string | null): Preferences | null => { + if (raw === null || !raw.trim()) return null; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (cause) { + console.warn( + "[mobile-storage] ignored invalid JSON", + new MobileStorageDecodeError({ key: PREFERENCES_KEY, cause }), + ); + return null; + } + return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) + ? (parsed as Preferences) + : null; + }; + + const parseFallback = (raw: string | null): PreferencesFallback | null => { + if (raw === null || !raw.trim()) return null; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (cause) { + console.warn( + "[mobile-storage] ignored invalid JSON", + new MobileStorageDecodeError({ key: PREFERENCES_FALLBACK_KEY, cause }), + ); + return null; + } + if ( + typeof parsed !== "object" || + parsed === null || + !("payload" in parsed) || + typeof parsed.payload !== "string" || + !("updatedAt" in parsed) || + typeof parsed.updatedAt !== "number" + ) { + return null; + } + const preferences = parsePayload(parsed.payload); + return preferences === null + ? null + : { payload: parsed.payload, updatedAt: parsed.updatedAt, preferences }; + }; + + const encode = Effect.fn("MobilePreferencesStore.encode")(function* ( + key: string, + value: unknown, + ) { + return yield* Effect.try({ + try: () => JSON.stringify(value), + catch: (cause) => new MobileStorageEncodeError({ key, cause }), + }); + }); + + const nextUpdatedAt = Ref.modify(lastUpdatedAt, (last) => { + const next = Math.max(Date.now(), last + 1); + return [next, next] as const; + }); + + const saveJson = Effect.fn("MobilePreferencesStore.saveJson")(function* ( + payload: string, + updatedAt?: number, + ) { + const timestamp = updatedAt ?? (yield* nextUpdatedAt); + yield* Ref.update(lastUpdatedAt, (last) => Math.max(last, timestamp)); + const databaseResult = yield* Effect.result(database.savePreferencesJson(payload, timestamp)); + if (databaseResult._tag === "Failure") { + yield* Effect.logWarning("Database unavailable; saving preferences to secure storage.").pipe( + Effect.annotateLogs({ cause: databaseResult.failure }), + ); + const fallback = yield* encode(PREFERENCES_FALLBACK_KEY, { payload, updatedAt: timestamp }); + yield* secureStorage.setItem(PREFERENCES_FALLBACK_KEY, fallback); + return; + } + yield* secureStorage + .removeItem(PREFERENCES_FALLBACK_KEY) + .pipe( + Effect.catch((error) => + Effect.logWarning("Could not remove the mobile preferences fallback.").pipe( + Effect.annotateLogs({ error }), + ), + ), + ); + }); + + const loadUnlocked = Effect.gen(function* () { + const databaseResult = yield* Effect.result(database.loadPreferencesJson); + const databaseAvailable = databaseResult._tag === "Success"; + const storedJson = databaseAvailable + ? databaseResult.success + : Option.none(); + if (databaseResult._tag === "Failure") { + yield* Effect.logWarning("Database unavailable; loading fallback preferences.").pipe( + Effect.annotateLogs({ cause: databaseResult.failure }), + ); + } + + const fallbackResult = yield* Effect.result(secureStorage.getItem(PREFERENCES_FALLBACK_KEY)); + let fallbackJson: string | null = null; + if (fallbackResult._tag === "Success") { + fallbackJson = fallbackResult.success; + } else if (Option.isNone(storedJson)) { + return yield* fallbackResult.failure; + } else { + yield* Effect.logWarning("Could not inspect the mobile preferences fallback.").pipe( + Effect.annotateLogs({ error: fallbackResult.failure }), + ); + } + + const fallback = parseFallback(fallbackJson); + const storedPreferences = Option.isSome(storedJson) + ? parsePayload(storedJson.value.payload) + : null; + const fallbackIsNewer = + fallback !== null && + (storedPreferences === null || + (Option.isSome(storedJson) && fallback.updatedAt > storedJson.value.updatedAt)); + + let parsed: Preferences | null = null; + if (fallbackIsNewer) { + parsed = fallback.preferences; + yield* Ref.update(lastUpdatedAt, (last) => Math.max(last, fallback.updatedAt)); + if (databaseAvailable) yield* saveJson(fallback.payload, fallback.updatedAt); + } else if (storedPreferences !== null && Option.isSome(storedJson)) { + parsed = storedPreferences; + yield* Ref.update(lastUpdatedAt, (last) => Math.max(last, storedJson.value.updatedAt)); + if (fallbackJson !== null) { + yield* secureStorage + .removeItem(PREFERENCES_FALLBACK_KEY) + .pipe( + Effect.catch((error) => + Effect.logWarning("Could not remove a stale mobile preferences fallback.").pipe( + Effect.annotateLogs({ error }), + ), + ), + ); + } + } + + if (parsed === null) { + const legacyJson = yield* secureStorage.getItem(PREFERENCES_KEY); + const legacyPreferences = parsePayload(legacyJson); + parsed = legacyPreferences; + if (legacyJson !== null && legacyPreferences !== null && databaseAvailable) { + yield* saveJson(legacyJson); + yield* secureStorage + .removeItem(PREFERENCES_KEY) + .pipe( + Effect.catch((error) => + Effect.logWarning("Could not remove migrated mobile preferences.").pipe( + Effect.annotateLogs({ error }), + ), + ), + ); + } + } + + return parsed === null ? {} : sanitizePreferences(parsed); + }); + + const load = lock + .withPermits(1)(loadUnlocked) + .pipe(Effect.mapError((cause) => new MobilePreferencesLoadError({ cause }))); + + const update = Effect.fn("MobilePreferencesStore.update")((transform) => + lock + .withPermits(1)( + Effect.gen(function* () { + const current = yield* loadUnlocked; + const patch = yield* Effect.try({ + try: () => transform(current), + catch: (cause) => new MobilePreferencesSaveError({ cause }), + }); + const next: Preferences = { ...current, ...patch }; + const payload = yield* encode(PREFERENCES_KEY, next); + yield* saveJson(payload); + return next; + }), + ) + .pipe( + Effect.mapError((cause) => + cause instanceof MobilePreferencesSaveError + ? cause + : new MobilePreferencesSaveError({ cause }), + ), + ), + ); + + return MobilePreferencesStore.of({ + load, + update, + savePatch: (patch) => update(() => patch), + }); +}); + +export const layer = Layer.effect(MobilePreferencesStore, make()); diff --git a/apps/mobile/src/persistence/mobile-secure-storage.ts b/apps/mobile/src/persistence/mobile-secure-storage.ts new file mode 100644 index 00000000000..0ab7c9e4d96 --- /dev/null +++ b/apps/mobile/src/persistence/mobile-secure-storage.ts @@ -0,0 +1,52 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as SecureStore from "expo-secure-store"; + +const MobileSecureStorageOperation = Schema.Literals(["read", "write", "delete"]); + +export class MobileSecureStorageError extends Schema.TaggedErrorClass()( + "MobileSecureStorageError", + { + operation: MobileSecureStorageOperation, + key: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Mobile secure storage operation ${this.operation} failed for key ${this.key}.`; + } +} + +export class MobileSecureStorage extends Context.Service< + MobileSecureStorage, + { + readonly getItem: (key: string) => Effect.Effect; + readonly setItem: (key: string, value: string) => Effect.Effect; + readonly removeItem: (key: string) => Effect.Effect; + } +>()("@t3tools/mobile/persistence/MobileSecureStorage") {} + +export const make = MobileSecureStorage.of({ + getItem: Effect.fn("MobileSecureStorage.getItem")((key) => + Effect.tryPromise({ + try: () => SecureStore.getItemAsync(key), + catch: (cause) => new MobileSecureStorageError({ operation: "read", key, cause }), + }), + ), + setItem: Effect.fn("MobileSecureStorage.setItem")((key, value) => + Effect.tryPromise({ + try: () => SecureStore.setItemAsync(key, value), + catch: (cause) => new MobileSecureStorageError({ operation: "write", key, cause }), + }), + ), + removeItem: Effect.fn("MobileSecureStorage.removeItem")((key) => + Effect.tryPromise({ + try: () => SecureStore.deleteItemAsync(key), + catch: (cause) => new MobileSecureStorageError({ operation: "delete", key, cause }), + }), + ), +}); + +export const layer = Layer.succeed(MobileSecureStorage, make); diff --git a/apps/mobile/src/persistence/mobile-storage.ts b/apps/mobile/src/persistence/mobile-storage.ts new file mode 100644 index 00000000000..266e8c1a9a8 --- /dev/null +++ b/apps/mobile/src/persistence/mobile-storage.ts @@ -0,0 +1,266 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import * as Arr from "effect/Array"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import { pipe } from "effect/Function"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; + +import { + isRelayManagedConnection, + type SavedRemoteConnection, + toStableSavedRemoteConnection, +} from "../lib/connection"; +import * as MobileSecureStorage from "./mobile-secure-storage"; + +const CONNECTIONS_KEY = "t3code.connections"; +const AGENT_AWARENESS_DEVICE_ID_KEY = "t3code.agent-awareness.device-id"; +const AGENT_AWARENESS_REGISTRATION_KEY = "t3code.agent-awareness.registration"; +const RECENT_THREAD_SHORTCUTS_KEY = "t3code.recent-thread-shortcuts"; + +export class MobileStorageDecodeError extends Schema.TaggedErrorClass()( + "MobileStorageDecodeError", + { + key: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to decode mobile storage value for key ${this.key}.`; + } +} + +export class MobileStorageEncodeError extends Schema.TaggedErrorClass()( + "MobileStorageEncodeError", + { + key: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to encode mobile storage value for key ${this.key}.`; + } +} + +export class MobileDeviceIdGenerationError extends Schema.TaggedErrorClass()( + "MobileDeviceIdGenerationError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Failed to generate the mobile agent-awareness device id."; + } +} + +export interface AgentAwarenessRegistrationRecord { + readonly identity: string; + readonly signature: string; + readonly pushToStartToken?: string; +} + +export interface RecentThreadShortcut { + readonly environmentId: string; + readonly threadId: string; + readonly title: string; +} + +export class MobileStorage extends Context.Service< + MobileStorage, + { + readonly loadSavedConnections: Effect.Effect< + ReadonlyArray, + MobileSecureStorage.MobileSecureStorageError + >; + readonly saveConnection: ( + connection: SavedRemoteConnection, + ) => Effect.Effect< + void, + MobileSecureStorage.MobileSecureStorageError | MobileStorageEncodeError + >; + readonly clearSavedConnection: ( + environmentId: EnvironmentId, + ) => Effect.Effect< + void, + MobileSecureStorage.MobileSecureStorageError | MobileStorageEncodeError + >; + readonly loadOrCreateAgentAwarenessDeviceId: Effect.Effect< + string, + MobileSecureStorage.MobileSecureStorageError | MobileDeviceIdGenerationError + >; + readonly loadAgentAwarenessDeviceId: Effect.Effect< + string | null, + MobileSecureStorage.MobileSecureStorageError + >; + readonly loadAgentAwarenessRegistrationRecord: Effect.Effect< + AgentAwarenessRegistrationRecord | null, + MobileSecureStorage.MobileSecureStorageError + >; + readonly saveAgentAwarenessRegistrationRecord: ( + record: AgentAwarenessRegistrationRecord, + ) => Effect.Effect< + void, + MobileSecureStorage.MobileSecureStorageError | MobileStorageEncodeError + >; + readonly clearAgentAwarenessRegistrationRecord: Effect.Effect< + void, + MobileSecureStorage.MobileSecureStorageError + >; + readonly loadRecentThreadShortcuts: Effect.Effect< + ReadonlyArray, + MobileSecureStorage.MobileSecureStorageError + >; + readonly saveRecentThreadShortcuts: ( + threads: ReadonlyArray, + ) => Effect.Effect< + void, + MobileSecureStorage.MobileSecureStorageError | MobileStorageEncodeError + >; + } +>()("@t3tools/mobile/persistence/MobileStorage") {} + +export const make = Effect.fn("MobileStorage.make")(function* () { + const secureStorage = yield* MobileSecureStorage.MobileSecureStorage; + + const parseJson = (key: string, raw: string): A | null => { + if (!raw.trim()) return null; + try { + return JSON.parse(raw) as A; + } catch (cause) { + console.warn( + "[mobile-storage] ignored invalid JSON", + new MobileStorageDecodeError({ key, cause }), + ); + return null; + } + }; + + const readJson = Effect.fn("MobileStorage.readJson")(function* (key: string) { + const raw = (yield* secureStorage.getItem(key)) ?? ""; + return parseJson(key, raw); + }); + + const writeJson = Effect.fn("MobileStorage.writeJson")(function* (key: string, value: unknown) { + const encoded = yield* Effect.try({ + try: () => JSON.stringify(value), + catch: (cause) => new MobileStorageEncodeError({ key, cause }), + }); + yield* secureStorage.setItem(key, encoded); + }); + + const loadSavedConnections = readJson<{ + readonly connections?: ReadonlyArray; + }>(CONNECTIONS_KEY).pipe( + Effect.map((parsed) => + pipe( + parsed?.connections ?? [], + Arr.filter( + (connection) => + !!connection.environmentId && + (!!connection.bearerToken?.trim() || isRelayManagedConnection(connection)), + ), + ), + ), + ); + + const saveConnection = Effect.fn("MobileStorage.saveConnection")(function* ( + connection: SavedRemoteConnection, + ) { + const current = yield* loadSavedConnections; + const stableConnection = toStableSavedRemoteConnection(connection); + const next = current.some((entry) => entry.environmentId === connection.environmentId) + ? pipe( + current, + Arr.map((entry) => + entry.environmentId === connection.environmentId ? stableConnection : entry, + ), + ) + : pipe(current, Arr.append(stableConnection)); + yield* writeJson(CONNECTIONS_KEY, { connections: next }); + }); + + const clearSavedConnection = Effect.fn("MobileStorage.clearSavedConnection")(function* ( + environmentId: EnvironmentId, + ) { + const current = yield* loadSavedConnections; + const next = pipe( + current, + Arr.filter((entry) => entry.environmentId !== environmentId), + ); + yield* writeJson(CONNECTIONS_KEY, { connections: next }); + }); + + const loadOrCreateAgentAwarenessDeviceId = Effect.gen(function* () { + const existing = yield* secureStorage.getItem(AGENT_AWARENESS_DEVICE_ID_KEY); + if (existing?.trim()) return existing; + const deviceId = yield* Effect.tryPromise({ + try: () => import("../lib/uuid").then(({ uuidv4 }) => uuidv4()), + catch: (cause) => new MobileDeviceIdGenerationError({ cause }), + }); + yield* secureStorage.setItem(AGENT_AWARENESS_DEVICE_ID_KEY, deviceId); + return deviceId; + }); + + const loadAgentAwarenessDeviceId = secureStorage + .getItem(AGENT_AWARENESS_DEVICE_ID_KEY) + .pipe(Effect.map((existing) => (existing?.trim() ? existing : null))); + + const loadAgentAwarenessRegistrationRecord = readJson( + AGENT_AWARENESS_REGISTRATION_KEY, + ).pipe( + Effect.map((parsed) => { + if ( + !parsed || + typeof parsed !== "object" || + typeof parsed.identity !== "string" || + typeof parsed.signature !== "string" + ) { + return null; + } + return { + identity: parsed.identity, + signature: parsed.signature, + ...(typeof parsed.pushToStartToken === "string" && parsed.pushToStartToken + ? { pushToStartToken: parsed.pushToStartToken } + : {}), + }; + }), + ); + + // Threads most recently opened on this device, newest first — the source + // for the launcher's dynamic "recent thread" app shortcuts. + const loadRecentThreadShortcuts = readJson<{ + readonly threads?: ReadonlyArray; + }>(RECENT_THREAD_SHORTCUTS_KEY).pipe( + Effect.map((parsed) => + pipe( + parsed?.threads ?? [], + Arr.filter( + (thread) => + typeof thread?.environmentId === "string" && + thread.environmentId.length > 0 && + typeof thread.threadId === "string" && + thread.threadId.length > 0 && + typeof thread.title === "string", + ), + ), + ), + ); + + return MobileStorage.of({ + loadSavedConnections, + saveConnection, + clearSavedConnection, + loadOrCreateAgentAwarenessDeviceId, + loadAgentAwarenessDeviceId, + loadAgentAwarenessRegistrationRecord, + saveAgentAwarenessRegistrationRecord: (record) => + writeJson(AGENT_AWARENESS_REGISTRATION_KEY, record), + clearAgentAwarenessRegistrationRecord: secureStorage.setItem( + AGENT_AWARENESS_REGISTRATION_KEY, + "", + ), + loadRecentThreadShortcuts, + saveRecentThreadShortcuts: (threads) => writeJson(RECENT_THREAD_SHORTCUTS_KEY, { threads }), + }); +}); + +export const layer = Layer.effect(MobileStorage, make()); diff --git a/apps/mobile/src/state/client-cache-state.ts b/apps/mobile/src/state/client-cache-state.ts new file mode 100644 index 00000000000..3912857b575 --- /dev/null +++ b/apps/mobile/src/state/client-cache-state.ts @@ -0,0 +1,87 @@ +import type { EnvironmentId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import { Atom } from "effect/unstable/reactivity"; + +import { type ClientCacheKind, MobileDatabase } from "../persistence/mobile-database"; +import * as Runtime from "../lib/runtime"; + +export interface EnvironmentClientCacheSummary { + readonly environmentId: EnvironmentId; + readonly recordCount: number; + readonly payloadBytes: number; + readonly kinds: Readonly>>; +} + +export interface ClientCacheSummary { + readonly recordCount: number; + readonly payloadBytes: number; + readonly environments: ReadonlyArray; +} + +export type ClientCacheClearScope = + | { readonly type: "all" } + | { readonly type: "environment"; readonly environmentId: EnvironmentId }; + +function aggregateCacheSummary( + rows: ReadonlyArray<{ + readonly environmentId: EnvironmentId; + readonly kind: ClientCacheKind; + readonly recordCount: number; + readonly payloadBytes: number; + }>, +): ClientCacheSummary { + const environments = new Map(); + let recordCount = 0; + let payloadBytes = 0; + + for (const row of rows) { + recordCount += row.recordCount; + payloadBytes += row.payloadBytes; + const current = environments.get(row.environmentId) ?? { + environmentId: row.environmentId, + recordCount: 0, + payloadBytes: 0, + kinds: {}, + }; + environments.set(row.environmentId, { + environmentId: row.environmentId, + recordCount: current.recordCount + row.recordCount, + payloadBytes: current.payloadBytes + row.payloadBytes, + kinds: { ...current.kinds, [row.kind]: row.recordCount }, + }); + } + + return { + recordCount, + payloadBytes, + environments: [...environments.values()], + }; +} + +const clientCacheRuntime = Atom.runtime(Runtime.runtimeContextLayer); + +export const clientCacheSummaryAtom = clientCacheRuntime + .atom( + MobileDatabase.pipe( + Effect.flatMap((database) => database.inspectCaches), + Effect.map(aggregateCacheSummary), + ), + ) + .pipe(Atom.withLabel("mobile:client-cache:summary")); + +export const clearClientCacheAtom = clientCacheRuntime + .fn((scope: ClientCacheClearScope, get) => + MobileDatabase.pipe( + Effect.flatMap((database) => + scope.type === "all" + ? database.clearAllCaches + : database.clearEnvironmentCache(scope.environmentId), + ), + Effect.tap(() => + Effect.sync(() => { + get.refresh(clientCacheSummaryAtom); + }), + ), + ), + ) + .pipe(Atom.withLabel("mobile:client-cache:clear")); diff --git a/apps/mobile/src/state/preferences.test.ts b/apps/mobile/src/state/preferences.test.ts new file mode 100644 index 00000000000..c53594eb230 --- /dev/null +++ b/apps/mobile/src/state/preferences.test.ts @@ -0,0 +1,272 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; +import { vi } from "vite-plus/test"; + +vi.mock("expo-secure-store", () => ({ + getItemAsync: vi.fn(), + setItemAsync: vi.fn(), +})); + +vi.mock("react-native", () => ({ + Platform: { OS: "ios" }, +})); + +vi.mock("../lib/runtime", async () => { + const Layer = await import("effect/Layer"); + return { + runtime: { runPromise: vi.fn() }, + runtimeContextLayer: Layer.empty, + }; +}); + +import type { Preferences } from "../persistence/mobile-preferences"; +import { + createMobilePreferencesState, + MobilePreferencesLoadError, + MobilePreferencesSaveError, + MobilePreferencesStore, +} from "./preferences"; + +function deferred() { + let resolve!: (value: A) => void; + const promise = new Promise((resume) => { + resolve = resume; + }); + return { promise, resolve } as const; +} + +function makePreferencesState( + service: Omit & + Partial>, +) { + const completeService = MobilePreferencesStore.of({ + ...service, + update: + service.update ?? + ((transform) => + service.load.pipe( + Effect.flatMap((current) => service.savePatch(transform(current))), + Effect.mapError((cause) => + cause._tag === "MobilePreferencesSaveError" + ? cause + : new MobilePreferencesSaveError({ cause }), + ), + )), + }); + return createMobilePreferencesState( + Atom.runtime(Layer.succeed(MobilePreferencesStore, completeService)), + ); +} + +describe("mobile preferences state", () => { + it.effect("shares one preference load across consumers", () => + Effect.gen(function* () { + const load = vi.fn(() => Promise.resolve({ baseFontSize: 17 })); + const state = makePreferencesState({ + load: Effect.promise(load), + savePatch: (patch) => Effect.succeed(patch), + }); + const registry = AtomRegistry.make(); + const unmountFirst = registry.mount(state.preferencesAtom); + const unmountSecond = registry.mount(state.preferencesAtom); + + expect( + yield* AtomRegistry.getResult(registry, state.preferencesAtom, { + suspendOnWaiting: true, + }), + ).toEqual({ baseFontSize: 17 }); + expect(load).toHaveBeenCalledTimes(1); + + unmountSecond(); + unmountFirst(); + registry.dispose(); + }), + ); + + it.effect("preserves an optimistic patch when the initial load finishes later", () => + Effect.gen(function* () { + const pendingLoad = deferred(); + const savePatch = vi.fn((patch: Partial) => Effect.succeed(patch)); + const state = makePreferencesState({ + load: Effect.promise(() => pendingLoad.promise), + savePatch, + }); + const registry = AtomRegistry.make(); + const unmountPreferences = registry.mount(state.preferencesAtom); + const unmountUpdate = registry.mount(state.updatePreferencesAtom); + + registry.set(state.updatePreferencesAtom, { + collapsedProjectGroups: ["project:new"], + }); + pendingLoad.resolve({ + baseFontSize: 18, + collapsedProjectGroups: ["project:old"], + }); + + const preferences = yield* AtomRegistry.getResult(registry, state.preferencesAtom, { + suspendOnWaiting: true, + }); + expect(preferences).toEqual({ + baseFontSize: 18, + collapsedProjectGroups: ["project:new"], + }); + expect(savePatch).toHaveBeenCalledWith({ + collapsedProjectGroups: ["project:new"], + }); + expect(AsyncResult.isFailure(registry.get(state.updatePreferencesAtom))).toBe(false); + + unmountUpdate(); + unmountPreferences(); + registry.dispose(); + }), + ); + + it.effect("falls back to empty preferences when secure storage cannot be read", () => + Effect.gen(function* () { + const state = makePreferencesState({ + load: Effect.fail( + new MobilePreferencesLoadError({ + cause: new Error("secure storage unavailable"), + }), + ), + savePatch: (patch) => Effect.succeed(patch), + }); + const registry = AtomRegistry.make(); + const unmount = registry.mount(state.preferencesAtom); + + expect( + yield* AtomRegistry.getResult(registry, state.preferencesAtom, { + suspendOnWaiting: true, + }), + ).toEqual({}); + + unmount(); + registry.dispose(); + }), + ); + + it.effect("does not roll back a newer optimistic write with the same value", () => + Effect.gen(function* () { + let saveCount = 0; + const state = makePreferencesState({ + load: Effect.succeed({ baseFontSize: 16, codeFontSize: 13 }), + savePatch: (patch) => { + saveCount += 1; + return saveCount === 1 + ? Effect.fail(new MobilePreferencesSaveError({ cause: new Error("write failed") })) + : Effect.succeed(patch); + }, + }); + const registry = AtomRegistry.make(); + const unmountPreferences = registry.mount(state.preferencesAtom); + const unmountUpdate = registry.mount(state.updatePreferencesAtom); + + yield* AtomRegistry.getResult(registry, state.preferencesAtom, { + suspendOnWaiting: true, + }); + registry.set(state.updatePreferencesAtom, { baseFontSize: 18 }); + registry.set(state.updatePreferencesAtom, { baseFontSize: 18, codeFontSize: 15 }); + + yield* Effect.promise(() => + vi.waitFor(() => { + expect(AsyncResult.isFailure(registry.get(state.updatePreferencesAtom))).toBe(false); + expect(Option.getOrThrow(AsyncResult.value(registry.get(state.preferencesAtom)))).toEqual( + { + baseFontSize: 18, + codeFontSize: 15, + }, + ); + }), + ); + + unmountUpdate(); + unmountPreferences(); + registry.dispose(); + }), + ); + + it.effect("rolls back an optimistic field when its save fails", () => + Effect.gen(function* () { + const state = makePreferencesState({ + load: Effect.succeed({ baseFontSize: 16 }), + savePatch: () => + Effect.fail(new MobilePreferencesSaveError({ cause: new Error("write failed") })), + }); + const registry = AtomRegistry.make(); + const unmountPreferences = registry.mount(state.preferencesAtom); + const unmountUpdate = registry.mount(state.updatePreferencesAtom); + + yield* AtomRegistry.getResult(registry, state.preferencesAtom, { + suspendOnWaiting: true, + }); + registry.set(state.updatePreferencesAtom, { baseFontSize: 18 }); + + yield* Effect.promise(() => + vi.waitFor(() => { + expect(Option.getOrThrow(AsyncResult.value(registry.get(state.preferencesAtom)))).toEqual( + { + baseFontSize: 16, + }, + ); + }), + ); + + unmountUpdate(); + unmountPreferences(); + registry.dispose(); + }), + ); + + it.effect("rolls back to the last confirmed value after a later save fails", () => + Effect.gen(function* () { + let saveCount = 0; + const state = makePreferencesState({ + load: Effect.succeed({ baseFontSize: 16 }), + savePatch: () => { + saveCount += 1; + return saveCount === 1 + ? Effect.succeed({ baseFontSize: 14 }) + : Effect.fail(new MobilePreferencesSaveError({ cause: new Error("write failed") })); + }, + }); + const registry = AtomRegistry.make(); + const unmountPreferences = registry.mount(state.preferencesAtom); + const unmountUpdate = registry.mount(state.updatePreferencesAtom); + + yield* AtomRegistry.getResult(registry, state.preferencesAtom, { + suspendOnWaiting: true, + }); + registry.set(state.updatePreferencesAtom, { baseFontSize: 14 }); + yield* Effect.promise(() => + vi.waitFor(() => { + expect(saveCount).toBe(1); + expect(registry.get(state.updatePreferencesAtom).waiting).toBe(false); + expect(Option.getOrThrow(AsyncResult.value(registry.get(state.preferencesAtom)))).toEqual( + { + baseFontSize: 14, + }, + ); + }), + ); + + registry.set(state.updatePreferencesAtom, { baseFontSize: 18 }); + yield* Effect.promise(() => + vi.waitFor(() => { + expect(saveCount).toBe(2); + expect(Option.getOrThrow(AsyncResult.value(registry.get(state.preferencesAtom)))).toEqual( + { + baseFontSize: 14, + }, + ); + }), + ); + + unmountUpdate(); + unmountPreferences(); + registry.dispose(); + }), + ); +}); diff --git a/apps/mobile/src/state/preferences.ts b/apps/mobile/src/state/preferences.ts new file mode 100644 index 00000000000..d173cf55be5 --- /dev/null +++ b/apps/mobile/src/state/preferences.ts @@ -0,0 +1,124 @@ +import * as Effect from "effect/Effect"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; + +import { MobilePreferencesStore, type Preferences } from "../persistence/mobile-preferences"; +import * as Runtime from "../lib/runtime"; + +export { + MobilePreferencesLoadError, + MobilePreferencesSaveError, + MobilePreferencesStore, +} from "../persistence/mobile-preferences"; + +interface OptimisticPreferences { + readonly values: Partial; + readonly versions: Partial>; +} + +/** + * Owns the device preference blob for the lifetime of the app registry. + * Optimistic patches are kept separately so writes made while persistence is + * still loading cannot be replaced by the eventual read result. + */ +export function createMobilePreferencesState(runtime: Atom.AtomRuntime) { + const storedPreferencesAtom = runtime + .atom( + MobilePreferencesStore.pipe( + Effect.flatMap((store) => store.load), + Effect.catch((error) => + Effect.logWarning("Could not load mobile preferences.", error).pipe( + Effect.as({}), + ), + ), + ), + ) + .pipe(Atom.keepAlive, Atom.withLabel("mobile:preferences:stored")); + + const optimisticPatchAtom = Atom.make({ values: {}, versions: {} }).pipe( + Atom.keepAlive, + Atom.withLabel("mobile:preferences:optimistic-patch"), + ); + const confirmedPreferencesAtom = Atom.make({}).pipe( + Atom.keepAlive, + Atom.withLabel("mobile:preferences:confirmed"), + ); + let nextPatchVersion = 0; + + const preferencesAtom = Atom.make((get) => { + const stored = get(storedPreferencesAtom); + const confirmed = get(confirmedPreferencesAtom); + const optimistic = get(optimisticPatchAtom); + return AsyncResult.map(stored, (preferences) => ({ + ...preferences, + ...confirmed, + ...optimistic.values, + })); + }).pipe(Atom.keepAlive, Atom.withLabel("mobile:preferences")); + + const updatePreferencesAtom = runtime + .fn( + (patch: Partial, get) => { + const version = ++nextPatchVersion; + const current = get(optimisticPatchAtom); + const versions = { ...current.versions }; + for (const key of Object.keys(patch) as Array) { + versions[key] = version; + } + get.set(optimisticPatchAtom, { + values: { ...current.values, ...patch }, + versions, + }); + return MobilePreferencesStore.pipe( + Effect.flatMap((store) => store.savePatch(patch)), + Effect.tap((saved) => + Effect.sync(() => { + get.set(confirmedPreferencesAtom, saved); + const optimistic = get(optimisticPatchAtom); + const values = { ...optimistic.values } as Record; + const currentVersions = { ...optimistic.versions } as Record; + for (const key of Object.keys(patch) as Array) { + if (optimistic.versions[key] === version) { + delete values[key]; + delete currentVersions[key]; + } + } + get.set(optimisticPatchAtom, { + values: values as Partial, + versions: currentVersions as Partial>, + }); + }), + ), + Effect.tapError(() => + Effect.sync(() => { + const optimistic = get(optimisticPatchAtom); + const values = { ...optimistic.values } as Record; + const currentVersions = { ...optimistic.versions } as Record; + for (const key of Object.keys(patch) as Array) { + if (optimistic.versions[key] === version) { + delete values[key]; + delete currentVersions[key]; + } + } + get.set(optimisticPatchAtom, { + values: values as Partial, + versions: currentVersions as Partial>, + }); + }), + ), + ); + }, + // The storage layer serializes preference read-modify-write operations. + // Keep every invocation alive so one preference update cannot interrupt + // another update to a different field in the shared blob. + { concurrent: true }, + ) + .pipe(Atom.keepAlive, Atom.withLabel("mobile:preferences:update")); + + return { preferencesAtom, updatePreferencesAtom } as const; +} + +const mobilePreferencesRuntime = Atom.runtime(Runtime.runtimeContextLayer); +export const mobilePreferencesState = createMobilePreferencesState(mobilePreferencesRuntime); + +export const mobilePreferencesAtom = mobilePreferencesState.preferencesAtom; +export const updateMobilePreferencesAtom = mobilePreferencesState.updatePreferencesAtom; diff --git a/apps/mobile/src/state/query.ts b/apps/mobile/src/state/query.ts index c29d01d397b..e2adffbdfb7 100644 --- a/apps/mobile/src/state/query.ts +++ b/apps/mobile/src/state/query.ts @@ -1,5 +1,6 @@ import { useAtomRefresh, useAtomValue } from "@effect/atom-react"; -import * as Cause from "effect/Cause"; +import { causeFailureMessage } from "@t3tools/client-runtime/errors"; +import type * as Cause from "effect/Cause"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; @@ -15,10 +16,7 @@ export interface EnvironmentQueryView { } function formatError(cause: Cause.Cause): string { - const error = Cause.squash(cause); - return error instanceof Error && error.message.trim().length > 0 - ? error.message - : "The environment request failed."; + return causeFailureMessage(cause, "The environment request failed."); } export function useEnvironmentQuery( diff --git a/apps/mobile/src/state/thread-pr-presentation.ts b/apps/mobile/src/state/thread-pr-presentation.ts new file mode 100644 index 00000000000..601e29fa444 --- /dev/null +++ b/apps/mobile/src/state/thread-pr-presentation.ts @@ -0,0 +1,36 @@ +import type { VcsStatusResult } from "@t3tools/contracts"; +import { resolveChangeRequestPresentation } from "@t3tools/shared/sourceControl"; + +export type ThreadPr = NonNullable; + +export interface ThreadPrPresentation { + readonly number: number; + readonly state: ThreadPr["state"]; + readonly url: string; + /** Compact pull request number label, e.g. "3774". */ + readonly label: string; + /** Full, provider-aware label for assistive technologies. */ + readonly accessibilityLabel: string; + readonly textClassName: string; +} + +const PR_STATE_TEXT_CLASS: Record = { + open: "text-emerald-600 dark:text-emerald-400", + merged: "text-violet-600 dark:text-violet-400", + closed: "text-zinc-500 dark:text-zinc-400", +}; + +export function presentThreadPr( + pr: ThreadPr, + provider: VcsStatusResult["sourceControlProvider"] | null | undefined, +): ThreadPrPresentation { + const presentation = resolveChangeRequestPresentation(provider); + return { + number: pr.number, + state: pr.state, + url: pr.url, + label: String(pr.number), + accessibilityLabel: `#${pr.number} ${presentation.longName} ${pr.state}`, + textClassName: PR_STATE_TEXT_CLASS[pr.state], + }; +} diff --git a/apps/mobile/src/state/threadQueryState.ts b/apps/mobile/src/state/threadQueryState.ts new file mode 100644 index 00000000000..a919fe0db88 --- /dev/null +++ b/apps/mobile/src/state/threadQueryState.ts @@ -0,0 +1,26 @@ +import { + EMPTY_ENVIRONMENT_THREAD_STATE, + type EnvironmentThreadState, +} from "@t3tools/client-runtime/state/threads"; +import { causeFailureMessage } from "@t3tools/client-runtime/errors"; +import type * as Cause from "effect/Cause"; +import * as Option from "effect/Option"; +import { AsyncResult } from "effect/unstable/reactivity"; + +function formatThreadStateFailure(cause: Cause.Cause): string { + return causeFailureMessage(cause, "Could not load conversation."); +} + +export function environmentThreadStateFromAsyncResult( + result: AsyncResult.AsyncResult, +): EnvironmentThreadState { + const value = Option.getOrNull(AsyncResult.value(result)); + if (AsyncResult.isFailure(result)) { + return { + ...(value ?? EMPTY_ENVIRONMENT_THREAD_STATE), + error: Option.some(formatThreadStateFailure(result.cause)), + }; + } + + return value ?? EMPTY_ENVIRONMENT_THREAD_STATE; +} diff --git a/apps/mobile/src/state/threads.test.ts b/apps/mobile/src/state/threads.test.ts new file mode 100644 index 00000000000..7285a9fa2b8 --- /dev/null +++ b/apps/mobile/src/state/threads.test.ts @@ -0,0 +1,40 @@ +import { EMPTY_ENVIRONMENT_THREAD_STATE } from "@t3tools/client-runtime/state/threads"; +import * as Cause from "effect/Cause"; +import * as Option from "effect/Option"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { describe, expect, it } from "vite-plus/test"; + +import { environmentThreadStateFromAsyncResult } from "./threadQueryState"; + +describe("environmentThreadStateFromAsyncResult", () => { + it("returns the empty state while the first value is loading", () => { + expect(environmentThreadStateFromAsyncResult(AsyncResult.initial(true))).toEqual( + EMPTY_ENVIRONMENT_THREAD_STATE, + ); + }); + + it("surfaces a failure when no previous value exists", () => { + const state = environmentThreadStateFromAsyncResult( + AsyncResult.failure(Cause.fail("thread sync failed")), + ); + + expect(Option.getOrNull(state.data)).toBeNull(); + expect(Option.getOrNull(state.error)).toBe("thread sync failed"); + }); + + it("preserves previous thread data while surfacing a refresh failure", () => { + const previousState = { + ...EMPTY_ENVIRONMENT_THREAD_STATE, + status: "live" as const, + }; + const previousSuccess = AsyncResult.success(previousState); + const state = environmentThreadStateFromAsyncResult( + AsyncResult.failure(Cause.fail(new Error("refresh failed")), { + previousSuccess: Option.some(previousSuccess), + }), + ); + + expect(state.status).toBe("live"); + expect(Option.getOrNull(state.error)).toBe("refresh failed"); + }); +}); diff --git a/apps/mobile/src/state/threads.ts b/apps/mobile/src/state/threads.ts index 34dc85f1689..cf6c1c2fe7a 100644 --- a/apps/mobile/src/state/threads.ts +++ b/apps/mobile/src/state/threads.ts @@ -1,4 +1,4 @@ -import { useAtomValue } from "@effect/atom-react"; +import { useAtomRefresh, useAtomValue } from "@effect/atom-react"; import { createEnvironmentThreadDetailAtoms, createEnvironmentThreadShellAtoms, @@ -8,12 +8,12 @@ import { createThreadEnvironmentAtoms, } from "@t3tools/client-runtime/state/threads"; import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; -import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { environmentCatalog } from "../connection/catalog"; import { connectionAtomRuntime } from "../connection/runtime"; import { environmentSnapshotAtom } from "./shell"; +import { environmentThreadStateFromAsyncResult } from "./threadQueryState"; export const threadEnvironment = createThreadEnvironmentAtoms(connectionAtomRuntime); export const environmentThreads = createEnvironmentThreadStateAtoms(connectionAtomRuntime); @@ -29,18 +29,33 @@ const EMPTY_THREAD_STATE_ATOM = Atom.make(AsyncResult.success(EMPTY_ENVIRONMENT_ Atom.withLabel("mobile-environment-thread:empty"), ); -export function useEnvironmentThread( +export interface EnvironmentThreadQuery { + readonly state: EnvironmentThreadState; + readonly isPending: boolean; + readonly refresh: () => void; +} + +export function useEnvironmentThreadQuery( environmentId: EnvironmentId | null, threadId: ThreadId | null, -): EnvironmentThreadState { - const result = useAtomValue( +): EnvironmentThreadQuery { + const atom = environmentId !== null && threadId !== null ? environmentThreads.stateAtom(environmentId, threadId) - : EMPTY_THREAD_STATE_ATOM, - ); - const state = Option.getOrElse( - AsyncResult.value(result), - () => EMPTY_ENVIRONMENT_THREAD_STATE, - ) as EnvironmentThreadState; - return state; + : EMPTY_THREAD_STATE_ATOM; + const result = useAtomValue(atom); + const refresh = useAtomRefresh(atom); + + return { + state: environmentThreadStateFromAsyncResult(result), + isPending: environmentId !== null && threadId !== null && result.waiting, + refresh, + }; +} + +export function useEnvironmentThread( + environmentId: EnvironmentId | null, + threadId: ThreadId | null, +): EnvironmentThreadState { + return useEnvironmentThreadQuery(environmentId, threadId).state; } diff --git a/apps/mobile/src/state/use-thread-detail.ts b/apps/mobile/src/state/use-thread-detail.ts index 8c48aa880ec..ff6c6dda75b 100644 --- a/apps/mobile/src/state/use-thread-detail.ts +++ b/apps/mobile/src/state/use-thread-detail.ts @@ -3,7 +3,11 @@ import type { EnvironmentThread } from "@t3tools/client-runtime/state/shell"; import type { EnvironmentId, OrchestrationV2ThreadProjection, ThreadId } from "@t3tools/contracts"; import { Atom } from "effect/unstable/reactivity"; -import { environmentThreadDetails, useEnvironmentThread } from "./threads"; +import { + environmentThreadDetails, + useEnvironmentThread, + useEnvironmentThreadQuery, +} from "./threads"; import { useThreadSelection } from "./use-thread-selection"; const EMPTY_THREAD_PROJECTION_ATOM = Atom.make(null).pipe( @@ -22,9 +26,17 @@ export function useThreadDetail(target: ThreadDetailTarget) { return useEnvironmentThread(target.environmentId, target.threadId); } +export function useThreadDetailQuery(target: ThreadDetailTarget) { + return useEnvironmentThreadQuery(target.environmentId, target.threadId); +} + export function useSelectedThreadDetailState() { + return useSelectedThreadDetailQuery().state; +} + +export function useSelectedThreadDetailQuery() { const { selectedThread } = useThreadSelection(); - return useThreadDetail({ + return useThreadDetailQuery({ environmentId: selectedThread?.environmentId ?? null, threadId: selectedThread?.id ?? null, }); diff --git a/apps/mobile/src/state/use-thread-outbox-drain.ts b/apps/mobile/src/state/use-thread-outbox-drain.ts index b82214c9e2a..09ad34fe9b0 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.ts @@ -16,6 +16,8 @@ import * as Cause from "effect/Cause"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { useCallback, useEffect, useRef, useState } from "react"; +import { addBreadcrumb } from "../lib/breadcrumbs"; +import { toUploadChatImageAttachments } from "../lib/composerImages"; import { scopedThreadKey } from "../lib/scopedEntities"; import { buildProjectThreadStartTurnInput } from "../lib/projectThreadStartTurn"; import { randomHex } from "../lib/uuid"; @@ -223,7 +225,7 @@ export function useThreadOutboxDrain(): void { messageId: queuedMessage.messageId, role: "user", text: queuedMessage.text, - attachments: queuedMessage.attachments, + attachments: toUploadChatImageAttachments(queuedMessage.attachments), }, modelSelection: settings.modelSelection, runtimeMode: settings.runtimeMode, @@ -337,6 +339,13 @@ export function useThreadOutboxDrain(): void { } beginDispatchingQueuedMessage(nextQueuedMessage.messageId); + addBreadcrumb("outbox.dispatch", { + action: deliveryAction, + creation: creation !== undefined, + environmentId: nextQueuedMessage.environmentId, + messageId: nextQueuedMessage.messageId, + threadId: nextQueuedMessage.threadId, + }); const removeQueuedMessage = (warning: string) => removeThreadOutboxMessage(nextQueuedMessage).then( () => true, @@ -362,6 +371,11 @@ export function useThreadOutboxDrain(): void { : Promise.resolve(false); void delivery .then((sent) => { + addBreadcrumb("outbox.result", { + messageId: nextQueuedMessage.messageId, + sent, + threadId: nextQueuedMessage.threadId, + }); if (sent) { retryAttemptRef.current.delete(nextQueuedMessage.messageId); retryNotBeforeRef.current.delete(nextQueuedMessage.messageId); diff --git a/apps/mobile/src/state/use-thread-pr.test.ts b/apps/mobile/src/state/use-thread-pr.test.ts new file mode 100644 index 00000000000..a355dddcd43 --- /dev/null +++ b/apps/mobile/src/state/use-thread-pr.test.ts @@ -0,0 +1,36 @@ +import type { VcsStatusResult } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { presentThreadPr } from "./thread-pr-presentation"; + +const pullRequest: NonNullable = { + number: 3774, + title: "Desktop-style pull request indicator", + url: "https://github.com/t3tools/t3code/pull/3774", + baseRef: "main", + headRef: "codex/desktop-style-pr-indicator", + state: "merged", +}; + +describe("presentThreadPr", () => { + it("uses the compact pull request number label without a hash prefix", () => { + expect(presentThreadPr(pullRequest, undefined)).toMatchObject({ + label: "3774", + accessibilityLabel: "#3774 pull request merged", + textClassName: "text-violet-600 dark:text-violet-400", + }); + }); + + it("uses merge-request terminology for GitLab", () => { + expect( + presentThreadPr(pullRequest, { + kind: "gitlab", + name: "GitLab", + baseUrl: "https://gitlab.com", + }), + ).toMatchObject({ + label: "3774", + accessibilityLabel: "#3774 merge request merged", + }); + }); +}); diff --git a/apps/mobile/src/state/use-thread-pr.ts b/apps/mobile/src/state/use-thread-pr.ts index b7d890bbe7f..a3440cd4848 100644 --- a/apps/mobile/src/state/use-thread-pr.ts +++ b/apps/mobile/src/state/use-thread-pr.ts @@ -1,40 +1,14 @@ import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; -import type { VcsStatusResult } from "@t3tools/contracts"; -import { resolveChangeRequestPresentation } from "@t3tools/shared/sourceControl"; import { useEnvironmentQuery } from "./query"; +import { presentThreadPr, type ThreadPrPresentation } from "./thread-pr-presentation"; import { vcsEnvironment } from "./vcs"; -export type ThreadPr = NonNullable; - -export interface ThreadPrPresentation { - readonly number: number; - readonly state: ThreadPr["state"]; - readonly url: string; - /** Compact chip label, e.g. "PR open" / "MR merged". */ - readonly label: string; - readonly textClassName: string; -} - -const PR_STATE_TEXT_CLASS: Record = { - open: "text-emerald-600 dark:text-emerald-400", - merged: "text-violet-600 dark:text-violet-400", - closed: "text-zinc-500 dark:text-zinc-400", -}; - -export function presentThreadPr( - pr: ThreadPr, - provider: VcsStatusResult["sourceControlProvider"] | null | undefined, -): ThreadPrPresentation { - const shortName = resolveChangeRequestPresentation(provider).shortName; - return { - number: pr.number, - state: pr.state, - url: pr.url, - label: `${shortName} ${pr.state}`, - textClassName: PR_STATE_TEXT_CLASS[pr.state], - }; -} +export { + presentThreadPr, + type ThreadPr, + type ThreadPrPresentation, +} from "./thread-pr-presentation"; /** * Live PR status for a thread's branch. Subscriptions are deduplicated per diff --git a/apps/mobile/src/state/use-thread-selection.ts b/apps/mobile/src/state/use-thread-selection.ts index 524540ad83d..85e874b339c 100644 --- a/apps/mobile/src/state/use-thread-selection.ts +++ b/apps/mobile/src/state/use-thread-selection.ts @@ -13,6 +13,7 @@ import { type EnvironmentThreadShell, } from "@t3tools/client-runtime/state/shell"; import * as Option from "effect/Option"; +import { copySorted } from "@t3tools/shared/Array"; import { useProject, useThreadShell } from "../state/entities"; import { useEnvironmentThread } from "../state/threads"; @@ -56,7 +57,7 @@ function threadDetailToShell( projection: OrchestrationV2ThreadProjection, ): EnvironmentThreadShell { const thread = projection.thread; - const runsByOrdinal = projection.runs.toSorted((left, right) => right.ordinal - left.ordinal); + const runsByOrdinal = copySorted(projection.runs, (left, right) => right.ordinal - left.ordinal); const latestRun = runsByOrdinal[0] ?? null; const activeRun = runsByOrdinal.find( diff --git a/apps/server/src/orchestration-v2/EventSink.ts b/apps/server/src/orchestration-v2/EventSink.ts index 5e226553e73..c17827acddb 100644 --- a/apps/server/src/orchestration-v2/EventSink.ts +++ b/apps/server/src/orchestration-v2/EventSink.ts @@ -130,6 +130,19 @@ export interface EventSinkV2Shape { readonly threadId?: ThreadId; readonly afterSequence?: number; }) => Stream.Stream; + /** + * Like `stream`, but inserts a catch-up-complete signal after the finite + * replay window and before live events. Used by thread resume when clients + * request a completion marker. + */ + readonly streamWithCatchUpComplete: (input: { + readonly threadId?: ThreadId; + readonly afterSequence: number; + }) => Stream.Stream< + | { readonly kind: "stored"; readonly stored: OrchestrationV2StoredEvent } + | { readonly kind: "catch-up-complete" }, + EventSinkV2Error + >; readonly latestSequence: (input?: { readonly threadId?: ThreadId; }) => Effect.Effect; @@ -463,6 +476,43 @@ const baseLayer: Layer.Layer< }), ); + const streamWithCatchUpComplete = (input: { + readonly threadId?: ThreadId; + readonly afterSequence: number; + }) => + Stream.unwrap( + Effect.gen(function* () { + const subscription = yield* PubSub.subscribe(liveEvents); + const highWater = yield* eventStore.latestSequence(); + const afterSequence = input.afterSequence; + const replay = catchUp({ + afterSequence, + throughSequence: highWater, + ...(input.threadId === undefined ? {} : { threadId: input.threadId }), + }).pipe(Stream.map((stored) => ({ kind: "stored" as const, stored }))); + const live = Stream.fromSubscription(subscription).pipe( + Stream.filter((stored) => stored.sequence > Math.max(highWater, afterSequence)), + Stream.filter( + (stored) => input.threadId === undefined || stored.event.threadId === input.threadId, + ), + Stream.map((stored) => ({ kind: "stored" as const, stored })), + ); + return Stream.concat( + replay, + Stream.concat(Stream.succeed({ kind: "catch-up-complete" as const }), live), + ); + }), + ).pipe( + Stream.mapError( + (cause) => + new EventSinkStreamError({ + ...(input.threadId === undefined ? {} : { threadId: input.threadId }), + afterSequence: input.afterSequence, + cause, + }), + ), + ); + return EventSinkV2.of({ write: (input) => writeEffect({ ...input, effects: [] }).pipe( @@ -532,6 +582,7 @@ const baseLayer: Layer.Layer< }), ), ), + streamWithCatchUpComplete, latestSequence: (input) => eventStore.latestSequence(input).pipe( Effect.mapError( diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index b10e370cc5c..5ba9a39d196 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -159,6 +159,14 @@ export interface OrchestratorV2Shape { readonly threadId?: ThreadId; readonly afterSequence?: number; }) => Stream.Stream; + readonly streamResumeWithCatchUpComplete: (input: { + readonly threadId: ThreadId; + readonly afterSequence: number; + }) => Stream.Stream< + | { readonly kind: "stored"; readonly stored: OrchestrationV2StoredEvent } + | { readonly kind: "catch-up-complete" }, + OrchestratorV2Error + >; readonly streamDomainEvents: Stream.Stream; } @@ -5228,6 +5236,20 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }), ), ), + streamResumeWithCatchUpComplete: (input) => + eventSink + .streamWithCatchUpComplete({ + threadId: input.threadId, + afterSequence: input.afterSequence, + }) + .pipe( + Stream.mapError( + (cause) => + new OrchestratorDomainEventStreamError({ + cause, + }), + ), + ), streamDomainEvents: eventSink.stream().pipe( Stream.map((stored) => stored.event), Stream.mapError( @@ -5314,6 +5336,12 @@ export const layerUnavailable: Layer.Layer = Layer.succeed( cause: "Orchestration V2 live runtime is not configured.", }), ), + streamResumeWithCatchUpComplete: () => + Stream.fail( + new OrchestratorDomainEventStreamError({ + cause: "Orchestration V2 live runtime is not configured.", + }), + ), streamDomainEvents: Stream.fail( new OrchestratorDomainEventStreamError({ cause: "Orchestration V2 live runtime is not configured.", diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index 7a4301980e5..76c2b67516b 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -163,6 +163,7 @@ export interface ThreadManagementServiceShape { readonly getThreadEventSequence: OrchestratorV2["Service"]["getThreadEventSequence"]; readonly streamStoredEvents: OrchestratorV2["Service"]["streamStoredEvents"]; readonly streamStoredEventsFrom: OrchestratorV2["Service"]["streamStoredEventsFrom"]; + readonly streamResumeWithCatchUpComplete: OrchestratorV2["Service"]["streamResumeWithCatchUpComplete"]; readonly streamDomainEvents: OrchestratorV2["Service"]["streamDomainEvents"]; } @@ -462,6 +463,7 @@ const make = Effect.gen(function* () { getThreadEventSequence: orchestrator.getThreadEventSequence, streamStoredEvents: orchestrator.streamStoredEvents, streamStoredEventsFrom: orchestrator.streamStoredEventsFrom, + streamResumeWithCatchUpComplete: orchestrator.streamResumeWithCatchUpComplete, streamDomainEvents: orchestrator.streamDomainEvents, }); }); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 4638b116e0b..e8899199635 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -313,11 +313,17 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" } case "thread.meta.update": { - yield* requireThread({ + const thread = yield* requireThread({ readModel, command, threadId: command.threadId, }); + const branch = + command.branch !== undefined && + command.expectedBranch !== undefined && + thread.branch !== command.expectedBranch + ? thread.branch + : command.branch; const occurredAt = yield* nowIso; return { ...(yield* withEventBase({ @@ -333,7 +339,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" ...(command.modelSelection !== undefined ? { modelSelection: command.modelSelection } : {}), - ...(command.branch !== undefined ? { branch: command.branch } : {}), + ...(branch !== undefined ? { branch } : {}), ...(command.worktreePath !== undefined ? { worktreePath: command.worktreePath } : {}), updatedAt: occurredAt, }, diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 811c362f1e0..a2182cfb73c 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -56,6 +56,8 @@ const REASONING_EFFORT_LABELS: Readonly> = { medium: "Medium", high: "High", xhigh: "Extra High", + max: "Max", + ultra: "Ultra", }; const DEFAULT_SERVICE_TIER_ID = "default"; diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 111dfb882d4..e74c9ba712a 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -34,6 +34,7 @@ import { OrchestrationV2GetShellSnapshotError, OrchestrationV2GetThreadProjectionError, OrchestrationV2ThreadLaunchError, + type OrchestrationV2ThreadStreamItem, type OrchestrationProjectShell, type ProjectEntriesFailure, type ProjectFileFailure, @@ -593,6 +594,7 @@ const makeWsRpcLayer = ( otlpMetricsEnabled: config.otlpMetricsUrl !== undefined, }, settings, + threadResumeCompletionMarker: true, }; }); @@ -602,7 +604,11 @@ const makeWsRpcLayer = ( .pipe(Effect.ignoreCause({ log: true }), Effect.forkDetach, Effect.asVoid); const subscribeOrchestrationV2Thread = Effect.fn("ws.orchestrationV2.subscribeThread")( - function* (input: { readonly threadId: ThreadId; readonly afterSequence?: number }) { + function* (input: { + readonly threadId: ThreadId; + readonly afterSequence?: number; + readonly requestCompletionMarker?: boolean; + }) { yield* Effect.annotateCurrentSpan({ "orchestration_v2.thread_id": input.threadId, }); @@ -637,6 +643,35 @@ const makeWsRpcLayer = ( // published during the replay window is lost; overlapping events are // deduped by sequence on the client. if (input.afterSequence !== undefined) { + if (input.requestCompletionMarker === true) { + // Opt-in marker after catch-up so warm clients leave syncing without + // a full body retransmit, including when replay is empty. + return threadManagement + .streamResumeWithCatchUpComplete({ + threadId: input.threadId, + afterSequence: input.afterSequence, + }) + .pipe( + Stream.map( + (item): OrchestrationV2ThreadStreamItem => + item.kind === "catch-up-complete" + ? { kind: "synchronized" } + : { + kind: "event", + sequence: item.stored.sequence, + event: item.stored.event, + }, + ), + Stream.mapError( + (cause) => + new OrchestrationV2GetThreadProjectionError({ + threadId: input.threadId, + message: `Failed while streaming orchestration V2 thread ${input.threadId}`, + cause, + }), + ), + ); + } return eventStreamFrom(input.afterSequence); } diff --git a/apps/web/src/components/GitActionsControl.logic.test.ts b/apps/web/src/components/GitActionsControl.logic.test.ts index 3781fad8336..f302e976ca7 100644 --- a/apps/web/src/components/GitActionsControl.logic.test.ts +++ b/apps/web/src/components/GitActionsControl.logic.test.ts @@ -9,6 +9,7 @@ import { resolveLiveThreadBranchUpdate, resolveQuickAction, resolveThreadBranchUpdate, + resolveThreadBranchMetadataPatch, } from "./GitActionsControl.logic"; function status(overrides: Partial = {}): VcsStatusResult { @@ -1111,6 +1112,18 @@ describe("resolveLiveThreadBranchUpdate", () => { }); }); +describe("resolveThreadBranchMetadataPatch", () => { + it("does not overwrite worktree metadata while reconciling a branch", () => { + assert.deepEqual( + resolveThreadBranchMetadataPatch("feature/current-ref", "feature/previous-ref"), + { + branch: "feature/current-ref", + expectedBranch: "feature/previous-ref", + }, + ); + }); +}); + describe("resolveAutoFeatureBranchName", () => { it("uses semantic preferred ref names when available", () => { const ref = resolveAutoFeatureBranchName(["main", "feature/other"], "fix toast copy"); diff --git a/apps/web/src/components/GitActionsControl.logic.ts b/apps/web/src/components/GitActionsControl.logic.ts index 3f6bae61cdd..96f7af794ac 100644 --- a/apps/web/src/components/GitActionsControl.logic.ts +++ b/apps/web/src/components/GitActionsControl.logic.ts @@ -373,6 +373,16 @@ export function resolveThreadBranchUpdate( }; } +export function resolveThreadBranchMetadataPatch( + branch: string | null, + expectedBranch: string | null, +): { + branch: string | null; + expectedBranch: string | null; +} { + return { branch, expectedBranch }; +} + export function resolveLiveThreadBranchUpdate(input: { threadBranch: string | null; gitStatus: VcsStatusResult | null; diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index ce5914925b3..484a6040284 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -46,6 +46,7 @@ import { requiresDefaultBranchConfirmation, resolveDefaultBranchActionDialogCopy, resolveLiveThreadBranchUpdate, + resolveThreadBranchMetadataPatch, resolveQuickAction, resolveThreadBranchUpdate, } from "./GitActionsControl.logic"; @@ -1053,13 +1054,11 @@ export default function GitActionsControl({ return; } - const worktreePath = activeServerThread.worktreePath; void updateThreadMetadata({ environmentId: activeThreadRef.environmentId, input: { threadId: activeThreadRef.threadId, - branch, - worktreePath, + ...resolveThreadBranchMetadataPatch(branch, activeServerThread.branch), }, }); diff --git a/apps/web/src/components/chat/ThreadErrorBanner.tsx b/apps/web/src/components/chat/ThreadErrorBanner.tsx index 86dda80abe1..a8059047ae1 100644 --- a/apps/web/src/components/chat/ThreadErrorBanner.tsx +++ b/apps/web/src/components/chat/ThreadErrorBanner.tsx @@ -16,14 +16,14 @@ export const ThreadErrorBanner = memo(function ThreadErrorBanner({
- - }> - {error} - - - {error} - - + + + }>{error} + + {error} + + + {onDismiss && (