fix(terminal): flush unsent local echo before the geometry replay

On a touch device the characters the user has typed live only in the local-echo
overlay until Enter; they have never reached the PTY. The replay re-enters
`selectSession` with `forceReload` on the session that is still active, and that
branch nulled `activeSessionId` before `_cleanupPreviousSession` ran. The flush
there is guarded on a session it can still see, so it was skipped, and the
unconditional `_localEchoOverlay.clear()` that follows took the characters with
it. Measured in chromium against the previous head: typing into the overlay and
then making the call the replay makes left `pendingText` empty with nothing
crossing into the delivery layer on either transport.

The flush moves into `_flushLocalEchoTo(sessionId)`, called from both
`_cleanupPreviousSession` and the `forceReload` branch before it nulls the id.
The session is a parameter because the two callers mean different ones: cleanup
flushes to the tab being left, the branch to the tab being reloaded.

This was reachable before this branch, through the one gesture that already
takes the `forceReload` path on an active session. What is new is that nothing
the user does triggers it. The replay fires on its own the moment a tab switch
finishes, which is exactly when someone typing into a still-loading terminal has
text in the overlay, and on a phone beside an active desktop tab that is every
tab switch.

A seventh browser case pins it: it forces the overlay on, since headless
chromium reports no touch support and the case would otherwise pass vacuously,
asserts the typed characters really are sitting unsent, then triggers the replay
and asserts they reached the session. Without the fix it fails with nothing
delivered at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michael Grundberg
2026-09-19 10:44:35 +02:00
co-authored by Claude Opus 5
parent e0d4477edc
commit 383f834704
2 changed files with 119 additions and 22 deletions
+47 -22
View File
@@ -5783,28 +5783,7 @@ class CodemanApp {
if (ta) ta.dispatchEvent(new CompositionEvent('compositionend', { data: '' }));
}
} catch {}
// Flush local echo text to PTY before switching tabs.
// Send as a single batch (no Enter) so it lands in the session's readline
// input buffer — avoids "old text resent on Enter" and overlay render bugs.
// Track flushed length so _render() offsets the overlay correctly even before
// the PTY echo arrives in the terminal buffer.
if (this.activeSessionId) {
const echoText = this._localEchoOverlay?.pendingText || '';
// Include buffer-detected flushed text (from Tab completion, etc.)
// so it's preserved across tab switches.
const existingFlushed = this._localEchoOverlay?.getFlushed()?.count || 0;
const existingFlushedText = this._localEchoOverlay?.getFlushed()?.text || '';
if (echoText) {
this._sendInputAsync(this.activeSessionId, echoText);
}
const totalOffset = existingFlushed + echoText.length;
if (totalOffset > 0) {
if (!this._flushedOffsets) this._flushedOffsets = new Map();
if (!this._flushedTexts) this._flushedTexts = new Map();
this._flushedOffsets.set(this.activeSessionId, totalOffset);
this._flushedTexts.set(this.activeSessionId, existingFlushedText + echoText);
}
}
this._flushLocalEchoTo(this.activeSessionId);
this._localEchoOverlay?.clear();
// Predictions are ephemeral + already sent: nothing to save/restore
// across a tab switch (unlike the buffer overlay's setFlushed machinery)
@@ -5819,6 +5798,45 @@ class CodemanApp {
}
}
/**
* Hand the local-echo overlay's unsent text to `sessionId` before anything
* clears it, and record what has now been flushed so `_render()` offsets the
* overlay correctly even before the PTY echo comes back.
*
* On a touch device the characters the user has typed live ONLY here until
* Enter — they have never reached the PTY — so whoever clears the overlay
* owes them a flush first. It is sent as one batch with no Enter, so it lands
* in the session's readline buffer rather than submitting a line the user has
* not finished.
*
* ⚠️ The session is a PARAMETER because the two callers are looking at
* different ones. `_cleanupPreviousSession` flushes to the tab being left,
* which is still `activeSessionId` when it runs. The `forceReload` branch in
* `selectSession` flushes to the tab being RELOADED, and must do it before it
* nulls `activeSessionId`: reading the field after that null is what silently
* dropped the text, since the guard here then saw no session and the
* unconditional `clear()` that follows took the characters with it.
* @param {string|null} sessionId
*/
_flushLocalEchoTo(sessionId) {
if (!sessionId) return;
const echoText = this._localEchoOverlay?.pendingText || '';
// Include buffer-detected flushed text (from Tab completion, etc.)
// so it's preserved across tab switches.
const existingFlushed = this._localEchoOverlay?.getFlushed()?.count || 0;
const existingFlushedText = this._localEchoOverlay?.getFlushed()?.text || '';
if (echoText) {
this._sendInputAsync(sessionId, echoText);
}
const totalOffset = existingFlushed + echoText.length;
if (totalOffset > 0) {
if (!this._flushedOffsets) this._flushedOffsets = new Map();
if (!this._flushedTexts) this._flushedTexts = new Map();
this._flushedOffsets.set(sessionId, totalOffset);
this._flushedTexts.set(sessionId, existingFlushedText + echoText);
}
}
_resetTerminalForReplay() {
this.terminal.reset();
this.terminal.write('\x1b[3J\x1b[H\x1b[2J');
@@ -6093,6 +6111,13 @@ class CodemanApp {
this._loadBufferQueue = null;
this._terminalRefreshOwner = null;
this._chunkedWriteGen = (this._chunkedWriteGen || 0) + 1;
// Anything typed but not yet submitted lives in the local-echo overlay and
// has never reached the PTY. `_cleanupPreviousSession` below flushes it,
// but only for a session it can still see, and the null on the next line
// hides this one from it. Flush first or the characters are cleared
// unread. The geometry replay re-enters here with no gesture behind it,
// so on a touch device this fires while the user is still typing.
this._flushLocalEchoTo(sessionId);
this.activeSessionId = null;
}
// Focus terminal SYNCHRONOUSLY before any await — iOS Safari only honors