diff --git a/apps/mobile/modules/t3-terminal/android/build.gradle b/apps/mobile/modules/t3-terminal/android/build.gradle index 0da0777cf4ff..ba497d80b2ca 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 94e0b38e51d6..2c17ef826281 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 000000000000..71300589ddce --- /dev/null +++ b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/TerminalLocalEcho.kt @@ -0,0 +1,445 @@ +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; that key stays in pendingInput until the pty echoes it. + */ + internal const val MAX_PENDING_INPUT = 256 + + private val OSC = Regex("\u001B\\][^\u0007]*(?:\u0007|\u001B\\\\)") + private val CSI = Regex("\u001B\\[[0-9:;<=>?]*[ -/]*[@-~]") + private val SIMPLE_ESC = Regex("\u001B[@-Z\\-_]") + + /** + * Secret prompt on the current line. 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, canPaint) + } + 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 = stillAwaitingRemote(state, suffix), + ) + return if (withOutput.predicted.isEmpty()) { + syncUnpredicted(withOutput, remote, suffix) + } else { + syncPredicted(withOutput, remote, suffix) + } + } + + /** + * True when the current pty line 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. The whole current line + * is inspected so a long prompt cannot drop the leading keyword. + */ + fun looksLikePasswordPrompt(buffer: String): Boolean { + if (buffer.isEmpty()) return false + val line = stripAnsi( + buffer.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. Trusted keys refused at the + * pending-input cap stay in [TerminalEchoState.pendingInput] so a later + * partial echo cannot paint past them. Secrets and keys that arrived + * before the surface was ready are not recorded as pending. + */ + private fun skipPaint( + state: TerminalEchoState, + echo: String?, + input: String, + canPaint: Boolean + ): LocalEchoDecision { + val held = if (input.isNotEmpty() && echo == null) { + state.copy(awaitingRemote = true) + } else { + state + } + val next = when { + echo != null && shouldRetainTrustedCap(held, echo, canPaint) -> + held.copy(pendingInput = held.pendingInput + echo) + echo == null || held.echoTrusted -> held + else -> held.copy(pendingInput = rememberPending(held.pendingInput, echo)) + } + return LocalEchoDecision(paint = null, state = next) + } + + /** + * True when a trusted key was skipped only because painting it would grow + * the predicted tail past [MAX_PENDING_INPUT]. + */ + private fun shouldRetainTrustedCap( + state: TerminalEchoState, + echo: String, + canPaint: Boolean + ): Boolean { + if (!canPaint || !state.echoTrusted || !shouldPaint(state)) return false + return state.predicted.length + echo.length > MAX_PENDING_INPUT + } + + /** + * A control key stays unanswered while the new pty bytes are only the + * printable echo already recorded. `\r`/`\n` or other processing clears it. + */ + private fun stillAwaitingRemote(state: TerminalEchoState, suffix: String): Boolean { + val outstanding = outstandingPrintable(state) + return state.awaitingRemote && + (suffix.isEmpty() || (outstanding.isNotEmpty() && outstanding.startsWith(suffix))) + } + + /** Unconfirmed printable bytes the pty may still echo unchanged. */ + private fun outstandingPrintable(state: TerminalEchoState): String = when { + state.pendingInput.startsWith(state.predicted) -> state.pendingInput + state.predicted.startsWith(state.pendingInput) -> state.predicted + else -> state.predicted.ifEmpty { state.pendingInput } + } + + /** 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's handling of + * that key is visible, so a later printable echo of earlier keys cannot + * paint the next character 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 000000000000..88034cbe4e8c --- /dev/null +++ b/apps/mobile/modules/t3-terminal/android/src/test/java/expo/modules/t3terminal/TerminalLocalEchoTest.kt @@ -0,0 +1,454 @@ +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) + } + } + + /** + * A prompt longer than the old 240-char tail still matches. Taking only + * the tail would drop the leading `Password` token and paint the secret. + */ + @Test + fun aLongPasswordPromptLineIsDetectedWithoutDroppingItsPrefix() { + val host = "a".repeat(300) + val prompt = "Password for 'https://user@$host.example.com': " + assertTrue(prompt.length > 240) + assertTrue(TerminalLocalEcho.looksLikePasswordPrompt(prompt)) + val harness = primed(prompt) + assertNull(harness.type("secret").paint) + assertEquals("", harness.state.predicted) + assertEquals("", harness.state.pendingInput) + } + + /** Long Passcode, PIN, and parenthetical passphrase lines stay on the pty path. */ + @Test + fun longPasscodePinAndPassphrasePromptLinesAreNotPainted() { + val padding = "x".repeat(300) + for (prompt in listOf( + "Passcode for $padding: ", + "PIN for $padding: ", + "Enter passphrase ($padding): ", + )) { + assertTrue(prompt.length > 240) + assertTrue(TerminalLocalEcho.looksLikePasswordPrompt(prompt)) + 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 + 1, harness.state.pendingInput.length) + assertTrue(harness.state.pendingInput.endsWith("Z")) + 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) + } + + /** + * Overflow at the pending cap stays in pendingInput. A partial echo must + * not paint the next key, or the later overflow echo would reset the grid. + */ + @Test + fun aPartialEchoAtThePendingCapDoesNotPaintPastRefusedKeys() { + val harness = primed("$\n") + val chunk = "a".repeat(64) + repeat(4) { + assertEquals(chunk, harness.type(chunk).paint) + } + assertNull(harness.type("Z").paint) + assertEquals(TerminalLocalEcho.MAX_PENDING_INPUT, harness.state.predicted.length) + assertEquals(TerminalLocalEcho.MAX_PENDING_INPUT + 1, harness.state.pendingInput.length) + assertTrue(harness.state.pendingInput.endsWith("Z")) + + val partial = harness.remote("$\n" + "a".repeat(200)) + assertTrue(partial is TerminalBufferSync.InSync) + assertEquals("a".repeat(56), harness.state.predicted) + assertTrue(harness.state.pendingInput.endsWith("Z")) + + assertNull(harness.type("W").paint) + assertEquals("a".repeat(56), harness.state.predicted) + assertFalse(harness.state.predicted.contains("W")) + + val rest = harness.remote("$\n" + "a".repeat(256) + "Z") + assertFeed(rest, "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) + } + + /** + * Echo of the command text is not Enter being processed. Hold local echo + * until CR/LF (or other control handling) is visible, or the next key is + * painted on the same line and the later newline resets the grid. + */ + @Test + fun awaitingRemoteIsNotClearedByAPrintableOnlySuffix() { + val harness = primed("$\n") + harness.type("ls") + assertNull(harness.type("\r").paint) + assertTrue(harness.state.awaitingRemote) + + val echoed = harness.remote("$\nls") + assertTrue(echoed is TerminalBufferSync.InSync) + assertTrue(harness.state.awaitingRemote) + assertNull(harness.type("x").paint) + assertEquals("", harness.state.predicted) + + val processed = harness.remote("$\nls\r\n$\n") + assertFeed(processed, "\r\n$\n") + 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) + } + } +}