diff --git a/apps/mobile/modules/t3-terminal/android/build.gradle b/apps/mobile/modules/t3-terminal/android/build.gradle index 0da0777cf4ff..85a2f1220a03 100644 --- a/apps/mobile/modules/t3-terminal/android/build.gradle +++ b/apps/mobile/modules/t3-terminal/android/build.gradle @@ -30,4 +30,5 @@ android { dependencies { implementation project(':expo-modules-core') + testImplementation 'junit:junit:4.13.2' } 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..e458ed71c372 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 @@ -245,7 +247,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 +257,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 +278,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) } } @@ -343,12 +343,12 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex cursorColorValue, paletteColors, ) - fedBuffer = "" } private fun recreateTerminal() { if (terminalHandle == 0L) return destroyTerminal() + echoState = TerminalEchoState() createTerminal() feedPendingBuffer() renderSnapshot() @@ -358,30 +358,91 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex 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. Any other pty bytes clear the echo + * gate and are drawn 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. Printable typing is painted only after + * the pty has finished echoing at least two characters on the line. + * Echo-off prompts, including `read -s -p 'API token: '`, are not painted. + */ + private fun emitTypedInput(data: String) { + if (data.isEmpty() || isCleanedUp) return + val decision = TerminalLocalEcho.noteLocalInput( + echoState, + data, + terminalHandle != 0L, + ) + val paint = decision.paint + echoState = decision.state + if (paint != null) { + feedBytes(paint) + clearSelectionAfterOutput() + renderSnapshot() + } + onInput(mapOf("data" to data)) + } + + 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. + */ + private fun rebuildFromRemote(sync: TerminalBufferSync.Reset) { + destroyTerminal() + createTerminal() + if (terminalHandle == 0L) { + echoState = TerminalEchoState() + 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) } - fedBuffer = initialBuffer + if (sync.buffer.isNotEmpty()) { + clearSelectionAfterOutput() + } + echoState = sync.state renderSnapshot() } + private fun feedBytes(data: String) { + if (terminalHandle == 0L || data.isEmpty()) return + emitResponse(GhosttyBridge.nativeFeed(terminalHandle, data.toByteArray(Charsets.UTF_8))) + } + + 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..cc0621ece377 --- /dev/null +++ b/apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/TerminalLocalEcho.kt @@ -0,0 +1,406 @@ +package expo.modules.t3terminal + +/** + * Paints printable Android keystrokes before the remote pty echoes them. + * + * Echo-off is not on the wire. A proper prefix of unsent input, or a single + * echoed byte, is not proof the pty is echoing: unrelated output such as `a` + * matches the start of a secret. Painting starts only after the pty has + * finished echoing at least two typed characters on the line, with nothing + * else mixed in. Any other output closes the gate. + */ +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. A trusted key that would pass + * it is not painted and stays in [TerminalEchoState.pendingInput] so the + * next key cannot be drawn ahead of it. + */ + internal const val MAX_PENDING_INPUT = 256 + + /** + * One matching byte is not an authoritative echo. Trust requires the pty + * to finish the outstanding input and to have echoed at least this many + * characters on the line. + */ + private const val MIN_AUTHORITATIVE_ECHO = 2 + + /** + * Records [input] and, when echo is known to be on, returns the bytes to + * paint. [canPaint] is false until the native terminal exists. + */ + 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 + * is not fed again. Anything other than a finished echo of the outstanding + * input clears the echo gate. A contradiction rebuilds from [remote]. + */ + fun applyRemoteBuffer(state: TerminalEchoState, remote: String): TerminalBufferSync { + if (!remote.startsWith(state.confirmed)) { + return resetTo(remote, replyFrom = retainedHistoryLength(state.confirmed, remote)) + } + val suffix = remote.substring(state.confirmed.length) + val withHold = state.copy(awaitingRemote = stillAwaitingRemote(state, suffix)) + return if (withHold.predicted.isEmpty()) { + syncUnpredicted(withHold, remote, suffix) + } else { + syncPredicted(withHold, remote, suffix) + } + } + + /** Short printable input a cooked shell echoes unchanged, or null. */ + fun predictableEcho(input: String): String? { + if (input.isEmpty() || input.length > MAX_PREDICTABLE_INPUT) return null + var index = 0 + while (index < input.length) { + val codePoint = input.codePointAt(index) + if (codePoint < 0x20 || codePoint == 0x7F || codePoint in 0x80..0x9F) return null + index += Character.charCount(codePoint) + } + return input + } + + private fun canPaintAhead( + state: TerminalEchoState, + echo: String, + canPaint: Boolean, + ): Boolean { + if (!canPaint || !echoKnownOn(state)) return false + return state.predicted.length + echo.length <= MAX_PENDING_INPUT + } + + /** + * Echo is known on only after an authoritative echo, with no control key + * still in flight and no unpainted printable bytes ahead of the cursor. + */ + private fun echoKnownOn(state: TerminalEchoState): Boolean = + state.echoTrusted && !state.awaitingRemote && state.pendingInput == state.predicted + + private fun skipPaint( + state: TerminalEchoState, + echo: String?, + input: String, + canPaint: Boolean, + ): LocalEchoDecision { + val held = if (input.isNotEmpty() && echo == null) { + state.copy(awaitingRemote = true, awaitingEchoPast = state.pendingInput.length) + } else { + state + } + val next = when { + echo != null && shouldRetainTrustedCap(held, echo, canPaint) -> + held.copy(pendingInput = held.pendingInput + echo) + echo != null && held.echoTrusted && !echoKnownOn(held) -> holdUnpainted(held, echo) + echo == null || held.echoTrusted -> held + else -> held.copy(pendingInput = rememberPending(held.pendingInput, echo)) + } + return LocalEchoDecision(paint = null, state = next) + } + + /** + * Keeps an unpainted trusted key in [TerminalEchoState.pendingInput] so a + * later partial echo cannot draw the key after it. A key that would grow + * pending past [MAX_PENDING_INPUT] holds the echo gate until the pty sends + * something other than the bytes already recorded. + */ + private fun holdUnpainted(state: TerminalEchoState, echo: String): TerminalEchoState { + if (state.pendingInput.length + echo.length <= MAX_PENDING_INPUT) { + return state.copy(pendingInput = state.pendingInput + echo) + } + return state.copy(awaitingRemote = true) + } + + private fun shouldRetainTrustedCap( + state: TerminalEchoState, + echo: String, + canPaint: Boolean, + ): Boolean { + if (!canPaint || !echoKnownOn(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. Bytes typed after the key clear the + * hold once they are echoed, even when the key itself produced no output. + */ + private fun stillAwaitingRemote(state: TerminalEchoState, suffix: String): Boolean { + if (!state.awaitingRemote) return false + val outstanding = outstandingPrintable(state) + val echoedPastControl = suffix.length > state.awaitingEchoPast && + outstanding.isNotEmpty() && + outstanding.startsWith(suffix) + if (echoedPastControl) return false + return suffix.isEmpty() || (outstanding.isNotEmpty() && outstanding.startsWith(suffix)) + } + + 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 } + } + + private fun rememberPending(pending: String, echo: String): String { + val combined = pending + echo + if (combined.length > MAX_PENDING_INPUT) return "" + return combined + } + + 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 = trustAfterEcho(state, suffix, learned), + echoedOnLine = echoedOnLineAfter(state, suffix, learned), + awaitingEchoPast = advanceHold(state, learned), + hasRemoteOutput = remote.isNotEmpty(), + ) + if (suffix.isEmpty()) return TerminalBufferSync.InSync(next) + return TerminalBufferSync.Feed(suffix, next) + } + + 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, replyFrom = state.confirmed.length) + } + + 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), + echoedOnLine = state.echoedOnLine + suffix.length, + awaitingEchoPast = (state.awaitingEchoPast - suffix.length).coerceAtLeast(0), + hasRemoteOutput = true, + ), + ) + } + + 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) + val finishedExtra = learned.confirmedEcho && learned.pending.isEmpty() && extra == pending + return TerminalBufferSync.Feed( + extra, + state.copy( + confirmed = remote, + predicted = "", + pendingInput = pendingAfterRemote(pending, extra, learned), + echoTrusted = state.echoTrusted && (extra.isEmpty() || finishedExtra), + echoedOnLine = if (extra.isEmpty() || finishedExtra) { + state.echoedOnLine + state.predicted.length + pending.length + } else { + 0 + }, + awaitingEchoPast = 0, + hasRemoteOutput = true, + ), + ) + } + + /** + * Enables the gate only when [suffix] is exactly the outstanding input and + * the line has now echoed at least [MIN_AUTHORITATIVE_ECHO] characters. + * A proper prefix, including a lone `a`, does not enable it. + */ + private fun trustAfterEcho( + state: TerminalEchoState, + suffix: String, + learned: LearnedEcho, + ): Boolean { + if (suffix.isEmpty()) return state.echoTrusted + if (!finishedEcho(state.pendingInput, suffix, learned)) return false + return state.echoedOnLine + state.pendingInput.length >= MIN_AUTHORITATIVE_ECHO + } + + private fun echoedOnLineAfter( + state: TerminalEchoState, + suffix: String, + learned: LearnedEcho, + ): Int { + if (suffix.isEmpty()) return state.echoedOnLine + val consumed = if (learned.confirmedEcho) { + state.pendingInput.length - learned.pending.length + } else { + 0 + } + if (consumed == 0) return 0 + return state.echoedOnLine + consumed + } + + /** The pty returned the whole outstanding input and nothing else. */ + private fun finishedEcho(pending: String, suffix: String, learned: LearnedEcho): Boolean = + pending.isNotEmpty() && + learned.confirmedEcho && + learned.pending.isEmpty() && + suffix.length == pending.length + + private fun advanceHold(state: TerminalEchoState, learned: LearnedEcho): Int { + if (!learned.confirmedEcho) return 0 + val consumed = state.pendingInput.length - learned.pending.length + return (state.awaitingEchoPast - consumed).coerceAtLeast(0) + } + + private fun pendingAfterRemote( + pending: String, + suffix: String, + learned: LearnedEcho, + ): String { + if (suffix.isNotEmpty() && !learned.confirmedEcho) return "" + return if (suffix.isEmpty()) pending else learned.pending + } + + 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) + } + + 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 and may append bytes in the same update. + * That overlap was already answered, so device replies are not resent. + */ + internal fun retainedHistoryLength(previous: String, remote: String): Int { + if (previous.isEmpty() || remote.isEmpty()) return 0 + if (remote.startsWith(previous)) return previous.length + 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 + } + + 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 + } + + private fun resetTo(remote: String, replyFrom: Int): TerminalBufferSync = TerminalBufferSync.Reset( + buffer = remote, + replyFrom = replyFrom, + state = TerminalEchoState( + confirmed = remote, + echoTrusted = false, + hasRemoteOutput = remote.isNotEmpty(), + ), + ) + + private data class LearnedEcho(val pending: String, val confirmedEcho: Boolean) +} + +/** + * [confirmed] is pty output already applied. [predicted] is the painted tail + * still absent from [confirmed]. [pendingInput] is predictable input not yet + * observed. [echoTrusted] is set only after a finished multi-character echo + * and cleared when the pty sends anything else. [awaitingRemote] holds + * painting after a control key. [awaitingEchoPast] is the pending length at + * that key, so a later echoed character can release the hold when the key + * itself produced no output. [echoedOnLine] counts consecutive echoed + * characters since the last non-echo byte. + */ +internal data class TerminalEchoState( + val confirmed: String = "", + val predicted: String = "", + val pendingInput: String = "", + val echoTrusted: Boolean = false, + val hasRemoteOutput: Boolean = false, + val awaitingRemote: Boolean = false, + val awaitingEchoPast: Int = 0, + val echoedOnLine: Int = 0, +) + +/** [paint] is null when the key must wait for the pty. */ +internal data class LocalEchoDecision( + val paint: String?, + val state: TerminalEchoState, +) + +/** How to update Ghostty after a remote buffer arrives. */ +internal sealed interface TerminalBufferSync { + val state: TerminalEchoState + + data class InSync(override val state: TerminalEchoState) : TerminalBufferSync + + data class Feed(val suffix: String, override val state: TerminalEchoState) : TerminalBufferSync + + /** + * Replay [buffer]. [replyFrom] is where device replies may resume; earlier + * bytes were already answered by the previous session. + */ + 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..7afa66b9c7fe --- /dev/null +++ b/apps/mobile/modules/t3-terminal/android/src/test/java/expo/modules/t3terminal/TerminalLocalEchoTest.kt @@ -0,0 +1,180 @@ +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 { + @Test + fun thirdKeyPaintsOnlyAfterTwoFinishedEchoes() { + val prompt = note("user@host:~$ ") + val first = TerminalLocalEcho.noteLocalInput(prompt, "l", canPaint = true) + assertNull(first.paint) + assertFalse(first.state.echoTrusted) + + val echoedFirst = TerminalLocalEcho.applyRemoteBuffer(first.state, "user@host:~$ l") + assertFalse(echoedFirst.state.echoTrusted) + assertNull(TerminalLocalEcho.noteLocalInput(echoedFirst.state, "s", canPaint = true).paint) + + val second = TerminalLocalEcho.noteLocalInput(echoedFirst.state, "s", canPaint = true) + val echoedSecond = TerminalLocalEcho.applyRemoteBuffer(second.state, "user@host:~$ ls") + assertTrue(echoedSecond.state.echoTrusted) + assertEquals("", echoedSecond.state.pendingInput) + + val third = TerminalLocalEcho.noteLocalInput(echoedSecond.state, " ", canPaint = true) + assertEquals(" ", third.paint) + assertEquals(" ", third.state.predicted) + } + + @Test + fun unrelatedPrefixDoesNotEnableEcho() { + var state = note("API token: ") + for (ch in "secret") { + val decision = TerminalLocalEcho.noteLocalInput(state, ch.toString(), canPaint = true) + assertNull(decision.paint) + state = decision.state + } + val coincidental = TerminalLocalEcho.applyRemoteBuffer(state, "API token: a") + assertFalse(coincidental.state.echoTrusted) + val next = TerminalLocalEcho.noteLocalInput(coincidental.state, "x", canPaint = true) + assertNull(next.paint) + assertEquals("", next.state.predicted) + } + + @Test + fun singleEchoedByteDoesNotEnableEcho() { + val first = TerminalLocalEcho.noteLocalInput(note("API token: "), "a", canPaint = true) + val echoed = TerminalLocalEcho.applyRemoteBuffer(first.state, "API token: a") + assertFalse(echoed.state.echoTrusted) + val next = TerminalLocalEcho.noteLocalInput(echoed.state, "b", canPaint = true) + assertNull(next.paint) + } + + @Test + fun echoDisabledPromptNeverPaintsTheSecret() { + val typed = typeUnpainted("read -s") + val entered = TerminalLocalEcho.noteLocalInput(typed, "\r", canPaint = true) + assertTrue(entered.state.awaitingRemote) + val prompted = TerminalLocalEcho.applyRemoteBuffer( + entered.state, + "user@host:~$ read -s\r\nAPI token: ", + ) + assertFalse(prompted.state.echoTrusted) + assertFalse(prompted.state.awaitingRemote) + + var state = prompted.state + for (ch in "s3cret") { + val decision = TerminalLocalEcho.noteLocalInput(state, ch.toString(), canPaint = true) + assertNull(decision.paint) + state = decision.state + } + val hidden = TerminalLocalEcho.applyRemoteBuffer(state, "user@host:~$ read -s\r\nAPI token: ") + assertFalse(hidden.state.echoTrusted) + assertEquals("", hidden.state.predicted) + } + + @Test + fun keyPastThePendingCapIsNotPaintedAndBlocksTheNextKey() { + var state = trustedLine("prompt ") + val filled = "x".repeat(TerminalLocalEcho.MAX_PENDING_INPUT) + for (ch in filled) { + val decision = TerminalLocalEcho.noteLocalInput(state, ch.toString(), canPaint = true) + assertEquals(ch.toString(), decision.paint) + state = decision.state + } + + val overflow = TerminalLocalEcho.noteLocalInput(state, "y", canPaint = true) + assertNull(overflow.paint) + assertTrue(overflow.state.pendingInput.endsWith("y")) + assertFalse(overflow.state.pendingInput == overflow.state.predicted) + + val partial = TerminalLocalEcho.applyRemoteBuffer(overflow.state, "prompt ab$filled") + assertNull(TerminalLocalEcho.noteLocalInput(partial.state, "z", canPaint = true).paint) + + val echoed = TerminalLocalEcho.applyRemoteBuffer(overflow.state, "prompt ab$filled" + "y") + assertEquals("", echoed.state.pendingInput) + assertTrue(echoed.state.echoTrusted) + val after = TerminalLocalEcho.noteLocalInput(echoed.state, "q", canPaint = true) + assertEquals("q", after.paint) + + val blocked = TerminalLocalEcho.noteLocalInput(overflow.state, "z", canPaint = true) + assertTrue(blocked.state.awaitingRemote) + val echoedPast = TerminalLocalEcho.applyRemoteBuffer(blocked.state, "prompt ab$filled" + "yz") + assertFalse(echoedPast.state.echoTrusted) + assertNull(TerminalLocalEcho.noteLocalInput(echoedPast.state, "q", canPaint = true).paint) + } + + @Test + fun awaitingRemoteStaysSetWhileSuffixIsOnlyPrintableEcho() { + val typed = typeUnpainted("ls") + val entered = TerminalLocalEcho.noteLocalInput(typed, "\r", canPaint = true) + val echoOnly = TerminalLocalEcho.applyRemoteBuffer(entered.state, "user@host:~$ ls") + assertTrue(echoOnly.state.awaitingRemote) + assertNull(TerminalLocalEcho.noteLocalInput(echoOnly.state, "x", canPaint = true).paint) + + val processed = TerminalLocalEcho.applyRemoteBuffer(echoOnly.state, "user@host:~$ ls\r\n") + assertFalse(processed.state.awaitingRemote) + assertFalse(processed.state.echoTrusted) + } + + @Test + fun silentControlKeyDoesNotBlockLaterEchoedKeys() { + val trusted = trustedLine("prompt ") + val deleted = TerminalLocalEcho.noteLocalInput(trusted, "\u007F", canPaint = true) + assertTrue(deleted.state.awaitingRemote) + val typed = TerminalLocalEcho.noteLocalInput(deleted.state, "c", canPaint = true) + assertNull(typed.paint) + val echoed = TerminalLocalEcho.applyRemoteBuffer(typed.state, "prompt abc") + assertFalse(echoed.state.awaitingRemote) + assertEquals("d", TerminalLocalEcho.noteLocalInput(echoed.state, "d", canPaint = true).paint) + } + + @Test + fun divergentRemoteClearsEchoAndDoesNotKeepAPartialPaint() { + val trusted = trustedLine("prompt ") + val painted = TerminalLocalEcho.noteLocalInput(trusted, "bc", canPaint = true) + val reset = TerminalLocalEcho.applyRemoteBuffer(painted.state, "OTHER") + assertTrue(reset is TerminalBufferSync.Reset) + assertFalse(reset.state.echoTrusted) + assertEquals("", reset.state.predicted) + assertNull(TerminalLocalEcho.noteLocalInput(reset.state, "z", canPaint = true).paint) + } + + @Test + fun scrollbackTrimDoesNotResendTheOverlappingTail() { + val previous = "HEAD" + "tail-already-answered" + val remote = "tail-already-answered" + "new" + assertEquals( + "tail-already-answered".length, + TerminalLocalEcho.retainedHistoryLength(previous, remote), + ) + } + + private fun note(remote: String): TerminalEchoState { + val sync = TerminalLocalEcho.applyRemoteBuffer(TerminalEchoState(), remote) + assertFalse(sync.state.echoTrusted) + return sync.state + } + + private fun typeUnpainted(text: String): TerminalEchoState { + var state = note("user@host:~$ ") + for (ch in text) { + val decision = TerminalLocalEcho.noteLocalInput(state, ch.toString(), canPaint = true) + assertNull(decision.paint) + state = decision.state + } + return state + } + + private fun trustedLine(prompt: String): TerminalEchoState { + val first = TerminalLocalEcho.noteLocalInput(note(prompt), "a", canPaint = true) + val mid = TerminalLocalEcho.applyRemoteBuffer(first.state, prompt + "a") + assertFalse(mid.state.echoTrusted) + val second = TerminalLocalEcho.noteLocalInput(mid.state, "b", canPaint = true) + val echoed = TerminalLocalEcho.applyRemoteBuffer(second.state, prompt + "ab") + assertTrue(echoed.state.echoTrusted) + return echoed.state + } +}