diff --git a/apps/android/app/src/main/java/com/litter/android/ui/terminal/GhosttySurfaceView.kt b/apps/android/app/src/main/java/com/litter/android/ui/terminal/GhosttySurfaceView.kt index 470a92240..48e54f6fa 100644 --- a/apps/android/app/src/main/java/com/litter/android/ui/terminal/GhosttySurfaceView.kt +++ b/apps/android/app/src/main/java/com/litter/android/ui/terminal/GhosttySurfaceView.kt @@ -431,7 +431,12 @@ private class GhosttyAndroidSurfaceView( } override fun onSingleTapConfirmed(e: MotionEvent): Boolean { - val renderer = terminalRenderer ?: return false + // ponytail: pop the IME on every confirmed tap, even before + // the renderer binds. The prior guard returned false when + // terminalRenderer was null, so tapping the black surface + // during startup never summoned the keyboard. + showIme() + val renderer = terminalRenderer ?: return true if (currentSelectionRange() != null) { clearSelection() return true @@ -444,7 +449,6 @@ private class GhosttyAndroidSurfaceView( runCatching { context.startActivity(intent) } return true } - showIme() return true } }, @@ -457,10 +461,21 @@ private class GhosttyAndroidSurfaceView( isFocusableInTouchMode = true isLongClickable = true isHapticFeedbackEnabled = true + activeSurface = java.lang.ref.WeakReference(this) } + internal fun forceShowIme() = showIme() + override fun onCheckIsTextEditor(): Boolean = true + companion object { + // ponytail: process-wide handle so the terminal header's keyboard + // button can summon the IME without threading a callback through + // every Composable. Weak so the view can be GC'd normally. + @Volatile + internal var activeSurface: java.lang.ref.WeakReference? = null + } + override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection { outAttrs.inputType = ( InputType.TYPE_CLASS_TEXT or @@ -917,3 +932,10 @@ private object KeyEventTranslator { else -> 0 } } + +// ponytail: top-level entry so the terminal header (or any Composable in +// the terminal package) can pop the IME on the current SurfaceView without +// exposing the private view class. +internal fun showTerminalKeyboard() { + GhosttyAndroidSurfaceView.activeSurface?.get()?.forceShowIme() +} diff --git a/apps/android/app/src/main/java/com/litter/android/ui/terminal/TerminalScreen.kt b/apps/android/app/src/main/java/com/litter/android/ui/terminal/TerminalScreen.kt index 0387e61de..994d3c0cf 100644 --- a/apps/android/app/src/main/java/com/litter/android/ui/terminal/TerminalScreen.kt +++ b/apps/android/app/src/main/java/com/litter/android/ui/terminal/TerminalScreen.kt @@ -26,6 +26,7 @@ import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.outlined.Keyboard import androidx.compose.material.icons.outlined.PhoneIphone import androidx.compose.material.icons.outlined.Storage import androidx.compose.material3.DropdownMenu @@ -539,6 +540,20 @@ private fun TerminalHeader( } } Spacer(Modifier.weight(1f)) + // ponytail: explicit keyboard toggle — the tap-to-focus path on + // the SurfaceView is easy to miss (users often tap chrome, not + // the black viewport) and some devices/IMEs never restore focus + // after a reconnect. + IconButton( + onClick = { showTerminalKeyboard() }, + modifier = Modifier.size(40.dp), + ) { + Icon( + Icons.Outlined.Keyboard, + contentDescription = "Show keyboard", + tint = LitterTheme.accent, + ) + } TextButton( onClick = onConfigClick, contentPadding = PaddingValues(horizontal = 10.dp, vertical = 0.dp), diff --git a/shared/rust-bridge/codex-mobile-client/src/terminal/remote_alleycat.rs b/shared/rust-bridge/codex-mobile-client/src/terminal/remote_alleycat.rs index 7e79ae977..eaa172f52 100644 --- a/shared/rust-bridge/codex-mobile-client/src/terminal/remote_alleycat.rs +++ b/shared/rust-bridge/codex-mobile-client/src/terminal/remote_alleycat.rs @@ -321,7 +321,12 @@ async fn should_accept_session( ) -> bool { match expected_session_id.lock().await.as_deref() { Some(expected) => expected == actual_session_id, - None => true, + // ponytail: drop replayed backlog frames from prior killed shell + // sessions until our own shell/spawn returns and sets the expected + // id. Without this a stale shell/exit in the ring breaks the read + // loop and the terminal reports "JSON-RPC stream closed" on every + // reconnect. + None => false, } }