From 2aff97b394b7fcd00d571bb1ee14e4a20b7c11a4 Mon Sep 17 00:00:00 2001 From: macodev00 <273427913+macodev00@users.noreply.github.com> Date: Wed, 23 Sep 2026 07:57:35 +0000 Subject: [PATCH] fix(mobile): echo Android terminal keystrokes locally Printable keys were drawn only after a remote pty round trip, so typing on Android felt late on every character. Paint those keys before the pty echoes them, stop once the prediction cap is reached, and leave password, passcode, and PIN prompts on the remote path. --- .../modules/t3-terminal/android/build.gradle | 7 + .../expo/modules/t3terminal/T3TerminalView.kt | 137 +++++- .../modules/t3terminal/TerminalLocalEcho.kt | 413 ++++++++++++++++++ .../t3terminal/TerminalLocalEchoTest.kt | 364 +++++++++++++++ 4 files changed, 898 insertions(+), 23 deletions(-) create mode 100644 apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/TerminalLocalEcho.kt create mode 100644 apps/mobile/modules/t3-terminal/android/src/test/java/expo/modules/t3terminal/TerminalLocalEchoTest.kt diff --git a/apps/mobile/modules/t3-terminal/android/build.gradle b/apps/mobile/modules/t3-terminal/android/build.gradle index 0da0777cf4ff3..ba497d80b2ca9 100644 --- a/apps/mobile/modules/t3-terminal/android/build.gradle +++ b/apps/mobile/modules/t3-terminal/android/build.gradle @@ -30,4 +30,11 @@ android { dependencies { implementation project(':expo-modules-core') + testImplementation 'junit:junit:4.13.2' +} + +tasks.withType(Test).configureEach { + javaLauncher = javaToolchains.launcherFor { + languageVersion = JavaLanguageVersion.of(21) + } } 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 94e0b38e51d62..2c17ef826281c 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 @@ -48,7 +48,9 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex onCapture(mapOf("text" to text)) } private var terminalHandle = 0L - private var fedBuffer = "" + + /** Local-echo cursor. [TerminalLocalEcho] keeps this aligned with Ghostty. */ + private var echoState = TerminalEchoState() private var cols = 0 private var rows = 0 private var clearingInput = false @@ -220,6 +222,10 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex destroyTerminal() } + /** + * Hidden field that captures IME and hardware keys. Printable typing is + * painted before the pty replies; see [emitTypedInput]. + */ private fun configureInputView() { inputView.setSingleLine(true) inputView.setTextColor(Color.TRANSPARENT) @@ -245,7 +251,7 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex 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")) + emitTypedInput("\r") true } else { false @@ -255,14 +261,12 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex if (event.action != KeyEvent.ACTION_DOWN) return@setOnKeyListener false when { keyCode == KeyEvent.KEYCODE_DEL -> { - onInput(mapOf("data" to "\u007F")) + emitTypedInput("\u007F") true } // Hardware keyboard Ctrl+A..Z -> control bytes 0x01..0x1A (Ctrl+C, Ctrl+Z, ...). event.isCtrlPressed && keyCode in KeyEvent.KEYCODE_A..KeyEvent.KEYCODE_Z -> { - onInput( - mapOf("data" to (keyCode - KeyEvent.KEYCODE_A + 1).toChar().toString()), - ) + emitTypedInput((keyCode - KeyEvent.KEYCODE_A + 1).toChar().toString()) true } else -> false @@ -278,7 +282,7 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex if (start >= end) return val insertedText = s.subSequence(start, end).toString() if (insertedText.isNotEmpty()) { - onInput(mapOf("data" to insertedText)) + emitTypedInput(insertedText) } } @@ -330,6 +334,10 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex renderSnapshot() } + /** + * Allocates the Ghostty session for the current grid. Pty bytes that + * arrived before the surface had a size are applied by [feedPendingBuffer]. + */ @Suppress("ComplexCondition") private fun createTerminal() { if (terminalHandle != 0L || cols <= 0 || rows <= 0 || isCleanedUp) return @@ -343,45 +351,128 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex cursorColorValue, paletteColors, ) - fedBuffer = "" } + /** + * Starts a fresh Ghostty session for a new [terminalKey], dropping any + * in-flight local echo. Echo trust is kept so the next shell key still + * paints immediately. + */ private fun recreateTerminal() { if (terminalHandle == 0L) return + val trusted = echoState.echoTrusted destroyTerminal() + echoState = TerminalEchoState(echoTrusted = trusted) createTerminal() feedPendingBuffer() renderSnapshot() } + /** + * Releases the Ghostty session. The echo cursor is left untouched so the + * caller can decide whether to replay or drop the prediction. + */ private fun destroyTerminal() { if (terminalHandle == 0L) return GhosttyBridge.nativeDestroy(terminalHandle) terminalHandle = 0L - fedBuffer = "" terminalCanvas.resetSelectionState() } + /** + * Reconciles [initialBuffer] with keystrokes already painted locally. + * A confirmed echo is not fed again. If the pty byte stream diverges, the + * grid is rebuilt from the authoritative buffer. + */ private fun feedPendingBuffer() { - if (terminalHandle == 0L || initialBuffer == fedBuffer) return - if (!initialBuffer.startsWith(fedBuffer)) { - recreateTerminal() - if (terminalHandle == 0L) return + if (terminalHandle == 0L) return + when (val sync = TerminalLocalEcho.applyRemoteBuffer(echoState, initialBuffer)) { + is TerminalBufferSync.InSync -> echoState = sync.state + is TerminalBufferSync.Feed -> applyRemoteSuffix(sync) + is TerminalBufferSync.Reset -> rebuildFromRemote(sync) } - 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() - } + } + + /** + * Sends [data] to the remote pty and, for printable typing, paints it in + * Ghostty immediately. [feedPendingBuffer] later drops a matching echo so + * a cooked shell does not draw the character twice. + */ + private fun emitTypedInput(data: String) { + if (data.isEmpty() || isCleanedUp) return + val decision = TerminalLocalEcho.noteLocalInput( + echoState, + data, + terminalHandle != 0L, + ) + val paint = decision.paint + if (paint != null) { + echoState = decision.state + feedBytes(paint) + clearSelectionAfterOutput() + renderSnapshot() + } else { + echoState = decision.state } - fedBuffer = initialBuffer + onInput(mapOf("data" to data)) + } + + /** Appends remote bytes that were not already painted as local echo. */ + private fun applyRemoteSuffix(sync: TerminalBufferSync.Feed) { + echoState = sync.state + feedBytes(sync.suffix) + clearSelectionAfterOutput() renderSnapshot() } + /** + * Discards the Ghostty session and replays [TerminalBufferSync.Reset.buffer]. + * Bytes before [TerminalBufferSync.Reset.replyFrom] were already answered, so + * their device replies are dropped. The live suffix still reports cursor + * position and primary DA. + */ + private fun rebuildFromRemote(sync: TerminalBufferSync.Reset) { + destroyTerminal() + createTerminal() + if (terminalHandle == 0L) { + echoState = TerminalEchoState(echoTrusted = sync.state.echoTrusted) + return + } + val replyFrom = sync.replyFrom.coerceIn(0, sync.buffer.length) + val history = sync.buffer.substring(0, replyFrom) + val live = sync.buffer.substring(replyFrom) + if (history.isNotEmpty()) { + GhosttyBridge.nativeFeed(terminalHandle, history.toByteArray(Charsets.UTF_8)) + } + if (live.isNotEmpty()) { + feedBytes(live) + } + if (sync.buffer.isNotEmpty()) { + clearSelectionAfterOutput() + } + echoState = sync.state + renderSnapshot() + } + + /** + * Writes [data] into Ghostty and forwards device replies (cursor reports, + * primary DA) as input, which is what the remote pty is waiting on. + */ + private fun feedBytes(data: String) { + if (terminalHandle == 0L || data.isEmpty()) return + emitResponse(GhosttyBridge.nativeFeed(terminalHandle, data.toByteArray(Charsets.UTF_8))) + } + + /** + * Drops an active selection after new output so the copy toolbar stays on + * the grid, matching the web terminal drawer. + */ + private fun clearSelectionAfterOutput() { + if (terminalHandle == 0L || !terminalCanvas.hasActiveSelection()) return + GhosttyBridge.nativeClearSelection(terminalHandle) + terminalCanvas.resetSelectionState() + } + private fun renderSnapshot() { if (terminalHandle == 0L) return TerminalFrame.decode( diff --git a/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/TerminalLocalEcho.kt b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/TerminalLocalEcho.kt new file mode 100644 index 0000000000000..363121ded8505 --- /dev/null +++ b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/TerminalLocalEcho.kt @@ -0,0 +1,413 @@ +package expo.modules.t3terminal + +/** + * Paints printable Android keystrokes before the remote pty echoes them. + * + * The surface otherwise waits for a full attach round trip before Ghostty + * can draw, so every character feels late on a phone. Only bytes a cooked + * shell echoes unchanged are painted ahead. Controls, pastes, and secret + * prompts stay on the remote path. Prediction stops once the unconfirmed + * tail would pass [MAX_PENDING_INPUT]. If the pty byte stream contradicts a + * prediction, the grid is rebuilt from that stream and local echo stays off + * until a later keystroke is echoed verbatim. + */ +internal object TerminalLocalEcho { + /** One IME delivery longer than this is a paste, not typing. */ + private const val MAX_PREDICTABLE_INPUT = 64 + + /** + * Cap for input the pty has not echoed yet. The untrusted probe is dropped + * past this size. A trusted session stops painting once another key would + * pass it, and that key is left for the remote echo. + */ + internal const val MAX_PENDING_INPUT = 256 + + /** Tail of the pty stream inspected for a secret prompt. */ + private const val PASSWORD_TAIL_CHARS = 240 + + private val OSC = Regex("\u001B\\][^\u0007]*(?:\u0007|\u001B\\\\)") + private val CSI = Regex("\u001B\\[[0-9:;<=>?]*[ -/]*[@-~]") + private val SIMPLE_ESC = Regex("\u001B[@-Z\\-_]") + + /** + * Visible tail of a secret prompt. The keyword starts the line or follows + * whitespace or an opening bracket, and the line ends with `:`. That covers + * `Passcode:` and `PIN:` plus prompts whose colon is not the first one + * (`Password for 'https://…':`, `Enter passphrase (empty for no passphrase):`) + * without treating a path such as `~/pin:` as a prompt. + */ + private val PASSWORD_PROMPT = Regex( + "(?i)(?:^|[\\s\\[('\"])(?:pass(?:word|phrase|code)|pass phrase|pin)\\b[^\\n\\r]*:\\s*$", + ) + + /** + * Returns [input] when it is a short run of characters a cooked shell + * echoes unchanged, or null when the pty must be allowed to interpret it. + */ + fun predictableEcho(input: String): String? { + if (!isPredictableEcho(input)) return null + return input + } + + /** + * Records [input] and, when it is safe, returns the bytes to paint now. + * [canPaint] is false until the native terminal exists; the key is still + * sent to the pty by the caller. A trusted prediction that would grow past + * [MAX_PENDING_INPUT] is not painted. + */ + fun noteLocalInput( + state: TerminalEchoState, + input: String, + canPaint: Boolean + ): LocalEchoDecision { + val echo = predictableEcho(input) + if (echo == null || !canPaintAhead(state, echo, canPaint)) { + return skipPaint(state, echo, input) + } + return LocalEchoDecision( + paint = echo, + state = state.copy( + pendingInput = state.pendingInput + echo, + predicted = state.predicted + echo, + ), + ) + } + + /** + * Folds the authoritative pty buffer into [state]. A confirmed prediction + * produces no further bytes to feed. A contradiction rebuilds from [remote]. + * [TerminalBufferSync.Reset.replyFrom] marks where device replies may resume. + */ + fun applyRemoteBuffer(state: TerminalEchoState, remote: String): TerminalBufferSync { + if (!remote.startsWith(state.confirmed)) { + return resetTo( + remote, + echoTrusted = state.echoTrusted, + replyFrom = retainedHistoryLength(state.confirmed, remote), + ) + } + val suffix = remote.substring(state.confirmed.length) + val withOutput = state.copy( + hasRemoteOutput = remote.isNotEmpty(), + awaitingRemote = if (suffix.isNotEmpty()) false else state.awaitingRemote, + ) + return if (withOutput.predicted.isEmpty()) { + syncUnpredicted(withOutput, remote, suffix) + } else { + syncPredicted(withOutput, remote, suffix) + } + } + + /** + * True when the visible tail of pty output asks for a secret (password, + * passphrase, passcode, or PIN). Those prompts run with echo disabled, so + * painting the key would reveal it until the next remote frame. + */ + fun looksLikePasswordPrompt(buffer: String): Boolean { + if (buffer.isEmpty()) return false + val visible = stripAnsi(buffer.takeLast(PASSWORD_TAIL_CHARS)) + val line = visible.substringAfterLast('\n').substringAfterLast('\r').trimEnd() + return PASSWORD_PROMPT.containsMatchIn(line) + } + + /** + * True for space and other non-control characters. C0/C1 controls and DEL + * are excluded because the pty may swallow or expand them. + */ + private fun isEchoableCodePoint(codePoint: Int): Boolean = + codePoint >= 0x20 && codePoint != 0x7F && codePoint !in 0x80..0x9F + + /** True when every code point in [input] is safe to paint ahead of the pty. */ + private fun isPredictableEcho(input: String): Boolean { + if (input.isEmpty() || input.length > MAX_PREDICTABLE_INPUT) return false + return codePointsAreEchoable(input) + } + + /** True when [input] contains no C0/C1 control or DEL code point. */ + private fun codePointsAreEchoable(input: String): Boolean { + var index = 0 + while (index < input.length) { + val codePoint = input.codePointAt(index) + if (!isEchoableCodePoint(codePoint)) return false + index += Character.charCount(codePoint) + } + return true + } + + /** + * True when [echo] may be painted now. A missing surface, a cursor that is + * not ready, or a prediction that would pass [MAX_PENDING_INPUT] stays on + * the remote path. + */ + private fun canPaintAhead( + state: TerminalEchoState, + echo: String, + canPaint: Boolean + ): Boolean { + if (!canPaint || !shouldPaint(state)) return false + return state.predicted.length + echo.length <= MAX_PENDING_INPUT + } + + /** + * Local echo is safe only after the pty has drawn a frame, echo has not + * been contradicted, no control key is still in flight, every outstanding + * byte is already on screen, and the cursor is not sitting on a secret prompt. + */ + private fun shouldPaint(state: TerminalEchoState): Boolean { + val cursorReady = state.hasRemoteOutput && + state.echoTrusted && + !state.awaitingRemote && + state.pendingInput == state.predicted + return cursorReady && !looksLikePasswordPrompt(state.confirmed) + } + + /** + * Keeps a key that was not painted so a later verbatim echo can arm + * prediction. A non-empty control holds painting until the pty answers, + * because that key can move the cursor. Already-trusted input that we + * refused to paint (a secret, a full prediction cap, or a surface that is + * not ready) must not block the next safe key. + */ + private fun skipPaint( + state: TerminalEchoState, + echo: String?, + input: String + ): LocalEchoDecision { + val held = if (input.isNotEmpty() && echo == null) { + state.copy(awaitingRemote = true) + } else { + state + } + if (echo == null || held.echoTrusted) { + return LocalEchoDecision(paint = null, state = held) + } + return LocalEchoDecision( + paint = null, + state = held.copy(pendingInput = rememberPending(held.pendingInput, echo)), + ) + } + + /** Drops the probe once it exceeds [MAX_PENDING_INPUT]. */ + private fun rememberPending(pending: String, echo: String): String { + val combined = pending + echo + if (combined.length > MAX_PENDING_INPUT) return "" + return combined + } + + /** Applies pty bytes that were not painted ahead of time. */ + private fun syncUnpredicted( + state: TerminalEchoState, + remote: String, + suffix: String + ): TerminalBufferSync { + val learned = learnEcho(state.pendingInput, suffix) + val next = state.copy( + confirmed = remote, + pendingInput = pendingAfterRemote(state.pendingInput, suffix, learned), + echoTrusted = state.echoTrusted || learned.confirmedEcho, + ) + if (suffix.isEmpty()) return TerminalBufferSync.InSync(next) + return TerminalBufferSync.Feed(suffix, next) + } + + /** Applies pty bytes while a local prediction is still on screen. */ + private fun syncPredicted( + state: TerminalEchoState, + remote: String, + suffix: String + ): TerminalBufferSync = when { + suffix.isEmpty() || state.predicted.startsWith(suffix) -> + confirmPredictedPrefix(state, remote, suffix) + suffix.startsWith(state.predicted) -> + confirmPredictedAndFeedRest(state, remote, suffix) + else -> resetTo(remote, echoTrusted = false, replyFrom = state.confirmed.length) + } + + /** The remote stream confirmed a prefix of what is already on screen. */ + private fun confirmPredictedPrefix( + state: TerminalEchoState, + remote: String, + suffix: String + ): TerminalBufferSync { + if (suffix.isEmpty()) return TerminalBufferSync.InSync(state) + return TerminalBufferSync.InSync( + state.copy( + confirmed = remote, + predicted = state.predicted.substring(suffix.length), + pendingInput = dropMatchedPrefix(state.pendingInput, suffix), + echoTrusted = true, + ), + ) + } + + /** The prediction was echoed, and the pty sent more bytes after it. */ + private fun confirmPredictedAndFeedRest( + state: TerminalEchoState, + remote: String, + suffix: String + ): TerminalBufferSync { + val extra = suffix.substring(state.predicted.length) + val pending = dropMatchedPrefix(state.pendingInput, state.predicted) + val learned = learnEcho(pending, extra) + return TerminalBufferSync.Feed( + extra, + state.copy( + confirmed = remote, + predicted = "", + pendingInput = pendingAfterRemote(pending, extra, learned), + echoTrusted = true, + ), + ) + } + + /** + * Consumes [pending] when [suffix] echoes it. Anything that is not that + * echo is dropped, except when [suffix] is empty: the pty has not spoken + * yet, so the probe stays armed. + */ + private fun pendingAfterRemote( + pending: String, + suffix: String, + learned: LearnedEcho + ): String { + if (suffix.isNotEmpty() && !learned.confirmedEcho) return "" + return if (suffix.isEmpty()) pending else learned.pending + } + + /** Matches [pending] against the front of a new pty suffix. */ + private fun learnEcho(pending: String, suffix: String): LearnedEcho = when { + pending.isEmpty() || suffix.isEmpty() -> LearnedEcho(pending, confirmedEcho = false) + suffix.startsWith(pending) -> LearnedEcho("", confirmedEcho = true) + pending.startsWith(suffix) -> + LearnedEcho(pending.substring(suffix.length), confirmedEcho = true) + else -> LearnedEcho(pending, confirmedEcho = false) + } + + /** Removes an echoed prefix, or forgets [pending] if it does not match. */ + private fun dropMatchedPrefix(pending: String, prefix: String): String = when { + prefix.isEmpty() -> pending + pending.startsWith(prefix) -> pending.substring(prefix.length) + else -> "" + } + + /** + * Length of the longest suffix of [previous] that is a prefix of [remote]. + * + * A scrollback trim drops the head of the pty buffer and may append new + * bytes in the same update. That overlapping tail was already answered. + * The scan is linear so a full scrollback does not compare quadratically + * on the UI thread. + */ + internal fun retainedHistoryLength(previous: String, remote: String): Int { + val fast = when { + previous.isEmpty() || remote.isEmpty() -> 0 + remote.startsWith(previous) -> previous.length + else -> -1 + } + if (fast >= 0) return fast + val border = prefixBorder(remote) + var matched = 0 + for (index in previous.indices) { + while (matched > 0 && previous[index] != remote[matched]) { + matched = border[matched - 1] + } + if (previous[index] == remote[matched]) { + matched++ + } + if (matched == remote.length && index != previous.lastIndex) { + matched = border[matched - 1] + } + } + return matched + } + + /** Knuth-Morris-Pratt border table for [pattern]. */ + private fun prefixBorder(pattern: String): IntArray { + val border = IntArray(pattern.length) + var length = 0 + var index = 1 + while (index < pattern.length) { + if (pattern[index] == pattern[length]) { + length++ + border[index] = length + index++ + } else if (length > 0) { + length = border[length - 1] + } else { + index++ + } + } + return border + } + + /** + * Rebuilds emulator state from [remote] after the byte stream is no longer + * a prefix. [replyFrom] is the first index whose device replies are still owed. + */ + private fun resetTo( + remote: String, + echoTrusted: Boolean, + replyFrom: Int + ): TerminalBufferSync = TerminalBufferSync.Reset( + buffer = remote, + replyFrom = replyFrom, + state = TerminalEchoState( + confirmed = remote, + echoTrusted = echoTrusted, + hasRemoteOutput = remote.isNotEmpty(), + ), + ) + + /** Removes OSC, CSI, and single-character escapes so a prompt can be read. */ + private fun stripAnsi(text: String): String = + SIMPLE_ESC.replace(CSI.replace(OSC.replace(text, ""), ""), "") + + /** What remains of a probe after comparing it with one pty suffix. */ + private data class LearnedEcho(val pending: String, val confirmedEcho: Boolean) +} + +/** + * Cursor for local echo. [confirmed] is pty output already applied. + * [predicted] is the painted tail still absent from [confirmed]. + * [pendingInput] is predictable input not yet observed in the pty stream. + * [awaitingRemote] is set after a control key until the pty sends new bytes, + * so the next character is not painted at a stale cursor. + */ +internal data class TerminalEchoState( + val confirmed: String = "", + val predicted: String = "", + val pendingInput: String = "", + val echoTrusted: Boolean = true, + val hasRemoteOutput: Boolean = false, + val awaitingRemote: Boolean = false +) + +/** Result of a local keystroke: [paint] is null when the pty must echo it. */ +internal data class LocalEchoDecision( + val paint: String?, + val state: TerminalEchoState +) + +/** How to update the Ghostty session after a remote buffer arrives. */ +internal sealed interface TerminalBufferSync { + val state: TerminalEchoState + + /** The emulator already shows [state]. */ + data class InSync(override val state: TerminalEchoState) : TerminalBufferSync + + /** Append [suffix] to the emulator. These bytes were not painted locally. */ + data class Feed(val suffix: String, override val state: TerminalEchoState) : TerminalBufferSync + + /** + * Discard the emulator and replay [buffer]. + * + * [replyFrom] is the index where bytes the previous session has not already + * answered begin. The view replays the prefix without sending device replies, + * so a historical cursor report is not typed into the shell. + */ + data class Reset( + val buffer: String, + val replyFrom: Int, + override val state: TerminalEchoState + ) : TerminalBufferSync +} diff --git a/apps/mobile/modules/t3-terminal/android/src/test/java/expo/modules/t3terminal/TerminalLocalEchoTest.kt b/apps/mobile/modules/t3-terminal/android/src/test/java/expo/modules/t3terminal/TerminalLocalEchoTest.kt new file mode 100644 index 0000000000000..063e99a918911 --- /dev/null +++ b/apps/mobile/modules/t3-terminal/android/src/test/java/expo/modules/t3terminal/TerminalLocalEchoTest.kt @@ -0,0 +1,364 @@ +package expo.modules.t3terminal + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class TerminalLocalEchoTest { + /** Cooked-mode typing is painted immediately and not fed a second time when the pty echoes it. */ + @Test + fun printableTypingIsPaintedAndNotFedAgainWhenThePtyEchoesIt() { + val harness = primed("$\n") + assertEquals("a", harness.type("a").paint) + assertEquals("b", harness.type("b").paint) + + val partial = harness.remote("$\na") + assertTrue(partial is TerminalBufferSync.InSync) + assertEquals("b", harness.state.predicted) + + val echoed = harness.remote("$\nab") + assertTrue(echoed is TerminalBufferSync.InSync) + assertEquals("", harness.state.predicted) + assertEquals("$\nab", harness.state.confirmed) + } + + /** A resize-style replay of the same pty buffer must leave an in-flight prediction on screen. */ + @Test + fun anIdenticalRemoteBufferDoesNotClearAnOutstandingPrediction() { + val harness = primed("$\n") + harness.type("a") + + val same = harness.remote("$\n") + assertTrue(same is TerminalBufferSync.InSync) + assertEquals("a", harness.state.predicted) + } + + /** Output that follows a predicted command is appended without repainting the command. */ + @Test + fun commandOutputAfterAPredictedLineFeedsOnlyTheUnpaintedTail() { + val harness = primed("$\n") + harness.type("ls") + + val sync = harness.remote("$\nls\r\nfile\r\n$\n") + assertFeed(sync, "\r\nfile\r\n$\n") + assertEquals("", harness.state.predicted) + } + + /** Backspace from the pty is not predicted; only the deletion sequence is fed after the echoed characters. */ + @Test + fun backspaceEchoFeedsOnlyTheDeletionSequence() { + val harness = primed("$\n") + harness.type("ab") + + assertFeed(harness.remote("$\nab\b \b"), "\b \b") + } + + /** + * A raw-mode redraw rolls the grid back and prediction stays off until a key + * is echoed verbatim. Device replies resume at the new suffix, not the history. + */ + @Test + fun aContradictionRebuildsFromThePtyAndStopsPredictingUntilACleanEcho() { + val harness = primed("$\n") + harness.type("i") + + val diverged = harness.remote("$\n\u001B[2J") + assertTrue(diverged is TerminalBufferSync.Reset) + val reset = diverged as TerminalBufferSync.Reset + assertEquals("$\n\u001B[2J", reset.buffer) + assertEquals("$\n".length, reset.replyFrom) + assertFalse(harness.state.echoTrusted) + assertNull(harness.type("j").paint) + assertEquals("j", harness.state.pendingInput) + + val unrelated = harness.remote("$\n\u001B[2JMORE") + assertFeed(unrelated, "MORE") + assertEquals("", harness.state.pendingInput) + assertFalse(harness.state.echoTrusted) + + assertNull(harness.type("s").paint) + assertFeed(harness.remote("$\n\u001B[2JMOREs"), "s") + assertTrue(harness.state.echoTrusted) + assertEquals("h", harness.type("h").paint) + } + + /** No new pty bytes means the probe used to re-arm echo is still waiting. */ + @Test + fun anUnchangedBufferKeepsTheProbeArmed() { + val harness = primed("$\n") + harness.type("i") + harness.remote("$\nSCREEN") + harness.type("j") + + val same = harness.remote(harness.state.confirmed) + assertTrue(same is TerminalBufferSync.InSync) + assertEquals("j", harness.state.pendingInput) + } + + /** A trimmed scrollback keeps echo trust, and the retained tail is not treated as new input. */ + @Test + fun scrollbackTrimKeepsEchoTrust() { + val harness = primed("scrollback\n$\n") + harness.type("a") + + val trimmed = harness.remote("$\n") + assertTrue(trimmed is TerminalBufferSync.Reset) + val reset = trimmed as TerminalBufferSync.Reset + assertEquals("$\n", reset.buffer) + assertEquals(reset.buffer.length, reset.replyFrom) + assertTrue(harness.state.echoTrusted) + assertEquals("", harness.state.predicted) + assertEquals("b", harness.type("b").paint) + } + + /** Bytes appended in the same update as a trim still owe device replies. A disjoint buffer is all live. */ + @Test + fun scrollbackTrimRepliesOnlyForTheNewTail() { + val harness = primed("HEAD\n$\n") + val trimmed = harness.remote("$\n\u001B[6n") + val reset = trimmed as TerminalBufferSync.Reset + assertEquals("$\n".length, reset.replyFrom) + + assertEquals(4, TerminalLocalEcho.retainedHistoryLength("ababa", "babaXY")) + assertEquals(3, TerminalLocalEcho.retainedHistoryLength("abcXabc", "abcY")) + assertEquals(2, TerminalLocalEcho.retainedHistoryLength("aaa", "aa")) + assertEquals(0, TerminalLocalEcho.retainedHistoryLength("$\n", "trimmed-tail")) + assertEquals(0, TerminalLocalEcho.retainedHistoryLength("", "abc")) + } + + /** Keys that arrive before the first pty frame wait, then typing predicts once a prompt exists. */ + @Test + fun keysBeforeTheFirstFrameAreNotPainted() { + val harness = EchoHarness() + assertNull(harness.type("a").paint) + assertEquals("", harness.state.pendingInput) + + assertFeed(harness.remote("$\n"), "$\n") + assertEquals("b", harness.type("b").paint) + } + + /** If Ghostty is not allocated yet, the key is not recorded as already painted. */ + @Test + fun aMissingSurfaceDoesNotClaimTheCharacterWasPainted() { + val harness = primed("$\n") + assertNull(harness.type("a", canPaint = false).paint) + assertEquals("", harness.state.predicted) + + assertFeed(harness.remote("$\na"), "a") + } + + /** Password entry is not painted, and the following shell prompt predicts again. */ + @Test + fun passwordPromptsAreNotPaintedAndTheNextPromptPredictsAgain() { + val harness = primed("[sudo] password for marcel: ") + val hidden = harness.type("secret") + assertNull(hidden.paint) + assertEquals("", harness.state.predicted) + assertEquals("", harness.state.pendingInput) + + assertFeed(harness.remote("[sudo] password for marcel: \r\n$\n"), "\r\n$\n") + assertEquals("ls", harness.type("ls").paint) + } + + /** Secret detection reads the visible prompt, including passcode and PIN, not ANSI or earlier lines. */ + @Test + fun passwordPromptDetectionIgnoresAnsiAndEarlierLines() { + assertTrue(TerminalLocalEcho.looksLikePasswordPrompt("\u001B[31mPassword:\u001B[0m ")) + assertTrue(TerminalLocalEcho.looksLikePasswordPrompt("Enter passphrase for key: ")) + assertTrue(TerminalLocalEcho.looksLikePasswordPrompt("pass phrase: ")) + assertTrue(TerminalLocalEcho.looksLikePasswordPrompt("Passcode: ")) + assertTrue(TerminalLocalEcho.looksLikePasswordPrompt("\u001B[31mPIN:\u001B[0m ")) + assertTrue(TerminalLocalEcho.looksLikePasswordPrompt("Enter PIN: ")) + assertTrue( + TerminalLocalEcho.looksLikePasswordPrompt("Password for 'https://user@github.com': ") + ) + assertTrue( + TerminalLocalEcho.looksLikePasswordPrompt("Enter passphrase (empty for no passphrase): "), + ) + assertFalse(TerminalLocalEcho.looksLikePasswordPrompt("user@host:~$ ")) + assertFalse(TerminalLocalEcho.looksLikePasswordPrompt("user@host:~/pin: ")) + assertFalse(TerminalLocalEcho.looksLikePasswordPrompt("user@host:~/password-manager: ")) + assertFalse(TerminalLocalEcho.looksLikePasswordPrompt("Password reset complete\n$ ")) + assertFalse(TerminalLocalEcho.looksLikePasswordPrompt("echo hello")) + assertFalse(TerminalLocalEcho.looksLikePasswordPrompt("mapping: ")) + } + + /** Passcode and PIN prompts leave the secret for the pty, which has echo disabled. */ + @Test + fun passcodeAndPinPromptsAreNotPainted() { + for (prompt in listOf("Passcode: ", "PIN: ", "passcode for device: ")) { + val harness = primed(prompt) + assertNull(harness.type("1234").paint) + assertEquals("", harness.state.predicted) + assertEquals("", harness.state.pendingInput) + } + } + + /** + * Trusted prediction stops at [TerminalLocalEcho.MAX_PENDING_INPUT]. Further + * keystrokes are not copied into the predicted tail; the pty echo of that + * overflow is fed once, and painting resumes after the tail drains. + */ + @Test + fun trustedPredictionStopsPaintingAtThePendingCap() { + val harness = primed("$\n") + val chunk = "a".repeat(64) + repeat(4) { + assertEquals(chunk, harness.type(chunk).paint) + } + assertEquals(TerminalLocalEcho.MAX_PENDING_INPUT, harness.state.predicted.length) + + repeat(20) { + assertNull(harness.type("Z").paint) + } + assertEquals(TerminalLocalEcho.MAX_PENDING_INPUT, harness.state.predicted.length) + assertEquals(TerminalLocalEcho.MAX_PENDING_INPUT, harness.state.pendingInput.length) + assertFalse(harness.state.predicted.contains("Z")) + + val echoed = "$\n" + chunk.repeat(4) + "Z" + assertFeed(harness.remote(echoed), "Z") + assertEquals("", harness.state.predicted) + assertEquals("b", harness.type("b").paint) + } + + /** An untrusted probe is discarded once it would grow past the same pending cap. */ + @Test + fun anUntrustedProbeIsDroppedOnceItPassesThePendingCap() { + val harness = primed("$\n") + harness.type("i") + harness.remote("$\n\u001B[2J") + assertFalse(harness.state.echoTrusted) + + val chunk = "a".repeat(64) + repeat(5) { + assertNull(harness.type(chunk).paint) + } + assertTrue(harness.state.pendingInput.length <= TerminalLocalEcho.MAX_PENDING_INPUT) + } + + /** DEL moves the cursor, so a printable key typed before the pty answers is not painted locally. */ + @Test + fun deleteThenAPrintableKeyWaitsUntilThePtyReplies() { + val harness = primed("$\nab") + assertNull(harness.type("\u007F").paint) + assertTrue(harness.state.awaitingRemote) + assertNull(harness.type("c").paint) + assertEquals("", harness.state.predicted) + + assertFeed(harness.remote("$\nab\b \bc"), "\b \bc") + assertFalse(harness.state.awaitingRemote) + assertEquals("d", harness.type("d").paint) + } + + /** Enter moves the cursor, so typeahead is not painted until the next pty frame. */ + @Test + fun enterThenAPrintableKeyWaitsUntilThePtyReplies() { + val harness = primed("$\n") + harness.type("ls") + assertNull(harness.type("\r").paint) + assertTrue(harness.state.awaitingRemote) + assertNull(harness.type("x").paint) + + assertFeed(harness.remote("$\nls\r\n$\nx"), "\r\n$\nx") + assertFalse(harness.state.awaitingRemote) + assertEquals("y", harness.type("y").paint) + } + + /** A secret typed after Enter, before the password prompt arrives, is not painted. */ + @Test + fun typeaheadBeforeAPasswordPromptArrivesIsNotPainted() { + val harness = primed("$\n") + assertNull(harness.type("\r").paint) + assertNull(harness.type("s3cret").paint) + assertEquals("", harness.state.predicted) + + assertFeed( + harness.remote("$\n\r\n[sudo] password for marcel: "), + "\r\n[sudo] password for marcel: ", + ) + assertNull(harness.type("x").paint) + assertEquals("", harness.state.predicted) + } + + /** Controls, mixed newlines, and pastes wait for the pty; short words and emoji do not. */ + @Test + fun controlsPastesAndOverlongInputAreNotLocalEcho() { + val harness = primed("$\n") + assertNull(TerminalLocalEcho.predictableEcho("")) + assertNull(TerminalLocalEcho.predictableEcho("\r")) + assertNull(TerminalLocalEcho.predictableEcho("\n")) + assertNull(TerminalLocalEcho.predictableEcho("\t")) + assertNull(TerminalLocalEcho.predictableEcho("\u007F")) + assertNull(TerminalLocalEcho.predictableEcho("\u0003")) + assertNull(TerminalLocalEcho.predictableEcho("a\nb")) + assertNull(TerminalLocalEcho.predictableEcho("x".repeat(65))) + assertEquals("ok", TerminalLocalEcho.predictableEcho("ok")) + assertEquals(" ", TerminalLocalEcho.predictableEcho(" ")) + assertEquals("šŸ˜€", TerminalLocalEcho.predictableEcho("šŸ˜€")) + + assertNull(harness.type("\u007F").paint) + assertEquals("", harness.state.predicted) + assertTrue(harness.state.awaitingRemote) + assertNull(harness.type("hello").paint) + assertFeed(harness.remote("$\n\b \bhello"), "\b \bhello") + assertEquals("!", harness.type("!").paint) + } + + /** A multi-unit emoji is one echo and is not fed again when the pty returns it. */ + @Test + fun emojiEchoIsConfirmedWithoutASecondFeed() { + val harness = primed("$\n") + assertEquals("šŸ˜€", harness.type("šŸ˜€").paint) + assertTrue(harness.remote("$\nšŸ˜€") is TerminalBufferSync.InSync) + assertEquals("", harness.state.predicted) + } + + /** Feeds [prompt] as the first pty frame so later keystrokes are allowed to paint. */ + private fun primed(prompt: String): EchoHarness { + val harness = EchoHarness() + assertFeed(harness.remote(prompt), prompt) + assertTrue(harness.state.hasRemoteOutput) + return harness + } + + /** Asserts [sync] appends exactly [suffix] and nothing that was already painted. */ + private fun assertFeed(sync: TerminalBufferSync, suffix: String) { + if (sync !is TerminalBufferSync.Feed) { + error("expected feed of ${suffix.debug()} but was $sync") + } + assertEquals(suffix, sync.suffix) + } +} + +private class EchoHarness(initial: TerminalEchoState = TerminalEchoState()) { + var state: TerminalEchoState = initial + private set + + /** Applies an authoritative pty buffer and keeps the resulting cursor. */ + fun remote(buffer: String): TerminalBufferSync { + val sync = TerminalLocalEcho.applyRemoteBuffer(state, buffer) + state = sync.state + return sync + } + + /** Records one local input, optionally while the native surface cannot paint. */ + fun type(input: String, canPaint: Boolean = true): LocalEchoDecision { + val decision = TerminalLocalEcho.noteLocalInput(state, input, canPaint) + state = decision.state + return decision + } +} + +/** Renders control bytes so a failed feed assertion shows the sequence that arrived. */ +private fun String.debug(): String = buildString { + for (character in this@debug) { + val code = character.code + if (code < 0x20 || code == 0x7F) { + append("\\u").append(code.toString(16).padStart(4, '0')) + } else { + append(character) + } + } +}