From 1fbac5c6c80e71b2fc5d8e5c018bc66e6990f68e Mon Sep 17 00:00:00 2001 From: wahaj4311 Date: Sun, 5 Jul 2026 15:38:12 +0500 Subject: [PATCH] fix(android): reject stale shell backlog + always-visible keyboard toggle Terminal was broken on every reconnect with 'v1-Backend: terminal JSON-RPC stream closed' and the on-screen keyboard could not be summoned by tapping the viewport. Two fixes: 1. shared/remote_alleycat: reject replayed backlog frames from a prior killed shell session until our own shell/spawn returns and sets the expected session id. Otherwise a stale shell/exit in the ring breaks the read loop before initialize returns. 2. android/GhosttySurfaceView + TerminalScreen: pop the IME on every confirmed tap (previously the tap handler bailed out when the renderer was still binding), and add an explicit keyboard IconButton to the terminal header for reconnects/edge cases where focus is not restored to the SurfaceView. --- .../android/ui/terminal/GhosttySurfaceView.kt | 26 +++++++++++++++++-- .../android/ui/terminal/TerminalScreen.kt | 15 +++++++++++ .../src/terminal/remote_alleycat.rs | 7 ++++- 3 files changed, 45 insertions(+), 3 deletions(-) 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, } }