From 92dbe6413f30b66b635620f133329b105b747ee2 Mon Sep 17 00:00:00 2001 From: lior Date: Wed, 29 Jul 2026 12:10:04 +0300 Subject: [PATCH 1/5] fix(terminal): retain session replay ownership --- src/web/public/app.js | 6 +++--- src/web/public/terminal-ui.js | 13 ++++++++++--- test/terminal-flush-budget.test.ts | 14 ++++++++++++++ 3 files changed, 27 insertions(+), 6 deletions(-) diff --git a/src/web/public/app.js b/src/web/public/app.js index 6f953be8..5278b8f3 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -4359,9 +4359,9 @@ class CodemanApp { bufferWasEmpty = true; } - // Buffer load complete — unblock live SSE writes. chunkedTerminalWrite calls - // _finishBufferLoad internally (discarding queued events to prevent duplicate - // content); if we skipped the write (cache hit or empty), call it here. + // Buffer load complete — unblock live SSE writes. selectSession owns this + // gate across every cache/fetch/replay stage, so its chunked writes leave + // the transaction open and we finish it exactly once here. // COD-144: when the load painted nothing, FLUSH the queued events instead of // discarding — a new session's prompt arrives only as a queued SSE event. if (this._isLoadingBuffer) { diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 3a9de819..ef7ec1df 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -2376,11 +2376,18 @@ Object.assign(CodemanApp.prototype, { // Generation counter: if a newer chunkedTerminalWrite starts (tab switch), // older writes abort instead of continuing to push stale data into the terminal. const writeGen = ++this._chunkedWriteGen; - const bufferLoadOwner = this._beginBufferLoad(loadOwner); + // A caller-provided owner means a wider operation (selectSession) already + // owns the live-output gate. Do not close that transaction after this one + // replay; the caller still has resize/fetch/reconciliation work to finish. + const ownsBufferLoad = loadOwner == null; + const bufferLoadOwner = ownsBufferLoad ? this._beginBufferLoad() : loadOwner; + const finishOwnedBufferLoad = () => { + if (ownsBufferLoad) this._finishBufferLoad(bufferLoadOwner); + }; return new Promise((resolve) => { if (!buffer || buffer.length === 0) { - this._finishBufferLoad(bufferLoadOwner); + finishOwnedBufferLoad(); resolve(); return; } @@ -2392,7 +2399,7 @@ Object.assign(CodemanApp.prototype, { const finish = () => { // Only finish if we're still the active write — a newer write owns buffer load state if (this._chunkedWriteGen === writeGen) { - this._finishBufferLoad(bufferLoadOwner); + finishOwnedBufferLoad(); } resolve(); }; diff --git a/test/terminal-flush-budget.test.ts b/test/terminal-flush-budget.test.ts index 1af721ab..b1695fc7 100644 --- a/test/terminal-flush-budget.test.ts +++ b/test/terminal-flush-budget.test.ts @@ -98,6 +98,20 @@ describe('terminal flush budget', () => { expect(finishBufferLoad).toHaveBeenCalledOnce(); }); + it('leaves a caller-owned buffer load open after replaying a chunk', async () => { + const { app } = loadTerminalUiHarness('codex'); + const beginBufferLoad = vi.fn(() => 'nested-owner'); + const finishBufferLoad = vi.fn(); + app._beginBufferLoad = beginBufferLoad; + app._finishBufferLoad = finishBufferLoad; + app.terminal.write = (_data: string, callback?: () => void) => callback?.(); + + await app.chunkedTerminalWrite('cached frame', 32 * 1024, 'select-owner'); + + expect(beginBufferLoad).not.toHaveBeenCalled(); + expect(finishBufferLoad).not.toHaveBeenCalled(); + }); + it('keeps stale buffer load owners from finishing a newer load', () => { const { app } = loadTerminalUiHarness('codex'); From 45f425df691a13142a51a97c0c2fa36a3bd5aa0d Mon Sep 17 00:00:00 2001 From: lior Date: Wed, 29 Jul 2026 12:17:39 +0300 Subject: [PATCH 2/5] fix(mobile): coalesce keyboard viewport settling --- src/web/public/mobile-handlers.js | 75 ++++++++++++++++--------------- test/mobile/keyboard.test.ts | 38 ++++++++++++++++ 2 files changed, 76 insertions(+), 37 deletions(-) diff --git a/src/web/public/mobile-handlers.js b/src/web/public/mobile-handlers.js index 5b8b99a5..ea1ed8f8 100644 --- a/src/web/public/mobile-handlers.js +++ b/src/web/public/mobile-handlers.js @@ -209,9 +209,12 @@ const MobileDetection = { * Also handles terminal scrolling and toolbar repositioning via visualViewport API. */ const KeyboardHandler = { + VIEWPORT_SETTLE_MS: 80, lastViewportHeight: 0, keyboardVisible: false, initialViewportHeight: 0, + _viewportSettleTimer: null, + _settleScrollToBottom: false, /** Initialize keyboard handling */ init() { @@ -276,6 +279,11 @@ const KeyboardHandler = { window.removeEventListener('scroll', this._windowScrollHandler); this._windowScrollHandler = null; } + if (this._viewportSettleTimer) { + clearTimeout(this._viewportSettleTimer); + this._viewportSettleTimer = null; + } + this._settleScrollToBottom = false; }, /** Handle viewport resize (keyboard show/hide) */ @@ -313,6 +321,7 @@ const KeyboardHandler = { } this.updateLayoutForKeyboard(); + this._scheduleViewportSettle(); this.lastViewportHeight = currentHeight; }, @@ -414,32 +423,9 @@ const KeyboardHandler = { // iOS Safari may scroll the document to reveal xterm's hidden textarea. window.scrollTo(0, 0); - // Refit terminal locally AND send resize to server so Claude Code (Ink) - // knows the actual terminal dimensions. Without this, Ink redraws at the - // old (larger) row count when the user types, causing content to scroll - // off the visible area with each keystroke. - // Note: the throttledResize handler still suppresses ongoing resize events - // while keyboard is up — this one-shot resize on open/close is sufficient. - setTimeout(() => { - if (typeof app !== 'undefined' && app.terminal) { - if (app.fitAddon) - try { - app.fitAddon.fit(); - } catch {} - // Eliminate terminal row quantization gap: xterm can only show whole - // rows, so leftover pixels create dead space below the last row. - // Shrink .main's paddingBottom by the gap so the terminal fills flush - // to the accessory bar. - this._shrinkPaddingToFit(); - app.terminal.scrollToBottom(); - app._syncMobileHelperTextareaToCursor?.(); - app._localEchoOverlay?.rerender?.(); - // Send resize to server so PTY dimensions match xterm - this._sendTerminalResize(); - } - // Reset again after fit/resize in case layout changes triggered scroll - window.scrollTo(0, 0); - }, 150); + // visualViewport emits multiple heights throughout the OS animation. + // Re-schedule on every event and fit only after the final height settles. + this._scheduleViewportSettle({ scrollToBottom: true }); // Reposition subagent windows to stack from bottom (above keyboard) if (typeof app !== 'undefined') app.relayoutMobileSubagentWindows(); @@ -454,22 +440,37 @@ const KeyboardHandler = { this.resetLayout(); - // Refit terminal, scroll to bottom, and send resize to restore original dimensions - setTimeout(() => { - if (typeof app !== 'undefined' && app.fitAddon) { - try { - app.fitAddon.fit(); - } catch {} - if (app.terminal) app.terminal.scrollToBottom(); - // Send resize to server to restore full terminal size - this._sendTerminalResize(); - } - }, 100); + this._scheduleViewportSettle({ scrollToBottom: true }); // Reposition subagent windows to stack from top (below header) if (typeof app !== 'undefined') app.relayoutMobileSubagentWindows(); }, + /** Coalesce the keyboard animation into one final xterm reflow and PTY resize. */ + _scheduleViewportSettle({ scrollToBottom = false } = {}) { + this._settleScrollToBottom = this._settleScrollToBottom || scrollToBottom; + if (this._viewportSettleTimer) clearTimeout(this._viewportSettleTimer); + this._viewportSettleTimer = setTimeout(() => { + this._viewportSettleTimer = null; + const shouldScrollToBottom = this._settleScrollToBottom; + this._settleScrollToBottom = false; + + if (typeof app !== 'undefined' && app.terminal) { + if (app.fitAddon) { + try { + app.fitAddon.fit(); + } catch {} + } + if (this.keyboardVisible) this._shrinkPaddingToFit(); + if (shouldScrollToBottom) app.terminal.scrollToBottom(); + app._syncMobileHelperTextareaToCursor?.(); + app._localEchoOverlay?.rerender?.(); + this._sendTerminalResize(); + } + window.scrollTo(0, 0); + }, this.VIEWPORT_SETTLE_MS); + }, + /** Send current terminal dimensions to the server (one-shot, for keyboard open/close) */ _sendTerminalResize() { if (typeof app === 'undefined' || !app.activeSessionId || !app.fitAddon) return; diff --git a/test/mobile/keyboard.test.ts b/test/mobile/keyboard.test.ts index 5032f653..a99ae023 100644 --- a/test/mobile/keyboard.test.ts +++ b/test/mobile/keyboard.test.ts @@ -323,6 +323,44 @@ describe('Virtual Keyboard', () => { expect(mainPadding).toBe(''); }); + it('coalesces keyboard animation frames into one final terminal fit', async () => { + const result = await page.evaluate(async () => { + const originalFit = app.fitAddon.fit.bind(app.fitAddon); + const originalSendResize = KeyboardHandler._sendTerminalResize.bind(KeyboardHandler); + const originalScrollToBottom = app.terminal.scrollToBottom.bind(app.terminal); + let fits = 0; + let resizes = 0; + let bottomRestores = 0; + app.fitAddon.fit = () => { + fits++; + }; + KeyboardHandler._sendTerminalResize = () => { + resizes++; + }; + app.terminal.scrollToBottom = () => { + bottomRestores++; + }; + + KeyboardHandler._scheduleViewportSettle({ scrollToBottom: true }); + await new Promise((resolve) => setTimeout(resolve, 30)); + KeyboardHandler._scheduleViewportSettle(); + await new Promise((resolve) => setTimeout(resolve, 30)); + KeyboardHandler._scheduleViewportSettle(); + await new Promise((resolve) => setTimeout(resolve, 50)); + const beforeFinalSettle = { fits, resizes, bottomRestores }; + await new Promise((resolve) => setTimeout(resolve, KeyboardHandler.VIEWPORT_SETTLE_MS)); + const afterFinalSettle = { fits, resizes, bottomRestores }; + + app.fitAddon.fit = originalFit; + KeyboardHandler._sendTerminalResize = originalSendResize; + app.terminal.scrollToBottom = originalScrollToBottom; + return { beforeFinalSettle, afterFinalSettle }; + }); + + expect(result.beforeFinalSettle).toEqual({ fits: 0, resizes: 0, bottomRestores: 0 }); + expect(result.afterFinalSettle).toEqual({ fits: 1, resizes: 1, bottomRestores: 1 }); + }); + it('accessory bar has the simple-mode action buttons', async () => { const actions = await page.evaluate(() => { return Array.from(document.querySelectorAll('.keyboard-accessory-bar [data-action]')).map( From cc37643122769044fc1733051f127317f004f067 Mon Sep 17 00:00:00 2001 From: lior Date: Wed, 29 Jul 2026 11:11:45 +0300 Subject: [PATCH 3/5] fix(terminal): drain deferred output without a wake event --- docs/terminal-anti-flicker.md | 38 +++++++----------------- src/web/public/terminal-ui.js | 47 ++++++++++++++---------------- test/terminal-flush-budget.test.ts | 20 +++++++++++++ 3 files changed, 52 insertions(+), 53 deletions(-) diff --git a/docs/terminal-anti-flicker.md b/docs/terminal-anti-flicker.md index 82f45b1b..ad7c38bd 100644 --- a/docs/terminal-anti-flicker.md +++ b/docs/terminal-anti-flicker.md @@ -43,38 +43,22 @@ const syncData = DEC_SYNC_START + data + DEC_SYNC_END; this.broadcast('session:terminal', { id: sessionId, data: syncData }); ``` -## Client-Side Implementation (`app.js`) +## Client-Side Implementation (`terminal-ui.js`) ### `batchTerminalWrite(data)` 1. Checks if flicker filter is enabled (optional, per-session) 2. If flicker filter active: buffers screen-clear patterns (`ESC[2J`, `ESC[H ESC[J`, `ESC[nA`) 3. Accumulates data in `pendingWrites` -4. Schedules `requestAnimationFrame` if not already scheduled -5. On rAF callback: checks for incomplete sync blocks (start without end) -6. If incomplete: waits up to 50ms via `syncWaitTimeout` -7. Calls `flushPendingWrites()` when complete - -### `extractSyncSegments(data)` - -- Parses DEC 2026 markers, returns array of content segments -- Content before sync blocks returned as-is -- Content inside sync blocks returned without markers -- Incomplete blocks (start without end) returned with marker for next chunk +4. Calls `_scheduleTerminalWriteFlush()` if no flush is pending +5. The yielded callback clears its scheduled flag before calling `flushPendingWrites()` +6. Large batches schedule their own next chunk until the queue is empty ### `flushPendingWrites()` -```javascript -const segments = extractSyncSegments(this.pendingWrites); -this.pendingWrites = ''; // Clear before writing -for (const segment of segments) { - if (segment && !segment.startsWith(DEC_SYNC_START)) { - terminal.write(segment); // Skip incomplete blocks (start with marker) - } -} -``` - -Note: Segments starting with `DEC_SYNC_START` are incomplete blocks awaiting more data. These are skipped (discarded if timeout forces flush). +- Joins the queued terminal data and passes DEC 2026 markers through to xterm.js 6, which handles synchronized output natively. +- Writes at most 32KB per yield for Codex and 64KB for other modes. +- Requeues the remainder and immediately schedules another safe yield. A final large response therefore drains without waiting for another SSE event. ### `chunkedTerminalWrite(buffer, chunkSize=128KB)` @@ -116,17 +100,15 @@ When detected, buffers 50ms of subsequent output before flushing atomically. ## Edge Cases -- **Incomplete sync blocks**: 50ms timeout forces flush (content discarded to prevent freeze) +- **Incomplete sync blocks**: xterm.js retains synchronized output until its closing marker - **Large buffers**: Chunked writing prevents UI freeze - **Server shutdown**: Skips batching via `_isStopping` flag - **Session switch**: Clears flicker filter state, pending writes, and sync timeout (prevents cross-session data bleed) - **SSE reconnect**: `handleInit()` clears all pending write state -**Trade-off:** If a sync block is split across SSE packets and the end marker doesn't arrive within 50ms, the incomplete content is discarded. This prioritizes responsiveness over completeness. In practice this is rare since the server always sends complete `SYNC_START...SYNC_END` pairs and SSE typically delivers them atomically. - ## DEC Mode 2026 Compatibility -Terminals that natively support DEC 2026 will buffer and render atomically. Terminals that don't support it ignore the escape sequences harmlessly. xterm.js doesn't support DEC 2026 natively, so the client implements its own buffering by parsing the markers. +Terminals that natively support DEC 2026 buffer and render atomically. Codeman uses xterm.js 6, so the client passes the markers through instead of parsing or discarding partial blocks. **Supporting terminals:** WezTerm, Kitty, Ghostty, iTerm2 3.5+, Windows Terminal, VSCode terminal @@ -135,4 +117,4 @@ Terminals that natively support DEC 2026 will buffer and render atomically. Term | File | Key Functions | |------|---------------| | `src/web/server.ts` | `batchTerminalData()`, `flushTerminalBatches()`, `broadcast()` | -| `src/web/public/app.js` | `batchTerminalWrite()`, `extractSyncSegments()`, `flushPendingWrites()`, `flushFlickerBuffer()`, `chunkedTerminalWrite()` | +| `src/web/public/terminal-ui.js` | `batchTerminalWrite()`, `_scheduleTerminalWriteFlush()`, `flushPendingWrites()`, `flushFlickerBuffer()`, `chunkedTerminalWrite()` | diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index ef7ec1df..4378b491 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -272,7 +272,7 @@ Object.assign(CodemanApp.prototype, { // WebGL renderer for GPU-accelerated terminal rendering. // Previously caused "page unresponsive" crashes from synchronous GPU stalls, - // but the 48KB/frame flush cap in flushPendingWrites() now prevents + // but the mode-aware 32/64KB frame cap in flushPendingWrites() now prevents // oversized terminal.write() calls that triggered the stalls. // Disable with ?nowebgl URL param if GPU issues return. // Auto-fallback: _initWebGL installs a long-task watchdog that disables @@ -2030,17 +2030,26 @@ Object.assign(CodemanApp.prototype, { // Accumulate raw data (may contain DEC 2026 markers) this.pendingWrites.push(data); + this._scheduleTerminalWriteFlush(); + }, - if (!this.writeFrameScheduled) { - this.writeFrameScheduled = true; - this._safeYield(() => { - // xterm.js 6.0 handles DEC 2026 sync markers natively — it buffers - // content between 2026h/2026l and renders atomically. No need for - // client-side incomplete-block detection; just flush every frame. - this.flushPendingWrites(); - this.writeFrameScheduled = false; - }); - } + /** + * Schedule one render-budgeted terminal flush. + * + * Clear the scheduled flag before flushing so flushPendingWrites() can queue + * another yield when a large final batch leaves bytes behind. Keeping the + * flag set through the flush stranded that remainder until unrelated output + * arrived, which looked like truncated responses and idle shell commands. + */ + _scheduleTerminalWriteFlush() { + if (this.writeFrameScheduled || this.pendingWrites.length === 0) return; + this.writeFrameScheduled = true; + this._safeYield(() => { + this.writeFrameScheduled = false; + // xterm.js 6.0 handles DEC 2026 sync markers natively — it buffers + // content between 2026h/2026l and renders atomically. + this.flushPendingWrites(); + }); }, /** @@ -2056,13 +2065,7 @@ Object.assign(CodemanApp.prototype, { this.flickerFilterActive = false; // Trigger a normal flush - if (!this.writeFrameScheduled) { - this.writeFrameScheduled = true; - this._safeYield(() => { - this.flushPendingWrites(); - this.writeFrameScheduled = false; - }); - } + this._scheduleTerminalWriteFlush(); }, /** @@ -2188,13 +2191,7 @@ Object.assign(CodemanApp.prototype, { this.terminal.write(joined.slice(0, MAX_FRAME_BYTES)); this.pendingWrites.push(joined.slice(MAX_FRAME_BYTES)); deferred = true; - if (!this.writeFrameScheduled) { - this.writeFrameScheduled = true; - this._safeYield(() => { - this.flushPendingWrites(); - this.writeFrameScheduled = false; - }); - } + this._scheduleTerminalWriteFlush(); } if ( preserveViewportY !== null && diff --git a/test/terminal-flush-budget.test.ts b/test/terminal-flush-budget.test.ts index b1695fc7..a3549b0b 100644 --- a/test/terminal-flush-budget.test.ts +++ b/test/terminal-flush-budget.test.ts @@ -47,6 +47,26 @@ function loadTerminalUiHarness(mode: string) { } describe('terminal flush budget', () => { + it('drains a large final batch without waiting for unrelated terminal output', () => { + const { app, writes } = loadTerminalUiHarness('codex'); + const scheduled: Array<() => void> = []; + app._safeYield = (callback: () => void) => { + scheduled.push(callback); + }; + app.isTerminalAtBottom = () => true; + + app.batchTerminalWrite('x'.repeat(96 * 1024)); + expect(scheduled).toHaveLength(1); + + while (scheduled.length > 0) { + scheduled.shift()?.(); + } + + expect(writes.map((write) => write.length)).toEqual([32 * 1024, 32 * 1024, 32 * 1024]); + expect(app.pendingWrites).toEqual([]); + expect(app.writeFrameScheduled).toBe(false); + }); + it('uses a smaller first-frame write budget for Codex output to reduce renderer stalls', () => { const { app, writes } = loadTerminalUiHarness('codex'); app.pendingWrites.push('x'.repeat(96 * 1024)); From 7bfee75e3052148279c5136ff465e4d0099e622a Mon Sep 17 00:00:00 2001 From: lior Date: Wed, 29 Jul 2026 14:23:54 +0300 Subject: [PATCH 4/5] fix(mobile): hold terminal frame across keyboard resize --- docs/terminal-anti-flicker.md | 16 ++ src/web/public/mobile-handlers.js | 267 ++++++++++++++++++++++- src/web/public/styles.css | 21 ++ src/web/public/terminal-ui.js | 10 + test/mobile/terminal-frame-cover.test.ts | 63 ++++++ 5 files changed, 376 insertions(+), 1 deletion(-) create mode 100644 test/mobile/terminal-frame-cover.test.ts diff --git a/docs/terminal-anti-flicker.md b/docs/terminal-anti-flicker.md index ad7c38bd..413daed9 100644 --- a/docs/terminal-anti-flicker.md +++ b/docs/terminal-anti-flicker.md @@ -73,6 +73,21 @@ this.broadcast('session:terminal', { id: sessionId, data: syncData }); - Parallelizes session attach with buffer fetch - Fire-and-forget resize (doesn't block tab switch) +## Mobile Keyboard Resize Cover + +Phone keyboard animation changes xterm's row count before the foreground TUI +finishes its `SIGWINCH` redraw. During that interval the DOM renderer can expose +blank or partially reflowed rows even though terminal state is valid. + +`KeyboardHandler` keeps the last painted `.xterm-rows` in an inert +`.terminal-resize-frame-cover` while the viewport settles. The clone is only a +visual cover: xterm remains the authoritative buffer, pointer events pass +through it, and shell sessions do not use it. The cover is armed immediately +before the resize and removed after xterm's parser callback, a short Codex +redraw quiet window, and two animation frames. A bounded expiry removes it when +no replacement output arrives; an active terminal buffer load keeps it in place +until that load finishes. + ## Optional Flicker Filter Per-session toggle via Session Settings. Adds ~50ms latency but eliminates remaining flicker on problematic terminals. @@ -118,3 +133,4 @@ Terminals that natively support DEC 2026 buffer and render atomically. Codeman u |------|---------------| | `src/web/server.ts` | `batchTerminalData()`, `flushTerminalBatches()`, `broadcast()` | | `src/web/public/terminal-ui.js` | `batchTerminalWrite()`, `_scheduleTerminalWriteFlush()`, `flushPendingWrites()`, `flushFlickerBuffer()`, `chunkedTerminalWrite()` | +| `src/web/public/mobile-handlers.js` | `_beginTerminalFrameCover()`, `_armTerminalFrameCover()`, `onTerminalFrameReady()` | diff --git a/src/web/public/mobile-handlers.js b/src/web/public/mobile-handlers.js index ea1ed8f8..2ae4744c 100644 --- a/src/web/public/mobile-handlers.js +++ b/src/web/public/mobile-handlers.js @@ -210,11 +210,26 @@ const MobileDetection = { */ const KeyboardHandler = { VIEWPORT_SETTLE_MS: 80, + FRAME_COVER_MIN_MS: 220, + FRAME_COVER_MAX_MS: 1600, + FRAME_COVER_LOAD_POLL_MS: 100, + FRAME_COVER_CODEX_QUIET_MS: 180, lastViewportHeight: 0, keyboardVisible: false, initialViewportHeight: 0, _viewportSettleTimer: null, _settleScrollToBottom: false, + _terminalInputRequested: false, + _terminalFrameCover: null, + _terminalFrameCoverStartedAt: 0, + _terminalFrameCoverArmed: false, + _terminalFrameCoverReady: false, + _terminalFrameCoverReadyAt: 0, + _terminalFrameCoverReadyVersion: 0, + _terminalFrameCoverSwapVersion: 0, + _terminalFrameCoverMinTimer: null, + _terminalFrameCoverMaxTimer: null, + _terminalFrameCoverQuietTimer: null, /** Initialize keyboard handling */ init() { @@ -227,6 +242,11 @@ const KeyboardHandler = { // Simple focus handler - scroll input into view after keyboard appears this._focusinHandler = (e) => { const target = e.target; + this._terminalInputRequested = this.isTerminalInputElement(target); + if (this._terminalInputRequested) { + if (!this.keyboardVisible) this._beginTerminalFrameCover(); + return; + } if (!this.isInputElement(target)) return; // Wait for keyboard animation, then scroll input into view @@ -284,6 +304,8 @@ const KeyboardHandler = { this._viewportSettleTimer = null; } this._settleScrollToBottom = false; + this._terminalInputRequested = false; + this._discardTerminalFrameCover(); }, /** Handle viewport resize (keyboard show/hide) */ @@ -304,6 +326,7 @@ const KeyboardHandler = { // Use 100px threshold (not 50) to handle iOS address bar drift, // iOS 26's persistent 24px discrepancy, and Safari bottom bar changes else if (heightDiff < 100 && this.keyboardVisible) { + if (this._terminalInputRequested) this._beginTerminalFrameCover(); this.keyboardVisible = false; document.body.classList.remove('keyboard-visible'); this.onKeyboardHide(); @@ -414,6 +437,8 @@ const KeyboardHandler = { /** Called when keyboard appears */ onKeyboardShow() { + if (this._terminalInputRequested) this._beginTerminalFrameCover(); + // Show keyboard accessory bar if (typeof KeyboardAccessoryBar !== 'undefined') { KeyboardAccessoryBar.show(); @@ -433,6 +458,9 @@ const KeyboardHandler = { /** Called when keyboard hides */ onKeyboardHide() { + const terminalOwnedKeyboard = this._terminalInputRequested; + this._terminalInputRequested = false; + // Hide keyboard accessory bar if (typeof KeyboardAccessoryBar !== 'undefined') { KeyboardAccessoryBar.hide(); @@ -440,7 +468,7 @@ const KeyboardHandler = { this.resetLayout(); - this._scheduleViewportSettle({ scrollToBottom: true }); + this._scheduleViewportSettle({ scrollToBottom: terminalOwnedKeyboard }); // Reposition subagent windows to stack from top (below header) if (typeof app !== 'undefined') app.relayoutMobileSubagentWindows(); @@ -465,12 +493,241 @@ const KeyboardHandler = { if (shouldScrollToBottom) app.terminal.scrollToBottom(); app._syncMobileHelperTextareaToCursor?.(); app._localEchoOverlay?.rerender?.(); + this._armTerminalFrameCover(); this._sendTerminalResize(); } window.scrollTo(0, 0); }, this.VIEWPORT_SETTLE_MS); }, + /** + * Freeze the currently painted terminal rows across a phone keyboard resize. + * xterm's DOM renderer can briefly clear between fit() and the TUI's SIGWINCH + * redraw; the inert clone keeps the last valid frame visible in that window. + */ + _beginTerminalFrameCover({ includeShell = false, restart = false, arm = false } = {}) { + if (!MobileDetection.isTouchDevice() || typeof app === 'undefined') return; + if (this._terminalFrameCover) { + if (restart) this._restartTerminalFrameCover(); + if (arm) this._armTerminalFrameCover(); + return; + } + const session = app.activeSessionId ? app.sessions?.get(app.activeSessionId) : null; + if (!app.activeSessionId || (!includeShell && session?.mode === 'shell')) return; + + const terminalElement = app.terminal?.element; + const screen = terminalElement?.querySelector?.('.xterm-screen'); + if (!(terminalElement instanceof HTMLElement) || !(screen instanceof HTMLElement)) return; + const rect = screen.getBoundingClientRect(); + if (rect.width < 1 || rect.height < 1) return; + + const rows = screen.querySelector('.xterm-rows'); + if (!(rows instanceof HTMLElement)) return; + const cover = document.createElement('div'); + cover.className = 'terminal-resize-frame-cover'; + cover.setAttribute('aria-hidden', 'true'); + const frame = document.createElement('div'); + frame.className = `${screen.className} terminal-resize-frame`; + frame.appendChild(rows.cloneNode(true)); + const localEcho = [...screen.children].find( + (child) => + child instanceof HTMLElement && + child.style.pointerEvents === 'none' && + child.style.zIndex === '7' && + child.textContent + ); + if (localEcho) frame.appendChild(localEcho.cloneNode(true)); + frame.style.width = `${rect.width}px`; + frame.style.height = `${rect.height}px`; + cover.appendChild(frame); + terminalElement.appendChild(cover); + + this._terminalFrameCover = cover; + this._terminalFrameCoverStartedAt = Date.now(); + this._terminalFrameCoverArmed = false; + this._terminalFrameCoverReady = false; + this._terminalFrameCoverReadyAt = 0; + this._terminalFrameCoverReadyVersion += 1; + this._terminalFrameCoverSwapVersion = 0; + this._scheduleTerminalFrameCoverExpiry(); + if (arm) this._armTerminalFrameCover(); + }, + + _restartTerminalFrameCover() { + if (!this._terminalFrameCover) return; + this._clearTerminalFrameCoverTimers(); + this._terminalFrameCoverStartedAt = Date.now(); + this._terminalFrameCoverArmed = false; + this._terminalFrameCoverReady = false; + this._terminalFrameCoverReadyAt = 0; + this._terminalFrameCoverReadyVersion += 1; + this._terminalFrameCoverSwapVersion = 0; + this._scheduleTerminalFrameCoverExpiry(); + }, + + _scheduleTerminalFrameCoverExpiry(delay = this.FRAME_COVER_MAX_MS) { + if (this._terminalFrameCoverMaxTimer) clearTimeout(this._terminalFrameCoverMaxTimer); + this._terminalFrameCoverMaxTimer = setTimeout(() => { + this._terminalFrameCoverMaxTimer = null; + if (this._terminalFrameCoverLoadPending()) { + this._scheduleTerminalFrameCoverExpiry(this.FRAME_COVER_LOAD_POLL_MS); + return; + } + this._forceTerminalFrameCoverSwap(); + }, delay); + }, + + _terminalFrameCoverLoadPending() { + if (typeof app === 'undefined' || !app.activeSessionId) return false; + const state = app.terminalLoadStates?.get?.(app.activeSessionId); + return Boolean(state && state.phase !== 'failed'); + }, + + _armTerminalFrameCover() { + if (!this._terminalFrameCover) return; + this._terminalFrameCoverArmed = true; + this._terminalFrameCoverReady = false; + this._terminalFrameCoverReadyAt = 0; + this._terminalFrameCoverReadyVersion += 1; + this._terminalFrameCoverSwapVersion = 0; + if (this._terminalFrameCoverQuietTimer) { + clearTimeout(this._terminalFrameCoverQuietTimer); + this._terminalFrameCoverQuietTimer = null; + } + if (this._terminalFrameCoverMinTimer) clearTimeout(this._terminalFrameCoverMinTimer); + const elapsed = Date.now() - this._terminalFrameCoverStartedAt; + this._terminalFrameCoverMinTimer = setTimeout( + () => { + this._terminalFrameCoverMinTimer = null; + this._tryFinishTerminalFrameCover(); + }, + Math.max(0, this.FRAME_COVER_MIN_MS - elapsed) + ); + }, + + onTerminalFramePending() { + if (!this._terminalFrameCover || !this._terminalFrameCoverArmed) return; + this._terminalFrameCoverReady = false; + this._terminalFrameCoverReadyAt = 0; + this._terminalFrameCoverReadyVersion += 1; + this._terminalFrameCoverSwapVersion = 0; + if (this._terminalFrameCoverQuietTimer) { + clearTimeout(this._terminalFrameCoverQuietTimer); + this._terminalFrameCoverQuietTimer = null; + } + }, + + needsTerminalFrameReady() { + return Boolean(this._terminalFrameCover && this._terminalFrameCoverArmed); + }, + + onTerminalFrameReady() { + if (!this._terminalFrameCoverArmed) return; + this._terminalFrameCoverReady = true; + this._terminalFrameCoverReadyAt = Date.now(); + this._terminalFrameCoverReadyVersion += 1; + this._tryFinishTerminalFrameCover(); + }, + + _tryFinishTerminalFrameCover() { + if (!this._terminalFrameCover || !this._terminalFrameCoverArmed || !this._terminalFrameCoverReady) { + return; + } + if (Date.now() - this._terminalFrameCoverStartedAt < this.FRAME_COVER_MIN_MS) return; + if (!this._terminalHasVisibleFrame()) return; + const activeSession = + typeof app !== 'undefined' && app.activeSessionId ? app.sessions?.get?.(app.activeSessionId) : null; + const quietMs = activeSession?.mode === 'codex' ? this.FRAME_COVER_CODEX_QUIET_MS : 0; + const quietRemaining = this._terminalFrameCoverReadyAt + quietMs - Date.now(); + if (quietRemaining > 0) { + if (!this._terminalFrameCoverQuietTimer) { + this._terminalFrameCoverQuietTimer = setTimeout(() => { + this._terminalFrameCoverQuietTimer = null; + this._tryFinishTerminalFrameCover(); + }, quietRemaining); + } + return; + } + this._scheduleTerminalFrameCoverSwap(); + }, + + _scheduleTerminalFrameCoverSwap() { + const readyVersion = this._terminalFrameCoverReadyVersion; + if (this._terminalFrameCoverSwapVersion === readyVersion) return; + this._terminalFrameCoverSwapVersion = readyVersion; + requestAnimationFrame(() => { + requestAnimationFrame(() => { + if ( + !this._terminalFrameCover || + !this._terminalFrameCoverArmed || + !this._terminalFrameCoverReady || + this._terminalFrameCoverReadyVersion !== readyVersion + ) { + return; + } + this._finishTerminalFrameCover(); + }); + }); + }, + + _forceTerminalFrameCoverSwap() { + const cover = this._terminalFrameCover; + if (!cover) return; + requestAnimationFrame(() => { + requestAnimationFrame(() => { + if (this._terminalFrameCover === cover) this._finishTerminalFrameCover(); + }); + }); + }, + + _terminalHasVisibleFrame() { + if (typeof app === 'undefined' || !app.terminal) return false; + if (app._localEchoOverlay?.state?.visible) return true; + const terminal = app.terminal; + const buffer = terminal.buffer?.active; + if (!buffer?.getLine) return false; + const viewportY = buffer.viewportY || 0; + const rows = Math.max(1, terminal.rows || 1); + for (let row = 0; row < rows; row++) { + if ( + buffer + .getLine(viewportY + row) + ?.translateToString?.(true) + ?.trim() + ) { + return true; + } + } + return false; + }, + + _finishTerminalFrameCover() { + if (!this._terminalFrameCover) return; + this._terminalFrameCoverArmed = false; + this._discardTerminalFrameCover(); + }, + + _clearTerminalFrameCoverTimers() { + if (this._terminalFrameCoverMinTimer) clearTimeout(this._terminalFrameCoverMinTimer); + if (this._terminalFrameCoverMaxTimer) clearTimeout(this._terminalFrameCoverMaxTimer); + if (this._terminalFrameCoverQuietTimer) clearTimeout(this._terminalFrameCoverQuietTimer); + this._terminalFrameCoverMinTimer = null; + this._terminalFrameCoverMaxTimer = null; + this._terminalFrameCoverQuietTimer = null; + }, + + _discardTerminalFrameCover() { + this._clearTerminalFrameCoverTimers(); + this._terminalFrameCover?.remove(); + this._terminalFrameCover = null; + this._terminalFrameCoverStartedAt = 0; + this._terminalFrameCoverArmed = false; + this._terminalFrameCoverReady = false; + this._terminalFrameCoverReadyAt = 0; + this._terminalFrameCoverReadyVersion += 1; + this._terminalFrameCoverSwapVersion = 0; + }, + /** Send current terminal dimensions to the server (one-shot, for keyboard open/close) */ _sendTerminalResize() { if (typeof app === 'undefined' || !app.activeSessionId || !app.fitAddon) return; @@ -517,6 +774,14 @@ const KeyboardHandler = { } catch {} }, + /** Check whether focus belongs to the terminal's mobile text-entry surface. */ + isTerminalInputElement(el) { + if (!el) return false; + if (el.id === 'cjkInput') return true; + if (typeof app !== 'undefined' && el === app.terminal?.textarea) return true; + return Boolean(el.closest?.('.xterm, .terminal-container')); + }, + /** Check if element is an input that triggers keyboard (excludes terminal) */ isInputElement(el) { if (!el) return false; diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 01e3305a..be111089 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -2504,6 +2504,27 @@ body.solo-mode .btn-lifecycle-log { background: transparent !important; } +/* Preserve the last painted terminal rows while a mobile keyboard resize + refits xterm and waits for the foreground TUI's SIGWINCH redraw. */ +.terminal-resize-frame-cover { + position: absolute; + inset: 0; + z-index: 6; + overflow: hidden; + pointer-events: none; + background: var(--term-bg, #161b23); + opacity: 1; +} + +.terminal-resize-frame { + position: absolute !important; + top: 0 !important; + right: auto !important; + bottom: auto !important; + left: 0 !important; + pointer-events: none !important; +} + /* Touch devices: prevent browser from claiming the touch gesture before our JS touchmove handler fires. Without this, the browser starts native scrolling during the first few px of finger travel and ignores our diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 4378b491..fcc7c36b 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -1983,6 +1983,9 @@ Object.assign(CodemanApp.prototype, { if (this._loadBufferQueue) this._loadBufferQueue.push(data); return; } + if (typeof KeyboardHandler !== 'undefined') { + KeyboardHandler.onTerminalFramePending?.(); + } // Check if at bottom BEFORE adding data (captures user's scroll position) // Only update if not already scheduled (preserve the first check's result) @@ -2220,6 +2223,13 @@ Object.assign(CodemanApp.prototype, { if (this._localEchoOverlay?.hasPending) { this._localEchoOverlay.rerender(); } + if ( + !deferred && + typeof KeyboardHandler !== 'undefined' && + KeyboardHandler.needsTerminalFrameReady?.() + ) { + this.terminal.write('', () => KeyboardHandler.onTerminalFrameReady?.()); + } // After Tab completion: detect the completed text in the overlay. // Use terminal.write('', callback) to defer detection until xterm.js diff --git a/test/mobile/terminal-frame-cover.test.ts b/test/mobile/terminal-frame-cover.test.ts new file mode 100644 index 00000000..b2110932 --- /dev/null +++ b/test/mobile/terminal-frame-cover.test.ts @@ -0,0 +1,63 @@ +// Port 3219 - Stable terminal frame coverage during mobile keyboard resize. +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import type { Page } from 'playwright'; +import { createDevicePage, closeAllBrowsers } from './helpers/browser.js'; +import { createTestServer, stopTestServer } from './helpers/server.js'; +import { REPRESENTATIVE_DEVICES } from './devices.js'; +import type { WebServer } from '../src/web/server.js'; + +const PORT = 3219; +const BASE_URL = `http://localhost:${PORT}`; + +describe('mobile terminal frame cover', () => { + let server: WebServer; + let page: Page; + let closePage: () => Promise; + + beforeAll(async () => { + server = await createTestServer(PORT); + const result = await createDevicePage(REPRESENTATIVE_DEVICES['standard-phone'], BASE_URL, 'chromium'); + page = result.page; + closePage = () => result.context.close(); + }); + + afterAll(async () => { + await closePage?.(); + await stopTestServer(server); + await closeAllBrowsers(); + }); + + it('keeps the old frame opaque and inert until xterm paints the replacement', async () => { + await page.evaluate(() => { + app.activeSessionId = 'frame-cover-test'; + app.sessions.set('frame-cover-test', { id: 'frame-cover-test', mode: 'codex' }); + app.terminal.write('\x1b[2J\x1b[HOLD STABLE FRAME'); + }); + await page.evaluate( + () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))) + ); + await page.evaluate(() => { + KeyboardHandler._beginTerminalFrameCover({ arm: true }); + app.batchTerminalWrite('\x1b[2J\x1b[HNEW DESTINATION FRAME'); + }); + + const cover = page.locator('.terminal-resize-frame-cover'); + await expect.poll(() => cover.count()).toBe(1); + await expect.poll(() => cover.textContent()).toContain('OLD STABLE FRAME'); + expect(await cover.textContent()).not.toContain('NEW DESTINATION FRAME'); + expect(await cover.evaluate((element) => getComputedStyle(element).pointerEvents)).toBe('none'); + expect(await cover.evaluate((element) => getComputedStyle(element).opacity)).toBe('1'); + + await expect.poll(() => cover.count(), { timeout: 3000 }).toBe(0); + await expect + .poll(() => + page.evaluate(() => { + const buffer = app.terminal.buffer.active; + return Array.from({ length: app.terminal.rows }, (_, row) => + buffer.getLine(buffer.viewportY + row)?.translateToString(true) + ).join('\n'); + }) + ) + .toContain('NEW DESTINATION FRAME'); + }); +}); From fddd53aac1342ff6506b7e3b69af2a10e70db407 Mon Sep 17 00:00:00 2001 From: lior Date: Wed, 29 Jul 2026 14:55:51 +0300 Subject: [PATCH 5/5] fix(mobile): reconcile authoritative terminal frames --- docs/terminal-anti-flicker.md | 36 +- src/web/public/app.js | 57 ++- src/web/public/constants.js | 3 + src/web/public/mobile-handlers.js | 73 ++- src/web/public/terminal-ui.js | 458 ++++++++++++++++++- test/mobile/terminal-frame-reconcile.test.ts | 203 ++++++++ test/terminal-buffer-flush.test.ts | 194 +++++++- test/terminal-frame-reconcile.test.ts | 231 ++++++++++ 8 files changed, 1204 insertions(+), 51 deletions(-) create mode 100644 test/mobile/terminal-frame-reconcile.test.ts create mode 100644 test/terminal-frame-reconcile.test.ts diff --git a/docs/terminal-anti-flicker.md b/docs/terminal-anti-flicker.md index 413daed9..d3535c1a 100644 --- a/docs/terminal-anti-flicker.md +++ b/docs/terminal-anti-flicker.md @@ -15,7 +15,7 @@ PTY Output → Server Batching → DEC 2026 Wrap → SSE → Client rAF → Sync | **3. SSE Broadcast** | `server.ts:broadcast()` | JSON serialize once, send to all clients | 0ms | | **4. Client rAF** | `app.js:batchTerminalWrite()` | `requestAnimationFrame` batching | 0-16ms | | **5. Sync Block Parser** | `app.js:extractSyncSegments()` | Strips DEC 2026 markers, waits for complete blocks | 0-50ms | -| **6. Chunked Loading** | `app.js:chunkedTerminalWrite()` | 64KB/frame for large buffers | variable | +| **6. Chunked Loading** | `terminal-ui.js:chunkedTerminalWrite()` | 32KB/frame for large buffers | variable | ## Server-Side Implementation (`server.ts`) @@ -60,10 +60,10 @@ this.broadcast('session:terminal', { id: sessionId, data: syncData }); - Writes at most 32KB per yield for Codex and 64KB for other modes. - Requeues the remainder and immediately schedules another safe yield. A final large response therefore drains without waiting for another SSE event. -### `chunkedTerminalWrite(buffer, chunkSize=128KB)` +### `chunkedTerminalWrite(buffer, chunkSize=32KB)` - For large buffer restoration (session switch, reconnect) -- Writes 128KB per `requestAnimationFrame` to avoid UI jank +- Writes 32KB per safe yield to avoid UI jank - Strips any embedded DEC 2026 markers from historical data ### `selectSession()` Optimizations @@ -88,6 +88,31 @@ redraw quiet window, and two animation frames. A bounded expiry removes it when no replacement output arrives; an active terminal buffer load keeps it in place until that load finishes. +## Authoritative Mobile Frame Reconciliation + +The cover hides transition frames, but it cannot determine whether xterm's +underlying pane is current. Touch-device Codex sessions therefore reconcile +after keyboard resizes, WebSocket attachment, and foreground dialogue +submission: + +1. Coalesce overlapping requests so only the newest session/viewport transition + can publish a frame. A newer request aborts the older settle or fetch. +2. Hold live SSE/WS writes behind the existing buffer-load ownership gate. +3. Fetch the bounded 128KB current pane from + `/api/sessions/:id/terminal?latest=1&format=stream`. +4. Require a `mux-visible` source and terminal stream cursor, then cross xterm's + parser fence. +5. Clear only the viewport, preserving xterm scrollback, and paint the pane + inside one DEC 2026 synchronized update. +6. Replay only queued output after the snapshot cursor. Covered bytes are + discarded and a cursor-crossing event contributes only its uncovered suffix. +7. Release the visual cover after the authoritative pane and any pending local + input overlay have painted. + +The stream endpoint and cursor metadata are the server contract; the client +keeps the JSON response as a compatibility fallback. Fetches have a bounded +timeout so a stalled capture cannot block later transitions indefinitely. + ## Optional Flicker Filter Per-session toggle via Session Settings. Adds ~50ms latency but eliminates remaining flicker on problematic terminals. @@ -120,6 +145,7 @@ When detected, buffers 50ms of subsequent output before flushing atomically. - **Server shutdown**: Skips batching via `_isStopping` flag - **Session switch**: Clears flicker filter state, pending writes, and sync timeout (prevents cross-session data bleed) - **SSE reconnect**: `handleInit()` clears all pending write state +- **Superseded frame capture**: Aborts its settle/fetch and releases its buffer-load gate ## DEC Mode 2026 Compatibility @@ -132,5 +158,5 @@ Terminals that natively support DEC 2026 buffer and render atomically. Codeman u | File | Key Functions | |------|---------------| | `src/web/server.ts` | `batchTerminalData()`, `flushTerminalBatches()`, `broadcast()` | -| `src/web/public/terminal-ui.js` | `batchTerminalWrite()`, `_scheduleTerminalWriteFlush()`, `flushPendingWrites()`, `flushFlickerBuffer()`, `chunkedTerminalWrite()` | -| `src/web/public/mobile-handlers.js` | `_beginTerminalFrameCover()`, `_armTerminalFrameCover()`, `onTerminalFrameReady()` | +| `src/web/public/terminal-ui.js` | `batchTerminalWrite()`, `flushPendingWrites()`, `chunkedTerminalWrite()`, `_requestTerminalFrameReconcile()` | +| `src/web/public/mobile-handlers.js` | `_beginTerminalFrameCover()`, `_armTerminalFrameCover()`, `onTerminalFrameAuthoritative()` | diff --git a/src/web/public/app.js b/src/web/public/app.js index 5278b8f3..49a5229a 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -619,6 +619,10 @@ class CodemanApp { this._loadBufferQueue = null; // queued SSE events during buffer load this._bufferLoadSeq = 0; this._bufferLoadOwner = null; + this._terminalFrameReconcileSeq = 0; + this._terminalFrameReconcilePending = null; + this._terminalFrameReconcilePromise = null; + this._terminalFrameReconcileAbortController = null; // Flicker filter state (buffers output after screen clears) this.flickerFilterBuffer = ''; @@ -1654,7 +1658,7 @@ class CodemanApp { return; } - this.batchTerminalWrite(data.data); + this.batchTerminalWrite(data.data, data.cursor); } } @@ -2321,7 +2325,21 @@ class CodemanApp { // (re)connects AND registers the desktop sizing claim server-side — // selectSession's earlier resizes ran before this WS existed, so they // went over HTTP, which never claims (see ws-routes sizingToken). - this.sendResize(sessionId)?.catch?.(() => {}); + const session = this.sessions?.get?.(sessionId); + const reconcileMobileCodex = + session?.mode === 'codex' && + typeof MobileDetection !== 'undefined' && + MobileDetection.isTouchDevice?.(); + if (reconcileMobileCodex) { + void this._requestTerminalFrameReconcile?.({ + captureWhenUnchanged: true, + reason: 'ws-attach', + resizeOptions: {}, + settleMs: TUI_REDRAW_SETTLE_MS, + }); + } else { + this.sendResize(sessionId)?.catch?.(() => {}); + } this._startMobileResizeRetry(sessionId); // Flush any durably-queued input over the fresh socket (covers frames a // prior half-open socket silently dropped, and input typed while offline). @@ -2338,7 +2356,7 @@ class CodemanApp { const msg = JSON.parse(event.data); if (msg.t === 'o') { // Terminal output — route through the same batching pipeline as SSE - this._onSessionTerminal({ id: sessionId, data: msg.d }); + this._onSessionTerminal({ id: sessionId, data: msg.d, cursor: msg.cursor }); } else if (msg.t === 'c') { this._onSessionClearTerminal({ id: sessionId }); } else if (msg.t === 'r') { @@ -2478,6 +2496,15 @@ class CodemanApp { */ _sendInputAsync(sessionId, input, opts) { if (!sessionId || !input) return; + if (this._shouldReconcileTerminalAction?.(sessionId, input)) { + if (typeof KeyboardHandler !== 'undefined') { + KeyboardHandler._beginTerminalFrameCover?.({ restart: true, arm: true }); + } + void this._requestTerminalFrameReconcile?.({ + reason: 'dialogue-selection', + settleMs: TUI_ACTION_REDRAW_SETTLE_MS, + }); + } this._reliableSend(sessionId, input, opts?.useMux === true); } @@ -2958,6 +2985,10 @@ class CodemanApp { this._isLoadingBuffer = false; this._loadBufferQueue = null; this._bufferLoadOwner = null; + this._terminalFrameReconcileSeq = (this._terminalFrameReconcileSeq || 0) + 1; + this._terminalFrameReconcilePending = null; + this._terminalFrameReconcileAbortController?.abort?.(); + this._terminalFrameReconcileAbortController = null; // Abort any in-flight chunkedTerminalWrite (SSE reconnect reloads buffers) this._chunkedWriteGen = (this._chunkedWriteGen || 0) + 1; // Preserve local echo overlay text across SSE reconnect — just hide until @@ -4053,6 +4084,22 @@ class CodemanApp { } const forceReload = options?.forceReload === true; if (this.activeSessionId === sessionId && !forceReload) return; + const targetSession = this.sessions.get(sessionId); + const coverMobileCodex = + targetSession?.mode === 'codex' && + typeof MobileDetection !== 'undefined' && + MobileDetection.isTouchDevice?.(); + if ( + (this.activeSessionId !== sessionId || forceReload) && + coverMobileCodex && + typeof KeyboardHandler !== 'undefined' + ) { + KeyboardHandler._beginTerminalFrameCover?.({ + includeShell: true, + restart: true, + arm: true, + }); + } if (this.activeSessionId === sessionId && forceReload) { this.terminalBufferCache?.delete(sessionId); this._xtermSnapshots?.delete(sessionId); @@ -4065,6 +4112,10 @@ class CodemanApp { this._chunkedWriteGen = (this._chunkedWriteGen || 0) + 1; this.activeSessionId = null; } + this._terminalFrameReconcileSeq = (this._terminalFrameReconcileSeq || 0) + 1; + this._terminalFrameReconcilePending = null; + this._terminalFrameReconcileAbortController?.abort?.(); + this._terminalFrameReconcileAbortController = null; // Focus terminal SYNCHRONOUSLY before any await — iOS Safari only honors // programmatic focus() within the user-gesture call stack (e.g. tab click). // After the first await the gesture context is lost and focus() is silently diff --git a/src/web/public/constants.js b/src/web/public/constants.js index 1db36064..4833fee9 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -56,9 +56,12 @@ const AUTO_CLOSE_NOTIFICATION_MS = 8000; // Auto-close browser notifications const THROTTLE_DELAY_MS = 100; // General UI throttle delay const TERMINAL_CHUNK_SIZE = 32 * 1024; // 32KB chunks for terminal buffer loading const TERMINAL_TAIL_SIZE = 1024 * 1024; // 1MB tail for initial load (more scrollback on tab switch) +const TERMINAL_LATEST_FRAME_SIZE = 128 * 1024; // Bounded current-pane request shown before history replay +const TERMINAL_FRAME_FETCH_TIMEOUT_MS = 5000; // Bound a stale pane capture so later transitions can recover const SYNC_WAIT_TIMEOUT_MS = 50; // Wait timeout for terminal sync const STATS_POLLING_INTERVAL_MS = 2000; // System stats polling const TUI_REDRAW_SETTLE_MS = 400; // Grace for a TUI to redraw after a real resize, before fetching its buffer +const TUI_ACTION_REDRAW_SETTLE_MS = 180; // Grace before capturing the pane after a decision is submitted // Z-index base values for layered floating windows const ZINDEX_SUBAGENT_BASE = 1000; diff --git a/src/web/public/mobile-handlers.js b/src/web/public/mobile-handlers.js index 2ae4744c..6e5fcb98 100644 --- a/src/web/public/mobile-handlers.js +++ b/src/web/public/mobile-handlers.js @@ -214,9 +214,12 @@ const KeyboardHandler = { FRAME_COVER_MAX_MS: 1600, FRAME_COVER_LOAD_POLL_MS: 100, FRAME_COVER_CODEX_QUIET_MS: 180, + KEYBOARD_CLOSE_START_DELTA_PX: 40, lastViewportHeight: 0, keyboardVisible: false, initialViewportHeight: 0, + _keyboardOpenMinHeight: 0, + _keyboardClosing: false, _viewportSettleTimer: null, _settleScrollToBottom: false, _terminalInputRequested: false, @@ -227,6 +230,7 @@ const KeyboardHandler = { _terminalFrameCoverReadyAt: 0, _terminalFrameCoverReadyVersion: 0, _terminalFrameCoverSwapVersion: 0, + _terminalFrameCoverAuthoritative: false, _terminalFrameCoverMinTimer: null, _terminalFrameCoverMaxTimer: null, _terminalFrameCoverQuietTimer: null, @@ -238,6 +242,8 @@ const KeyboardHandler = { this.initialViewportHeight = window.visualViewport?.height || window.innerHeight; this.lastViewportHeight = this.initialViewportHeight; + this._keyboardOpenMinHeight = 0; + this._keyboardClosing = false; // Simple focus handler - scroll input into view after keyboard appears this._focusinHandler = (e) => { @@ -305,6 +311,8 @@ const KeyboardHandler = { } this._settleScrollToBottom = false; this._terminalInputRequested = false; + this._keyboardOpenMinHeight = 0; + this._keyboardClosing = false; this._discardTerminalFrameCover(); }, @@ -313,9 +321,28 @@ const KeyboardHandler = { const currentHeight = window.visualViewport?.height || window.innerHeight; const heightDiff = this.initialViewportHeight - currentHeight; + // A soft keyboard closes through several growing visualViewport frames. + // Start covering at the first meaningful growth instead of waiting until + // the final hidden threshold, where a resize redraw may already be visible. + if (this.keyboardVisible) { + const openMinHeight = + this._keyboardOpenMinHeight > 0 + ? Math.min(this._keyboardOpenMinHeight, currentHeight) + : Math.min(this.lastViewportHeight || currentHeight, currentHeight); + this._keyboardOpenMinHeight = openMinHeight; + if (!this._keyboardClosing && currentHeight - openMinHeight >= this.KEYBOARD_CLOSE_START_DELTA_PX) { + this._keyboardClosing = true; + if (this._terminalInputRequested) { + this._beginTerminalFrameCover({ restart: true }); + } + } + } + // Keyboard appeared (viewport shrunk by more than 150px) if (heightDiff > 150 && !this.keyboardVisible) { this.keyboardVisible = true; + this._keyboardOpenMinHeight = currentHeight; + this._keyboardClosing = false; document.body.classList.add('keyboard-visible'); // While the keyboard is open, size the app to the visual viewport so // xterm's bottom row and cursor sit above the OS keyboard. @@ -326,8 +353,15 @@ const KeyboardHandler = { // Use 100px threshold (not 50) to handle iOS address bar drift, // iOS 26's persistent 24px discrepancy, and Safari bottom bar changes else if (heightDiff < 100 && this.keyboardVisible) { - if (this._terminalInputRequested) this._beginTerminalFrameCover(); + // Closing can begin while the opening cover still owns a queued + // compositor swap. Restart its lifecycle so that stale release cannot + // expose the final fit/SIGWINCH redraw sequence. + if (this._terminalInputRequested) { + this._beginTerminalFrameCover({ restart: true }); + } this.keyboardVisible = false; + this._keyboardOpenMinHeight = 0; + this._keyboardClosing = false; document.body.classList.remove('keyboard-visible'); this.onKeyboardHide(); // Re-sync --app-height now that keyboard is gone (MobileDetection skipped @@ -549,6 +583,7 @@ const KeyboardHandler = { this._terminalFrameCoverReadyAt = 0; this._terminalFrameCoverReadyVersion += 1; this._terminalFrameCoverSwapVersion = 0; + this._terminalFrameCoverAuthoritative = false; this._scheduleTerminalFrameCoverExpiry(); if (arm) this._armTerminalFrameCover(); }, @@ -562,6 +597,7 @@ const KeyboardHandler = { this._terminalFrameCoverReadyAt = 0; this._terminalFrameCoverReadyVersion += 1; this._terminalFrameCoverSwapVersion = 0; + this._terminalFrameCoverAuthoritative = false; this._scheduleTerminalFrameCoverExpiry(); }, @@ -590,6 +626,7 @@ const KeyboardHandler = { this._terminalFrameCoverReadyAt = 0; this._terminalFrameCoverReadyVersion += 1; this._terminalFrameCoverSwapVersion = 0; + this._terminalFrameCoverAuthoritative = false; if (this._terminalFrameCoverQuietTimer) { clearTimeout(this._terminalFrameCoverQuietTimer); this._terminalFrameCoverQuietTimer = null; @@ -629,6 +666,16 @@ const KeyboardHandler = { this._tryFinishTerminalFrameCover(); }, + onTerminalFrameAuthoritative() { + if (!this._terminalFrameCover || !this._terminalFrameCoverArmed) return; + this._terminalFrameCoverAuthoritative = true; + if (this._terminalFrameCoverQuietTimer) { + clearTimeout(this._terminalFrameCoverQuietTimer); + this._terminalFrameCoverQuietTimer = null; + } + this.onTerminalFrameReady(); + }, + _tryFinishTerminalFrameCover() { if (!this._terminalFrameCover || !this._terminalFrameCoverArmed || !this._terminalFrameCoverReady) { return; @@ -637,7 +684,8 @@ const KeyboardHandler = { if (!this._terminalHasVisibleFrame()) return; const activeSession = typeof app !== 'undefined' && app.activeSessionId ? app.sessions?.get?.(app.activeSessionId) : null; - const quietMs = activeSession?.mode === 'codex' ? this.FRAME_COVER_CODEX_QUIET_MS : 0; + const quietMs = + this._terminalFrameCoverAuthoritative || activeSession?.mode !== 'codex' ? 0 : this.FRAME_COVER_CODEX_QUIET_MS; const quietRemaining = this._terminalFrameCoverReadyAt + quietMs - Date.now(); if (quietRemaining > 0) { if (!this._terminalFrameCoverQuietTimer) { @@ -726,26 +774,19 @@ const KeyboardHandler = { this._terminalFrameCoverReadyAt = 0; this._terminalFrameCoverReadyVersion += 1; this._terminalFrameCoverSwapVersion = 0; + this._terminalFrameCoverAuthoritative = false; }, /** Send current terminal dimensions to the server (one-shot, for keyboard open/close) */ _sendTerminalResize() { if (typeof app === 'undefined' || !app.activeSessionId || !app.fitAddon) return; try { - const dims = app.fitAddon.proposeDimensions(); - if (dims) { - const cols = Math.max(dims.cols, 40); - const rows = Math.max(dims.rows, 10); - app._lastResizeDims = { cols, rows }; - // Declare the viewport type so resize arbitration can ignore this - // while a desktop connection is sizing the same session. - const viewportType = MobileDetection.getDeviceType ? MobileDetection.getDeviceType() : 'mobile'; - fetch(`/api/sessions/${app.activeSessionId}/resize`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ cols, rows, viewportType }), - }).catch(() => {}); - } + return app._requestTerminalFrameReconcile?.({ + captureWhenUnchanged: true, + reason: 'keyboard-resize', + resizeOptions: { takeControl: true, refit: false }, + settleMs: TUI_REDRAW_SETTLE_MS, + }); } catch {} }, diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index fcc7c36b..5e5526af 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -580,6 +580,11 @@ Object.assign(CodemanApp.prototype, { this._chunkedWriteGen = 0; this._bufferLoadSeq = 0; this._bufferLoadOwner = null; + this._terminalFrameReconcileSeq = 0; + this._terminalFrameReconcilePending = null; + this._terminalFrameReconcilePromise = null; + this._terminalFrameReconcileAbortController?.abort?.(); + this._terminalFrameReconcileAbortController = null; this._lastUserScrollUpAt = null; // Handle resize with throttling for performance @@ -1975,12 +1980,14 @@ Object.assign(CodemanApp.prototype, { return performance.now() - this._lastUserScrollUpAt < window.CodemanTerminalInput.USER_SCROLL_STICKY_SUPPRESS_MS; }, - batchTerminalWrite(data) { + batchTerminalWrite(data, cursor) { // If a buffer load (chunkedTerminalWrite) is in progress, queue live events // to prevent interleaving historical buffer data with live SSE data. // This is critical: interleaving causes cursor position chaos with Ink redraws. if (this._isLoadingBuffer) { - if (this._loadBufferQueue) this._loadBufferQueue.push(data); + if (this._loadBufferQueue) { + this._loadBufferQueue.push(this._isTerminalCursor(cursor) ? { data, cursor } : data); + } return; } if (typeof KeyboardHandler !== 'undefined') { @@ -2055,6 +2062,17 @@ Object.assign(CodemanApp.prototype, { }); }, + /** + * Resolve only after every xterm write queued before this call has parsed. + * Resetting or repainting before this fence lets an older terminal frame + * mutate the replacement screen through xterm's shared write buffer. + */ + _waitForTerminalParserFence() { + const terminal = this.terminal; + if (!terminal?.write) return Promise.resolve(); + return new Promise((resolve) => terminal.write('', resolve)); + }, + /** * Flush the flicker filter buffer to the terminal. * Called after the buffer window expires. @@ -2461,29 +2479,370 @@ Object.assign(CodemanApp.prototype, { }); }, + _waitForTerminalPaint() { + return new Promise((resolve) => { + this._safeYield(() => this._safeYield(resolve)); + }); + }, + + _waitForTerminalFrameSettle(delayMs, signal) { + if (delayMs <= 0) return Promise.resolve(true); + if (signal?.aborted) return Promise.resolve(false); + return new Promise((resolve) => { + let timer = null; + let onAbort = null; + const finish = (completed) => { + if (timer !== null) clearTimeout(timer); + signal?.removeEventListener?.('abort', onAbort); + resolve(completed); + }; + onAbort = () => finish(false); + timer = setTimeout(() => finish(true), delayMs); + signal?.addEventListener?.('abort', onAbort, { once: true }); + }); + }, + + _terminalSnapshotCursorFromHeaders(headers) { + if (headers?.get?.('x-codeman-terminal-format') !== 'stream-v1') return null; + const cursor = { + stream: headers.get('x-codeman-terminal-stream'), + generation: Number(headers.get('x-codeman-terminal-generation')), + start: Number(headers.get('x-codeman-terminal-start')), + end: Number(headers.get('x-codeman-terminal-end')), + }; + return this._isTerminalCursor(cursor) ? cursor : null; + }, + + /** + * Decode a compressed HTTP snapshot incrementally. Browser fetch exposes the + * losslessly decompressed bytes; TextDecoder preserves UTF-8 sequences split + * across response chunks. + */ + async _readTerminalSnapshotResponse(response, options = {}) { + if (!response?.ok) { + throw new Error(`Terminal snapshot request failed (${response?.status ?? 'unknown'})`); + } + + const cursor = this._terminalSnapshotCursorFromHeaders(response.headers); + if (!cursor) { + const payload = await response.json(); + const data = + payload?.data && typeof payload.data === 'object' ? payload.data : payload; + return { + ...(data ?? {}), + streamed: false, + aborted: false, + }; + } + + const result = { + terminalBuffer: '', + status: response.headers.get('x-codeman-terminal-status') || undefined, + fullSize: Number(response.headers.get('x-codeman-terminal-full-size')) || 0, + truncated: response.headers.get('x-codeman-terminal-truncated') === '1', + source: response.headers.get('x-codeman-terminal-source') || 'history', + cursor, + streamed: true, + aborted: false, + }; + const reader = response.body?.getReader?.(); + if (!reader) { + result.terminalBuffer = await response.text(); + return result; + } + + const decoder = new TextDecoder(); + const chunks = []; + + while (true) { + if (options.isCancelled?.()) { + result.aborted = true; + try { + await reader.cancel(); + } catch {} + return result; + } + const { done, value } = await reader.read(); + if (options.isCancelled?.()) { + result.aborted = true; + try { + await reader.cancel(); + } catch {} + return result; + } + if (done) break; + const text = decoder.decode(value, { stream: true }); + if (text) chunks.push(text); + } + const finalText = decoder.decode(); + if (finalText) chunks.push(finalText); + result.terminalBuffer = chunks.join(''); + return result; + }, + + _isTerminalActionSubmission(input) { + if (typeof input !== 'string' || !input || input.startsWith('\x1b[200~')) return false; + if (input === '\r' || input === '\n') return true; + return /\x1b\[<0;\d+;\d+[Mm]/.test(input); + }, + + _hasForegroundTerminalDecision(sessionId = this.activeSessionId) { + const hooks = sessionId ? this.pendingHooks?.get?.(sessionId) : null; + if (hooks?.has?.('permission_prompt') || hooks?.has?.('elicitation_dialog')) return true; + if ( + !sessionId || + sessionId !== this.activeSessionId || + !this.terminal || + !this._terminalViewportAtBottom() + ) { + return false; + } + + const buffer = this.terminal.buffer?.active; + if (!buffer?.getLine) return false; + const rows = Math.max(1, this.terminal.rows || 1); + let choices = 0; + let selectedChoice = false; + let selectionInstruction = false; + for (let row = 0; row < rows; row++) { + const line = buffer.getLine(buffer.viewportY + row)?.translateToString?.(true) || ''; + if (/^\s*(?:[❯›]\s*)?\d+[.)]\s+\S/.test(line)) choices += 1; + if (/^\s*[❯›]\s*\d+[.)]\s+\S/.test(line)) selectedChoice = true; + if ( + /(?:enter|return).*(?:select|confirm)|(?:select|confirm).*(?:enter|return)|esc\s+to\s+(?:cancel|go back)/i.test( + line + ) + ) { + selectionInstruction = true; + } + } + return choices >= 2 && (selectedChoice || selectionInstruction); + }, + + _shouldReconcileTerminalAction(sessionId, input) { + const isTouch = + typeof MobileDetection !== 'undefined' && MobileDetection.isTouchDevice?.(); + const session = sessionId ? this.sessions?.get?.(sessionId) : null; + return Boolean( + isTouch && + sessionId === this.activeSessionId && + session?.mode !== 'shell' && + this._isTerminalActionSubmission(input) && + this._hasForegroundTerminalDecision(sessionId) + ); + }, + + _isTerminalFrameReconcileCurrent(request) { + return Boolean( + request && + request.id === this._terminalFrameReconcileSeq && + request.sessionId === this.activeSessionId + ); + }, + + _requestTerminalFrameReconcile(options = {}) { + const sessionId = this.activeSessionId; + const session = sessionId ? this.sessions?.get?.(sessionId) : null; + if (!sessionId || !this.terminal || session?.mode === 'shell') { + return Promise.resolve(false); + } + + const request = { + captureWhenUnchanged: options.captureWhenUnchanged === true, + id: ++this._terminalFrameReconcileSeq, + reason: options.reason || 'terminal-transition', + resizeOptions: options.resizeOptions || null, + sessionId, + settleMs: Math.max(0, Number(options.settleMs) || 0), + }; + this._terminalFrameReconcilePending = request; + this._terminalFrameReconcileAbortController?.abort?.(); + if (!this._terminalFrameReconcilePromise) { + let trackedPromise; + trackedPromise = this._drainTerminalFrameReconciles().finally(() => { + if (this._terminalFrameReconcilePromise === trackedPromise) { + this._terminalFrameReconcilePromise = null; + } + }); + this._terminalFrameReconcilePromise = trackedPromise; + } + return this._terminalFrameReconcilePromise; + }, + + async _drainTerminalFrameReconciles() { + let result = false; + while (this._terminalFrameReconcilePending) { + const request = this._terminalFrameReconcilePending; + this._terminalFrameReconcilePending = null; + result = await this._runTerminalFrameReconcile(request); + } + return result; + }, + + async _runTerminalFrameReconcile(request) { + if (!this._isTerminalFrameReconcileCurrent(request)) return false; + if (this._isLoadingBuffer) { + if (typeof KeyboardHandler !== 'undefined') { + KeyboardHandler.onTerminalFrameReady?.(); + } + return false; + } + + let loadOwner = this._beginBufferLoad(`frame-reconcile-${request.id}`); + let authoritative = false; + let synchronizedUpdateOpen = false; + let fetchTimeout = null; + const abortController = + typeof AbortController !== 'undefined' ? new AbortController() : null; + this._terminalFrameReconcileAbortController = abortController; + const terminal = this.terminal; + const syncStart = '\x1b[?2026h'; + const syncEnd = '\x1b[?2026l'; + try { + if (request.resizeOptions) { + const dimensionsChanged = await this.sendResize(request.sessionId, request.resizeOptions); + if (!this._isTerminalFrameReconcileCurrent(request)) return false; + if (!dimensionsChanged && !request.captureWhenUnchanged) { + this._finishBufferLoad(loadOwner, { flushQueued: true }); + loadOwner = null; + await this._waitForTerminalPaint(); + if ( + this._isTerminalFrameReconcileCurrent(request) && + typeof KeyboardHandler !== 'undefined' + ) { + KeyboardHandler.onTerminalFrameAuthoritative?.(); + } + return false; + } + } + + if (request.settleMs > 0) { + const settled = await this._waitForTerminalFrameSettle( + request.settleMs, + abortController?.signal + ); + if (!settled || !this._isTerminalFrameReconcileCurrent(request)) return false; + } + + if (abortController) { + fetchTimeout = setTimeout( + () => abortController.abort(), + TERMINAL_FRAME_FETCH_TIMEOUT_MS + ); + } + const response = await fetch( + `/api/sessions/${request.sessionId}/terminal?latest=1` + + `&tail=${TERMINAL_LATEST_FRAME_SIZE}&format=stream`, + abortController ? { signal: abortController.signal } : undefined + ); + const latest = await this._readTerminalSnapshotResponse(response, { + paint: false, + isCancelled: () => + abortController?.signal.aborted || + !this._isTerminalFrameReconcileCurrent(request), + }); + if (fetchTimeout !== null) { + clearTimeout(fetchTimeout); + fetchTimeout = null; + } + if ( + latest.aborted || + !this._isTerminalFrameReconcileCurrent(request) || + latest.source !== 'mux-visible' || + !latest.terminalBuffer || + !this._isTerminalCursor(latest.cursor) + ) { + return false; + } + + // The pane snapshot includes everything accepted by the PTY through its + // cursor boundary. Drop pre-snapshot browser work, cross xterm's parser + // fence, then repaint only the visible rows so existing scrollback stays. + this.pendingWrites = []; + this.writeFrameScheduled = false; + this._clearTimer('flickerFilterTimeout'); + this.flickerFilterBuffer = ''; + this.flickerFilterActive = false; + await this._waitForTerminalParserFence(); + if (!this._isTerminalFrameReconcileCurrent(request)) return false; + + // A size-bounded transport transaction may have parsed its opening + // DEC-2026 marker while the closing fragment is still queued. Close it, + // then paint the replacement pane inside a fresh synchronized update. + synchronizedUpdateOpen = true; + await new Promise((resolve) => + terminal.write(`${syncEnd}${syncStart}\x1b[0m\x1b[H\x1b[2J`, resolve) + ); + await this.chunkedTerminalWrite( + latest.terminalBuffer, + TERMINAL_CHUNK_SIZE, + loadOwner + ); + if (!this._isTerminalFrameReconcileCurrent(request)) return false; + await new Promise((resolve) => terminal.write(syncEnd, resolve)); + synchronizedUpdateOpen = false; + + this._terminalScrollLocked = false; + this._wasAtBottomBeforeWrite = true; + terminal.scrollToBottom(); + await this._waitForTerminalPaint(); + if (!this._isTerminalFrameReconcileCurrent(request)) return false; + + this._finishBufferLoad(loadOwner, { snapshotCursor: latest.cursor }); + loadOwner = null; + this._syncMobileHelperTextareaToCursor?.(); + this._localEchoOverlay?.rerender?.(); + await this._waitForTerminalPaint(); + if (this._isTerminalFrameReconcileCurrent(request)) { + authoritative = true; + if (typeof KeyboardHandler !== 'undefined') { + KeyboardHandler.onTerminalFrameAuthoritative?.(); + } + } + _crashDiag.log(`FRAME_RECONCILE: ${request.reason}`); + return true; + } catch (err) { + if (!abortController?.signal.aborted) { + console.warn(`Failed to reconcile terminal frame after ${request.reason}:`, err); + } + return false; + } finally { + if (fetchTimeout !== null) clearTimeout(fetchTimeout); + if (this._terminalFrameReconcileAbortController === abortController) { + this._terminalFrameReconcileAbortController = null; + } + if (synchronizedUpdateOpen) { + try { + await new Promise((resolve) => terminal.write(syncEnd, resolve)); + } catch {} + } + if (loadOwner !== null) { + this._finishBufferLoad(loadOwner, { flushQueued: true }); + } + if ( + !authoritative && + this._isTerminalFrameReconcileCurrent(request) && + !this._terminalFrameReconcilePending && + typeof KeyboardHandler !== 'undefined' + ) { + KeyboardHandler.onTerminalFrameReady?.(); + } + } + }, + /** * Complete a buffer load: unblock live SSE writes. * Called when chunkedTerminalWrite finishes (or is skipped for empty buffers). * - * By default queued SSE events are DISCARDED, not flushed. For an established - * session the loaded buffer from the API is the source of truth up to the - * response timestamp; SSE events queued during the fetch+write overlap already - * appear in that buffer, so flushing them writes duplicate data (especially Ink - * cursor-up redraws), corrupting the terminal display. - * - * COD-144: a brand-new session is the exception. Its terminal fetch can resolve - * BEFORE the PTY emits its first prompt, so the fetched buffer is empty and the - * prompt arrives only as a queued SSE event. Discarding it leaves the terminal - * blank until a tab-switch re-fetches a now-populated buffer. When the caller - * knows the load painted nothing (empty fetch + no cache), it passes - * `{ flushQueued: true }` so the queued events are REPLAYED through - * `batchTerminalWrite()` instead of dropped. Replay runs after `_isLoadingBuffer` - * is cleared, so the events write through normally and are not re-queued. + * Cursor-bearing events are reconciled against the snapshot boundary: covered + * output is discarded, output after the boundary is replayed, and a batch that + * crosses the boundary contributes only its uncovered suffix. Legacy events + * retain COD-144's empty-buffer `flushQueued` fallback. * * After unblocking, new SSE/WS events deliver subsequent output normally. * * @param {string} [owner] Load token from `_beginBufferLoad`; a stale owner is a no-op. - * @param {{ flushQueued?: boolean }} [opts] When `flushQueued` is true, replay any queued events. + * @param {{ flushQueued?: boolean, snapshotCursor?: object }} [opts] */ _beginBufferLoad(owner) { if (this._bufferLoadSeq === undefined) this._bufferLoadSeq = 0; @@ -2502,16 +2861,71 @@ Object.assign(CodemanApp.prototype, { this._isLoadingBuffer = false; this._loadBufferQueue = null; this._bufferLoadOwner = null; - // COD-144: replay (rather than discard) queued live events when the load - // painted nothing — the queued prompt is the only content a new session has. - if (opts?.flushQueued && queued && queued.length) { - for (const data of queued) { - this.batchTerminalWrite(data); + if (queued && queued.length) { + for (const item of queued) { + if (opts?.snapshotCursor && typeof item === 'object' && item !== null) { + const replay = this._terminalEventAfterSnapshot(item, opts.snapshotCursor); + if (replay) this.batchTerminalWrite(replay.data, replay.cursor); + } else if (opts?.flushQueued) { + const data = typeof item === 'string' ? item : item.data; + const itemCursor = typeof item === 'string' ? undefined : item.cursor; + if (itemCursor) this.batchTerminalWrite(data, itemCursor); + else this.batchTerminalWrite(data); + } } } + const overlay = this._localEchoOverlay; + if (overlay?.hasPending && this.terminal?.write) { + this.terminal.write('', () => { + if (!this._isLoadingBuffer && overlay === this._localEchoOverlay && overlay.hasPending) { + overlay.rerender(); + } + }); + } return true; }, + _isTerminalCursor(cursor) { + return Boolean( + cursor && + typeof cursor.stream === 'string' && + cursor.stream.length > 0 && + Number.isSafeInteger(cursor.generation) && + cursor.generation >= 0 && + Number.isSafeInteger(cursor.start) && + Number.isSafeInteger(cursor.end) && + cursor.start >= 0 && + cursor.end >= cursor.start + ); + }, + + _terminalEventAfterSnapshot(item, snapshotCursor) { + const cursor = item?.cursor; + if (!this._isTerminalCursor(cursor) || !this._isTerminalCursor(snapshotCursor)) return null; + if (cursor.stream !== snapshotCursor.stream || cursor.generation < snapshotCursor.generation) return null; + if (cursor.generation > snapshotCursor.generation) return item; + if (cursor.end <= snapshotCursor.end) return null; + if (cursor.start >= snapshotCursor.end) return item; + + const coveredLength = snapshotCursor.end - cursor.start; + const syncStart = '\x1b[?2026h'; + const syncEnd = '\x1b[?2026l'; + const payloadStart = item.data.startsWith(syncStart) ? syncStart.length : 0; + const payloadEnd = item.data.endsWith(syncEnd) + ? item.data.length - syncEnd.length + : item.data.length; + const hasCursorAlignedPayload = + payloadEnd - payloadStart === cursor.end - cursor.start; + const suffix = hasCursorAlignedPayload + ? syncStart + item.data.slice(payloadStart + coveredLength, payloadEnd) + syncEnd + : item.data.slice(coveredLength); + + return { + data: suffix, + cursor: { ...cursor, start: snapshotCursor.end }, + }; + }, + // ═══════════════════════════════════════════════════════════════ // Terminal Controls // ═══════════════════════════════════════════════════════════════ diff --git a/test/mobile/terminal-frame-reconcile.test.ts b/test/mobile/terminal-frame-reconcile.test.ts new file mode 100644 index 00000000..498f08cd --- /dev/null +++ b/test/mobile/terminal-frame-reconcile.test.ts @@ -0,0 +1,203 @@ +// Port 3220 - Authoritative mobile terminal-frame reconciliation. +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import type { BrowserContext, Page } from 'playwright'; +import { createDevicePage, closeAllBrowsers } from './helpers/browser.js'; +import { createTestServer, stopTestServer } from './helpers/server.js'; +import { REPRESENTATIVE_DEVICES } from './devices.js'; +import type { WebServer } from '../src/web/server.js'; + +const PORT = 3220; +const BASE_URL = `http://localhost:${PORT}`; + +async function visibleTerminalText(page: Page): Promise { + return page.evaluate(() => { + const buffer = app.terminal.buffer.active; + return Array.from({ length: app.terminal.rows }, (_, row) => + buffer.getLine(buffer.viewportY + row)?.translateToString(true) + ).join('\n'); + }); +} + +describe('mobile terminal-frame reconciliation', () => { + let server: WebServer; + let page: Page; + let context: BrowserContext; + + beforeAll(async () => { + server = await createTestServer(PORT); + }); + + afterAll(async () => { + await stopTestServer(server); + await closeAllBrowsers(); + }); + + beforeEach(async () => { + const result = await createDevicePage(REPRESENTATIVE_DEVICES['standard-phone'], BASE_URL, 'chromium'); + page = result.page; + context = result.context; + }); + + afterEach(async () => { + await context.close(); + }); + + it('captures the latest pane even when local dimensions are unchanged', async () => { + await page.evaluate(() => { + const sessionId = 'frame-reconcile-resize'; + app.activeSessionId = sessionId; + app.sessions.set(sessionId, { id: sessionId, mode: 'codex' }); + app.hideWelcome(); + window.__frameRequests = []; + const originalFetch = window.fetch.bind(window); + window.fetch = async (input, init) => { + const url = String(input); + if (url.includes(`/api/sessions/${sessionId}/terminal?latest=1`)) { + window.__frameRequests.push(url); + return new Response('AUTHORITATIVE RESIZE FRAME', { + status: 200, + headers: { + 'x-codeman-terminal-format': 'stream-v1', + 'x-codeman-terminal-stream': 'stream-a', + 'x-codeman-terminal-generation': '1', + 'x-codeman-terminal-start': '0', + 'x-codeman-terminal-end': '5', + 'x-codeman-terminal-source': 'mux-visible', + }, + }); + } + return originalFetch(input, init); + }; + app.sendResize = async () => false; + const history = Array.from( + { length: app.terminal.rows + 8 }, + (_, index) => `HISTORY ${String(index).padStart(3, '0')}\r\n` + ).join(''); + app.terminal.write(`\x1b[2J\x1b[H${history}OLD RESIZE FRAME`); + }); + await page.evaluate( + () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))) + ); + + await page.evaluate(async () => { + KeyboardHandler._beginTerminalFrameCover({ arm: true }); + const reconcile = KeyboardHandler._sendTerminalResize(); + app.batchTerminalWrite('STALE', { + stream: 'stream-a', + generation: 1, + start: 0, + end: 5, + }); + await reconcile; + }); + + await expect.poll(() => page.locator('.terminal-resize-frame-cover').count()).toBe(0); + const state = await page.evaluate(() => { + const buffer = app.terminal.buffer.active; + const all = Array.from({ length: buffer.length }, (_, row) => buffer.getLine(row)?.translateToString(true)).join( + '\n' + ); + return { + all, + requests: window.__frameRequests, + }; + }); + const visible = await visibleTerminalText(page); + expect(state.requests).toHaveLength(1); + expect(state.requests[0]).toContain('latest=1&tail=131072&format=stream'); + expect(visible).toContain('AUTHORITATIVE RESIZE FRAME'); + expect(visible).not.toContain('STALE'); + expect(state.all).toContain('HISTORY 000'); + }); + + it('reconciles a visible dialogue after sending the selected input exactly once', async () => { + await page.evaluate(() => { + const sessionId = 'frame-reconcile-dialogue'; + app.activeSessionId = sessionId; + app.sessions.set(sessionId, { id: sessionId, mode: 'codex' }); + app.hideWelcome(); + window.__sentDialogueInputs = []; + const originalFetch = window.fetch.bind(window); + window.fetch = async (input, init) => { + const url = String(input); + if (url.includes(`/api/sessions/${sessionId}/terminal?latest=1`)) { + return new Response('DIALOGUE RESOLVED FRAME', { + status: 200, + headers: { + 'x-codeman-terminal-format': 'stream-v1', + 'x-codeman-terminal-stream': 'dialogue-stream', + 'x-codeman-terminal-generation': '1', + 'x-codeman-terminal-start': '0', + 'x-codeman-terminal-end': '0', + 'x-codeman-terminal-source': 'mux-visible', + }, + }); + } + return originalFetch(input, init); + }; + app._reliableSend = (_sessionId, input) => { + window.__sentDialogueInputs.push(input); + }; + app.terminal.write('\x1b[2J\x1b[HApprove deployment?\r\n\u203a 1. Yes\r\n 2. No\r\nPress enter to select'); + }); + await page.evaluate( + () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))) + ); + + const immediate = await page.evaluate(() => { + const detected = app._shouldReconcileTerminalAction('frame-reconcile-dialogue', '\r'); + app._sendInputAsync('frame-reconcile-dialogue', '\r'); + return { + detected, + loading: app._isLoadingBuffer, + sent: [...window.__sentDialogueInputs], + }; + }); + expect(immediate).toEqual({ detected: true, loading: true, sent: ['\r'] }); + + await page.evaluate(() => app._terminalFrameReconcilePromise); + await expect.poll(() => page.locator('.terminal-resize-frame-cover').count()).toBe(0); + const visible = await visibleTerminalText(page); + expect(visible).toContain('DIALOGUE RESOLVED FRAME'); + expect(visible).not.toContain('Approve deployment?'); + expect(await page.evaluate(() => window.__sentDialogueInputs)).toEqual(['\r']); + }); + + it('covers the first meaningful keyboard-close growth before the hidden threshold', async () => { + await page.evaluate(() => { + const sessionId = 'frame-reconcile-close'; + app.activeSessionId = sessionId; + app.sessions.set(sessionId, { id: sessionId, mode: 'codex' }); + app.hideWelcome(); + app._requestTerminalFrameReconcile = async () => false; + app.terminal.write('\x1b[2J\x1b[HSTABLE KEYBOARD FRAME'); + }); + await page.evaluate( + () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))) + ); + + const state = await page.evaluate(() => { + KeyboardHandler.keyboardVisible = true; + KeyboardHandler._terminalInputRequested = true; + KeyboardHandler.initialViewportHeight = 800; + KeyboardHandler.lastViewportHeight = 400; + KeyboardHandler._keyboardOpenMinHeight = 400; + KeyboardHandler._keyboardClosing = false; + Object.defineProperty(window.visualViewport, 'height', { + configurable: true, + value: 460, + }); + KeyboardHandler.handleViewportResize(); + const cover = app.terminal.element?.querySelector('.terminal-resize-frame-cover'); + return { + keyboardVisible: KeyboardHandler.keyboardVisible, + keyboardClosing: KeyboardHandler._keyboardClosing, + coverText: cover?.textContent || '', + }; + }); + + expect(state.keyboardVisible).toBe(true); + expect(state.keyboardClosing).toBe(true); + expect(state.coverText).toContain('STABLE KEYBOARD FRAME'); + }); +}); diff --git a/test/terminal-buffer-flush.test.ts b/test/terminal-buffer-flush.test.ts index 54267beb..7cbe8301 100644 --- a/test/terminal-buffer-flush.test.ts +++ b/test/terminal-buffer-flush.test.ts @@ -26,6 +26,7 @@ import { readFileSync } from 'node:fs'; import { performance } from 'node:perf_hooks'; import { resolve } from 'node:path'; +import { TextDecoder, TextEncoder } from 'node:util'; import vm from 'node:vm'; import { describe, expect, it, vi } from 'vitest'; @@ -41,6 +42,8 @@ function loadTerminalMixin(): Record { setInterval: vi.fn(), clearInterval: vi.fn(), requestAnimationFrame: vi.fn(), + TextDecoder, + TERMINAL_CHUNK_SIZE: 32 * 1024, CodemanApp: FakeCodemanApp, // terminal-ui.js IIFE is invoked with `window`; it reads/writes a few globals. window: { addEventListener: vi.fn(), removeEventListener: vi.fn() }, @@ -52,14 +55,38 @@ function loadTerminalMixin(): Record { const mixin = loadTerminalMixin(); +type TerminalCursor = { + stream: string; + generation: number; + start: number; + end: number; +}; + type BufferLoadApp = { _bufferLoadSeq: number; _bufferLoadOwner: string | null; _isLoadingBuffer: boolean; - _loadBufferQueue: string[] | null; - batchTerminalWrite: (data: string) => void; + _loadBufferQueue: Array | null; + batchTerminalWrite: (data: string, cursor?: TerminalCursor) => void; _beginBufferLoad: (owner?: string) => string; - _finishBufferLoad: (owner?: string, opts?: { flushQueued?: boolean }) => boolean; + _finishBufferLoad: (owner?: string, opts?: { flushQueued?: boolean; snapshotCursor?: TerminalCursor }) => boolean; + _isTerminalCursor: (cursor: unknown) => boolean; + _terminalEventAfterSnapshot: ( + item: { data: string; cursor: TerminalCursor }, + snapshotCursor: TerminalCursor + ) => { data: string; cursor: TerminalCursor } | null; + _terminalSnapshotCursorFromHeaders: (headers: Headers) => TerminalCursor | null; + _readTerminalSnapshotResponse: ( + response: Response, + options?: { + isCancelled?: () => boolean; + } + ) => Promise<{ + terminalBuffer: string; + cursor?: TerminalCursor; + streamed: boolean; + aborted: boolean; + }>; }; /** @@ -80,14 +107,22 @@ function makeApp() { }), _beginBufferLoad: mixin._beginBufferLoad as BufferLoadApp['_beginBufferLoad'], _finishBufferLoad: mixin._finishBufferLoad as BufferLoadApp['_finishBufferLoad'], + _isTerminalCursor: mixin._isTerminalCursor as BufferLoadApp['_isTerminalCursor'], + _terminalEventAfterSnapshot: mixin._terminalEventAfterSnapshot as BufferLoadApp['_terminalEventAfterSnapshot'], + _terminalSnapshotCursorFromHeaders: + mixin._terminalSnapshotCursorFromHeaders as BufferLoadApp['_terminalSnapshotCursorFromHeaders'], + _readTerminalSnapshotResponse: + mixin._readTerminalSnapshotResponse as BufferLoadApp['_readTerminalSnapshotResponse'], }; return { app, writes }; } /** Simulate live SSE events arriving while a buffer load is in progress (the queue path). */ -function pushWhileLoading(app: BufferLoadApp, data: string) { +function pushWhileLoading(app: BufferLoadApp, data: string, cursor?: TerminalCursor) { // Mirrors batchTerminalWrite's queue branch: if loading, push to the queue. - if (app._isLoadingBuffer && app._loadBufferQueue) app._loadBufferQueue.push(data); + if (app._isLoadingBuffer && app._loadBufferQueue) { + app._loadBufferQueue.push(cursor ? { data, cursor } : data); + } } describe('buffer-load flush (COD-144)', () => { @@ -169,4 +204,153 @@ describe('buffer-load flush (COD-144)', () => { expect(app.batchTerminalWrite).not.toHaveBeenCalled(); expect(writes).toEqual([]); }); + + it('discards cursor-covered events and replays only the synchronized suffix beyond the snapshot', () => { + const { app, writes } = makeApp(); + const owner = app._beginBufferLoad('cursor-overlap'); + pushWhileLoading(app, 'covered', { stream: 'stream-a', generation: 1, start: 0, end: 7 }); + pushWhileLoading(app, 'abcdefgh', { stream: 'stream-a', generation: 1, start: 7, end: 15 }); + pushWhileLoading(app, 'after', { stream: 'stream-a', generation: 1, start: 15, end: 20 }); + + expect( + app._finishBufferLoad(owner, { + snapshotCursor: { stream: 'stream-a', generation: 1, start: 0, end: 10 }, + }) + ).toBe(true); + + expect(writes).toEqual(['\x1b[?2026hdefgh\x1b[?2026l', 'after']); + expect(app.batchTerminalWrite).toHaveBeenNthCalledWith(1, '\x1b[?2026hdefgh\x1b[?2026l', { + stream: 'stream-a', + generation: 1, + start: 10, + end: 15, + }); + expect(app.batchTerminalWrite).toHaveBeenNthCalledWith(2, 'after', { + stream: 'stream-a', + generation: 1, + start: 15, + end: 20, + }); + }); + + it.each([ + ['opening fragment', '\x1b[?2026habcdefgh'], + ['middle fragment', 'abcdefgh'], + ['closing fragment', 'abcdefgh\x1b[?2026l'], + ])('rewraps an overlap inside a fragmented WS synchronized update (%s)', (_label, data) => { + const { app, writes } = makeApp(); + const owner = app._beginBufferLoad('cursor-fragment-overlap'); + pushWhileLoading(app, data, { + stream: 'stream-a', + generation: 1, + start: 7, + end: 15, + }); + + app._finishBufferLoad(owner, { + snapshotCursor: { stream: 'stream-a', generation: 1, start: 0, end: 10 }, + }); + + expect(writes).toEqual(['\x1b[?2026hdefgh\x1b[?2026l']); + expect(app.batchTerminalWrite).toHaveBeenCalledWith('\x1b[?2026hdefgh\x1b[?2026l', { + stream: 'stream-a', + generation: 1, + start: 10, + end: 15, + }); + }); + + it('drops stale stream and generation events while preserving a newer generation', () => { + const { app, writes } = makeApp(); + const owner = app._beginBufferLoad('cursor-generation'); + pushWhileLoading(app, 'old stream', { stream: 'old-stream', generation: 9, start: 0, end: 10 }); + pushWhileLoading(app, 'old generation', { stream: 'stream-a', generation: 1, start: 0, end: 14 }); + pushWhileLoading(app, 'new generation', { stream: 'stream-a', generation: 3, start: 0, end: 14 }); + + app._finishBufferLoad(owner, { + snapshotCursor: { stream: 'stream-a', generation: 2, start: 0, end: 5 }, + }); + + expect(writes).toEqual(['new generation']); + }); + + it('decodes a streamed snapshot losslessly when an emoji spans wire chunks', async () => { + const { app } = makeApp(); + const text = 'alpha \u05e9\u05dc\u05d5\u05dd \ud83d\ude80 omega'; + const bytes = new TextEncoder().encode(text); + const emojiByte = new TextEncoder().encode(text.slice(0, text.indexOf('\ud83d\ude80'))).length; + const wireChunks = [ + bytes.slice(0, emojiByte + 2), + bytes.slice(emojiByte + 2, emojiByte + 3), + bytes.slice(emojiByte + 3), + ]; + let index = 0; + const headers = new Headers({ + 'x-codeman-terminal-format': 'stream-v1', + 'x-codeman-terminal-stream': 'stream-a', + 'x-codeman-terminal-generation': '4', + 'x-codeman-terminal-start': '0', + 'x-codeman-terminal-end': String(text.length), + 'x-codeman-terminal-source': 'mux-visible', + 'x-codeman-terminal-truncated': '0', + }); + const response = { + ok: true, + headers, + body: { + getReader: () => ({ + read: async () => + index < wireChunks.length ? { done: false, value: wireChunks[index++] } : { done: true, value: undefined }, + cancel: async () => {}, + }), + }, + } as unknown as Response; + const result = await app._readTerminalSnapshotResponse(response); + + expect(result.terminalBuffer).toBe(text); + expect(result).toMatchObject({ + streamed: true, + aborted: false, + source: 'mux-visible', + cursor: { stream: 'stream-a', generation: 4, start: 0, end: text.length }, + }); + }); + + it('keeps the JSON terminal response as a non-streaming fallback', async () => { + const { app } = makeApp(); + const response = { + ok: true, + headers: new Headers({ 'content-type': 'application/json' }), + json: async () => ({ data: { terminalBuffer: 'legacy snapshot', truncated: false } }), + } as unknown as Response; + + await expect(app._readTerminalSnapshotResponse(response)).resolves.toMatchObject({ + terminalBuffer: 'legacy snapshot', + truncated: false, + streamed: false, + }); + }); + + it('repositions a pending local draft after the loaded frame becomes authoritative', () => { + const { app } = makeApp(); + const rerender = vi.fn(); + let writeDone: (() => void) | undefined; + Object.assign(app, { + _localEchoOverlay: { hasPending: true, rerender }, + terminal: { + write: vi.fn((_data: string, callback?: () => void) => { + writeDone = callback; + }), + }, + }); + const owner = app._beginBufferLoad('load-with-draft'); + + expect(app._finishBufferLoad(owner)).toBe(true); + expect(writeDone).toBeTypeOf('function'); + expect(rerender).not.toHaveBeenCalled(); + + writeDone?.(); + + expect(rerender).toHaveBeenCalledOnce(); + }); }); diff --git a/test/terminal-frame-reconcile.test.ts b/test/terminal-frame-reconcile.test.ts new file mode 100644 index 00000000..9c2161a3 --- /dev/null +++ b/test/terminal-frame-reconcile.test.ts @@ -0,0 +1,231 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import vm from 'node:vm'; +import { describe, expect, it, vi } from 'vitest'; + +function loadTerminalMixin() { + const CodemanApp = function CodemanApp(this: any) {}; + const fetchMock = vi.fn(async () => ({ ok: true })); + const keyboardHandler = { + onTerminalFrameAuthoritative: vi.fn(), + onTerminalFrameReady: vi.fn(), + }; + const context = vm.createContext({ + window: {}, + CodemanApp, + KeyboardHandler: keyboardHandler, + MobileDetection: { isTouchDevice: () => true }, + console: { warn: vi.fn(), log: vi.fn() }, + _crashDiag: { log: vi.fn() }, + performance: { now: () => 0 }, + requestAnimationFrame: (callback: () => void) => { + callback(); + return 1; + }, + setTimeout, + clearTimeout, + fetch: fetchMock, + TextDecoder, + AbortController, + DEC_SYNC_STRIP_RE: /\x1b\[\?2026[hl]/g, + TERMINAL_CHUNK_SIZE: 32 * 1024, + TERMINAL_LATEST_FRAME_SIZE: 128 * 1024, + TERMINAL_FRAME_FETCH_TIMEOUT_MS: 5000, + }); + const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-ui.js'), 'utf8'); + vm.runInContext(source, context, { filename: 'terminal-ui.js' }); + return { + mixin: (CodemanApp as any).prototype, + fetchMock, + keyboardHandler, + }; +} + +describe('authoritative terminal-frame reconciliation', () => { + it('coalesces intermediate requests and resolves all callers from the latest drain', async () => { + const { mixin } = loadTerminalMixin(); + let releaseFirst!: () => void; + const firstBlocked = new Promise((resolve) => { + releaseFirst = resolve; + }); + const reasons: string[] = []; + const app: any = { + activeSessionId: 'session-1', + sessions: new Map([['session-1', { mode: 'codex' }]]), + terminal: {}, + _terminalFrameReconcileSeq: 0, + _terminalFrameReconcilePending: null, + _terminalFrameReconcilePromise: null, + _requestTerminalFrameReconcile: mixin._requestTerminalFrameReconcile, + _drainTerminalFrameReconciles: mixin._drainTerminalFrameReconciles, + _runTerminalFrameReconcile: vi.fn(async (request: { reason: string }) => { + reasons.push(request.reason); + if (request.reason === 'first') await firstBlocked; + return request.reason === 'third'; + }), + }; + + const first = app._requestTerminalFrameReconcile({ reason: 'first' }); + const second = app._requestTerminalFrameReconcile({ reason: 'second' }); + const third = app._requestTerminalFrameReconcile({ reason: 'third' }); + + expect(first).toBe(second); + expect(second).toBe(third); + releaseFirst(); + + await expect(first).resolves.toBe(true); + expect(reasons).toEqual(['first', 'third']); + expect(app._terminalFrameReconcilePending).toBeNull(); + expect(app._terminalFrameReconcilePromise).toBeNull(); + }); + + it('interrupts an older settle window when a newer request supersedes it', async () => { + const { mixin } = loadTerminalMixin(); + const reasons: string[] = []; + const app: any = { + activeSessionId: 'session-1', + sessions: new Map([['session-1', { mode: 'codex' }]]), + terminal: {}, + _terminalFrameReconcileSeq: 0, + _terminalFrameReconcilePending: null, + _terminalFrameReconcilePromise: null, + _terminalFrameReconcileAbortController: null, + _requestTerminalFrameReconcile: mixin._requestTerminalFrameReconcile, + _drainTerminalFrameReconciles: mixin._drainTerminalFrameReconciles, + _waitForTerminalFrameSettle: mixin._waitForTerminalFrameSettle, + _runTerminalFrameReconcile: vi.fn(async function (request: { reason: string }) { + reasons.push(request.reason); + if (request.reason !== 'first') return true; + const controller = new AbortController(); + this._terminalFrameReconcileAbortController = controller; + return this._waitForTerminalFrameSettle(10_000, controller.signal); + }), + }; + + const first = app._requestTerminalFrameReconcile({ reason: 'first' }); + const latest = app._requestTerminalFrameReconcile({ reason: 'latest' }); + + await expect(latest).resolves.toBe(true); + await expect(first).resolves.toBe(true); + expect(reasons).toEqual(['first', 'latest']); + }); + + it('closes synchronized-update mode and releases the buffer gate when invalidated mid-paint', async () => { + const { mixin, fetchMock } = loadTerminalMixin(); + const writes: string[] = []; + const app: any = { + activeSessionId: 'session-1', + sessions: new Map([['session-1', { mode: 'codex' }]]), + pendingWrites: [], + writeFrameScheduled: false, + flickerFilterBuffer: '', + flickerFilterActive: false, + _bufferLoadSeq: 0, + _bufferLoadOwner: null, + _isLoadingBuffer: false, + _loadBufferQueue: null, + _terminalFrameReconcileSeq: 1, + _terminalFrameReconcilePending: null, + _isTerminalFrameReconcileCurrent: mixin._isTerminalFrameReconcileCurrent, + _runTerminalFrameReconcile: mixin._runTerminalFrameReconcile, + _beginBufferLoad: mixin._beginBufferLoad, + _finishBufferLoad: mixin._finishBufferLoad, + _isTerminalCursor: mixin._isTerminalCursor, + _terminalEventAfterSnapshot: mixin._terminalEventAfterSnapshot, + _clearTimer: vi.fn(), + _waitForTerminalParserFence: vi.fn(async () => {}), + _waitForTerminalPaint: vi.fn(async () => {}), + _readTerminalSnapshotResponse: vi.fn(async () => ({ + aborted: false, + source: 'mux-visible', + terminalBuffer: 'AUTHORITATIVE FRAME', + cursor: { stream: 'stream-a', generation: 1, start: 0, end: 19 }, + })), + chunkedTerminalWrite: vi.fn(async () => { + app._terminalFrameReconcileSeq += 1; + }), + batchTerminalWrite: vi.fn(), + terminal: { + write: vi.fn((data: string, callback?: () => void) => { + writes.push(data); + callback?.(); + }), + scrollToBottom: vi.fn(), + }, + }; + const request = { + id: 1, + sessionId: 'session-1', + reason: 'cancel-mid-paint', + resizeOptions: null, + settleMs: 0, + captureWhenUnchanged: false, + }; + + await expect(app._runTerminalFrameReconcile(request)).resolves.toBe(false); + + expect(fetchMock).toHaveBeenCalledOnce(); + expect(writes.at(-1)).toBe('\x1b[?2026l'); + expect(app._isLoadingBuffer).toBe(false); + expect(app._bufferLoadOwner).toBeNull(); + }); + + it('aborts a stalled snapshot request and releases the buffer gate', async () => { + vi.useFakeTimers(); + try { + const { mixin, fetchMock } = loadTerminalMixin(); + fetchMock.mockImplementation( + async (_input: unknown, init?: { signal?: AbortSignal }) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => reject(new Error('aborted')), { + once: true, + }); + }) + ); + const app: any = { + activeSessionId: 'session-1', + sessions: new Map([['session-1', { mode: 'codex' }]]), + pendingWrites: [], + writeFrameScheduled: false, + flickerFilterBuffer: '', + flickerFilterActive: false, + _bufferLoadSeq: 0, + _bufferLoadOwner: null, + _isLoadingBuffer: false, + _loadBufferQueue: null, + _terminalFrameReconcileSeq: 1, + _terminalFrameReconcilePending: null, + _terminalFrameReconcileAbortController: null, + _isTerminalFrameReconcileCurrent: mixin._isTerminalFrameReconcileCurrent, + _runTerminalFrameReconcile: mixin._runTerminalFrameReconcile, + _beginBufferLoad: mixin._beginBufferLoad, + _finishBufferLoad: mixin._finishBufferLoad, + _clearTimer: vi.fn(), + _waitForTerminalPaint: vi.fn(async () => {}), + batchTerminalWrite: vi.fn(), + terminal: { + write: vi.fn((_data: string, callback?: () => void) => callback?.()), + scrollToBottom: vi.fn(), + }, + }; + const request = { + id: 1, + sessionId: 'session-1', + reason: 'stalled-fetch', + resizeOptions: null, + settleMs: 0, + captureWhenUnchanged: false, + }; + + const reconcile = app._runTerminalFrameReconcile(request); + await vi.advanceTimersByTimeAsync(5000); + + await expect(reconcile).resolves.toBe(false); + expect(app._isLoadingBuffer).toBe(false); + expect(app._bufferLoadOwner).toBeNull(); + expect(app._terminalFrameReconcileAbortController).toBeNull(); + } finally { + vi.useRealTimers(); + } + }); +});