From 10540d515c84315837c90b500657a25124589004 Mon Sep 17 00:00:00 2001 From: lior Date: Sat, 25 Jul 2026 22:22:18 +0300 Subject: [PATCH 01/49] fix(input): stop adopting stale terminal text --- docs/local-echo-overlay-plan.md | 4 +- packages/xterm-zerolag-input/README.md | 6 +-- .../src/zerolag-input-addon.ts | 17 ------- .../test/zerolag-input-addon.test.ts | 46 ++++++++++--------- 4 files changed, 30 insertions(+), 43 deletions(-) diff --git a/docs/local-echo-overlay-plan.md b/docs/local-echo-overlay-plan.md index 8abc2c34..92971266 100644 --- a/docs/local-echo-overlay-plan.md +++ b/docs/local-echo-overlay-plan.md @@ -1,6 +1,8 @@ # Local Echo Overlay — Implementation Plan -> **Status: SHIPPED.** Implementation lives in `packages/xterm-zerolag-input/src/` (overlay-renderer.ts, prompt-finder.ts, cell-dimensions.ts, zerolag-input-addon.ts) with the embedded copy in `src/web/public/app.js`. This document is retained as historical design context. +> **Status: SHIPPED.** Source lives in `packages/xterm-zerolag-input/src/` and is bundled into the gitignored public vendor asset by the package/build scripts. This document is retained as historical design context. + +> **Current input rule:** ordinary typing and Backspace never infer editable text from the rendered terminal buffer. Consumers explicitly call `detectBufferText()` after Tab completion or `setFlushed()` when restoring known input. Full-screen TUIs can render status text after a prompt glyph, so implicit buffer adoption causes ghost deletion and stale text submission. ## Context diff --git a/packages/xterm-zerolag-input/README.md b/packages/xterm-zerolag-input/README.md index ee511c17..84d9ecc7 100644 --- a/packages/xterm-zerolag-input/README.md +++ b/packages/xterm-zerolag-input/README.md @@ -162,7 +162,7 @@ Implements xterm.js `ITerminalAddon`. The addon does **not** hook `terminal.onDa | Method | Returns | Description | |--------|---------|-------------| -| `addChar(char)` | `void` | Add a single printable character. Auto-detects existing buffer text on first keystroke. | +| `addChar(char)` | `void` | Add a single printable character. | | `appendText(text)` | `void` | Append multiple characters (paste). | | `removeChar()` | `'pending'` \| `'flushed'` \| `false` | Remove last char. See [backspace handling](#backspace-handling). | | `clear()` | `void` | Clear all state, hide overlay. Call on Enter/Ctrl+C/Escape. | @@ -177,7 +177,7 @@ Implements xterm.js `ITerminalAddon`. The addon does **not** hook `terminal.onDa | `'flushed'` | Text already sent to PTY | Send `\x7f` backspace to PTY | | `false` | Nothing to remove | Do nothing | -The cascade: pending text first, then flushed text, then auto-detect buffer text (handles tab completion). This means backspace "just works" through any combination of typed, flushed, and tab-completed text. +The cascade is pending text first, then explicitly tracked flushed text. The addon never infers editable input during ordinary typing or Backspace because full-screen terminal UIs can render status text after a prompt glyph. For Tab completion, call `detectBufferText()` after the completion response is rendered; for session restoration, call `setFlushed()`. ### Flushed Text @@ -195,7 +195,7 @@ Scan the terminal for text that exists after the prompt but wasn't typed through | Method | Description | |--------|-------------| -| `detectBufferText()` | Scan and return detected text (or `null`). Sets it as flushed. Guarded: runs once per `clear()` cycle. | +| `detectBufferText()` | Explicitly scan and return detected text (or `null`). Sets it as flushed. Guarded: runs once per `clear()` cycle. | | `resetBufferDetection()` | Re-enable detection. | | `suppressBufferDetection()` | Block detection until next `clear()`. Use for sessions with UI framework text after the prompt. | | `undoDetection()` | Undo last detection — clears flushed state, re-enables detection. For tab completion retry. | diff --git a/packages/xterm-zerolag-input/src/zerolag-input-addon.ts b/packages/xterm-zerolag-input/src/zerolag-input-addon.ts index 5e201ba4..7e7eb4f1 100644 --- a/packages/xterm-zerolag-input/src/zerolag-input-addon.ts +++ b/packages/xterm-zerolag-input/src/zerolag-input-addon.ts @@ -176,7 +176,6 @@ export class ZerolagInputAddon implements XtermAddon { * Call this when the user types a character (charCode >= 32, length === 1). */ addChar(char: string): void { - if (!this._pendingText && !this._flushedOffset) this._detectBufferText(); this._pendingText += char; this._render(); } @@ -186,7 +185,6 @@ export class ZerolagInputAddon implements XtermAddon { */ appendText(text: string): void { if (!text) return; - if (!this._pendingText && !this._flushedOffset) this._detectBufferText(); this._pendingText += text; this._render(); } @@ -197,7 +195,6 @@ export class ZerolagInputAddon implements XtermAddon { * Cascade order: * 1. Remove from `pendingText` if non-empty → returns `'pending'` * 2. Decrement `flushedOffset` if pending is empty but flushed exists → returns `'flushed'` - * 3. Try `detectBufferText()` if both are empty, then decrement → returns `'flushed'` * * @returns The source of the removed character, or `false` if nothing to remove. * @@ -229,20 +226,6 @@ export class ZerolagInputAddon implements XtermAddon { return 'flushed'; } - // Both empty — try detecting text already on the prompt line - // (handles tab completion, arrow-key edits, etc.) - this._detectBufferText(); - if (this._flushedOffset > 0) { - this._flushedOffset--; - this._flushedText = this._flushedText.slice(0, -1); - if (this._flushedOffset > 0) { - this._render(); - } else { - this._hide(); - } - return 'flushed'; - } - return false; } diff --git a/packages/xterm-zerolag-input/test/zerolag-input-addon.test.ts b/packages/xterm-zerolag-input/test/zerolag-input-addon.test.ts index 74063761..b4683d2f 100644 --- a/packages/xterm-zerolag-input/test/zerolag-input-addon.test.ts +++ b/packages/xterm-zerolag-input/test/zerolag-input-addon.test.ts @@ -122,14 +122,21 @@ describe('ZerolagInputAddon', () => { expect(addon.hasPending).toBe(false); }); - it('detects buffer text and removes from it when both empty', () => { + it('does not infer editable text from the terminal buffer', () => { const { addon } = tracked(['$ hello']); - // Both pending and flushed are empty, but buffer has text + const source = addon.removeChar(); - expect(source).toBe('flushed'); - // "hello" (5 chars) detected, then one removed = 4 - expect(addon.getFlushed().count).toBe(4); - expect(addon.getFlushed().text).toBe('hell'); + + expect(source).toBe(false); + expect(addon.getFlushed()).toEqual({ count: 0, text: '' }); + }); + + it('removes explicitly detected completion text as flushed input', () => { + const { addon } = tracked(['$ hello']); + expect(addon.detectBufferText()).toBe('hello'); + + expect(addon.removeChar()).toBe('flushed'); + expect(addon.getFlushed()).toEqual({ count: 4, text: 'hell' }); }); it('returns false on empty prompt with no buffer text', () => { @@ -295,22 +302,19 @@ describe('ZerolagInputAddon', () => { expect(addon.getFlushed().count).toBe(0); }); - it('suppressBufferDetection also blocks implicit detection in addChar', () => { + it('suppressBufferDetection keeps explicit detection disabled while typing', () => { const { addon } = tracked(['$ ink status bar']); addon.suppressBufferDetection(); - // addChar calls _detectBufferText internally on first keystroke addon.addChar('x'); - // Should have only 'x' pending, NOT the buffer text as flushed expect(addon.pendingText).toBe('x'); expect(addon.getFlushed().count).toBe(0); }); - it('suppressBufferDetection also blocks detection in removeChar cascade', () => { + it('suppressBufferDetection keeps explicit detection disabled before backspace', () => { const { addon } = tracked(['$ buffer text']); addon.suppressBufferDetection(); - // removeChar step 3 calls _detectBufferText — should be blocked expect(addon.removeChar()).toBe(false); expect(addon.getFlushed().count).toBe(0); }); @@ -705,23 +709,21 @@ describe('ZerolagInputAddon', () => { }); }); - describe('addChar implicit buffer detection', () => { - it('first keystroke detects existing buffer text as flushed', () => { + describe('typing with pre-existing buffer content', () => { + it('does not adopt existing buffer text on the first keystroke', () => { const { addon } = tracked(['$ existing']); - // First addChar should trigger _detectBufferText + addon.addChar('!'); expect(addon.pendingText).toBe('!'); - expect(addon.getFlushed().count).toBe(8); // 'existing' - expect(addon.getFlushed().text).toBe('existing'); + expect(addon.getFlushed()).toEqual({ count: 0, text: '' }); }); - it('second keystroke does NOT re-detect', () => { + it('does not adopt existing buffer text on appendText', () => { const { addon } = tracked(['$ existing']); - addon.addChar('a'); - addon.addChar('b'); - // Should NOT detect again — flushed from first char remains - expect(addon.pendingText).toBe('ab'); - expect(addon.getFlushed().count).toBe(8); // still 'existing' + + addon.appendText('pasted'); + expect(addon.pendingText).toBe('pasted'); + expect(addon.getFlushed()).toEqual({ count: 0, text: '' }); }); }); }); From dafad2b6cfc47c843314505a52dc87677b80dc9f Mon Sep 17 00:00:00 2001 From: lior Date: Sat, 25 Jul 2026 22:26:05 +0300 Subject: [PATCH 02/49] fix(terminal): drain deferred output without a wake event --- docs/terminal-anti-flicker.md | 80 ++++++++++++------------------ src/web/public/terminal-ui.js | 47 ++++++++---------- test/terminal-flush-budget.test.ts | 20 ++++++++ 3 files changed, 73 insertions(+), 74 deletions(-) diff --git a/docs/terminal-anti-flicker.md b/docs/terminal-anti-flicker.md index 82f45b1b..06b52fe3 100644 --- a/docs/terminal-anti-flicker.md +++ b/docs/terminal-anti-flicker.md @@ -8,24 +8,24 @@ Claude Code uses [Ink](https://github.com/vadimdemedes/ink) (React for terminals PTY Output → Server Batching → DEC 2026 Wrap → SSE → Client rAF → Sync Parser → xterm.js ``` -| Layer | Location | Technique | Latency | -|-------|----------|-----------|---------| -| **1. Server Batching** | `server.ts:batchTerminalData()` | Adaptive 16-50ms collection window | 16-50ms | -| **2. DEC Mode 2026** | `server.ts:flushTerminalBatches()` | Wraps with `\x1b[?2026h`...`\x1b[?2026l` | 0ms | -| **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 | +| Layer | Location | Technique | Latency | +| ------------------------ | ---------------------------------- | -------------------------------------------------- | -------- | +| **1. Server Batching** | `server.ts:batchTerminalData()` | Adaptive 16-50ms collection window | 16-50ms | +| **2. DEC Mode 2026** | `server.ts:flushTerminalBatches()` | Wraps with `\x1b[?2026h`...`\x1b[?2026l` | 0ms | +| **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 | ## Server-Side Implementation (`server.ts`) ### Constants ```typescript -const TERMINAL_BATCH_INTERVAL = 16; // Base: 60fps +const TERMINAL_BATCH_INTERVAL = 16; // Base: 60fps const BATCH_FLUSH_THRESHOLD = 32 * 1024; // Flush immediately if >32KB -const DEC_SYNC_START = '\x1b[?2026h'; // Begin synchronized update -const DEC_SYNC_END = '\x1b[?2026l'; // End synchronized update +const DEC_SYNC_START = '\x1b[?2026h'; // Begin synchronized update +const DEC_SYNC_END = '\x1b[?2026l'; // End synchronized update ``` ### Adaptive Batching (`batchTerminalData()`) @@ -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)` @@ -104,35 +88,33 @@ When detected, buffers 50ms of subsequent output before flushing atomically. ## Latency Analysis -| Source | Best Case | Worst Case | Notes | -|--------|-----------|------------|-------| -| Server batching | 0ms (flush) | 50ms (rapid events) | Immediate flush if >32KB | -| Sync block wait | 0ms | 50ms | Only if marker split across packets | -| Flicker filter | 0ms (disabled) | 50ms (enabled) | Optional per-session | -| rAF scheduling | 0ms | 16ms | Display refresh sync | -| **Total** | **0ms** | **~115ms** | Worst case rare in practice | +| Source | Best Case | Worst Case | Notes | +| --------------- | -------------- | ------------------- | ----------------------------------- | +| Server batching | 0ms (flush) | 50ms (rapid events) | Immediate flush if >32KB | +| Sync block wait | 0ms | 50ms | Only if marker split across packets | +| Flicker filter | 0ms (disabled) | 50ms (enabled) | Optional per-session | +| rAF scheduling | 0ms | 16ms | Display refresh sync | +| **Total** | **0ms** | **~115ms** | Worst case rare in practice | **Typical latency:** 16-32ms (server batch + rAF) ## 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 ## Files Involved -| File | Key Functions | -|------|---------------| -| `src/web/server.ts` | `batchTerminalData()`, `flushTerminalBatches()`, `broadcast()` | -| `src/web/public/app.js` | `batchTerminalWrite()`, `extractSyncSegments()`, `flushPendingWrites()`, `flushFlickerBuffer()`, `chunkedTerminalWrite()` | +| File | Key Functions | +| ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | +| `src/web/server.ts` | `batchTerminalData()`, `flushTerminalBatches()`, `broadcast()` | +| `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 f309a31b..fb666a97 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -259,7 +259,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 @@ -1942,17 +1942,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(); + }); }, /** @@ -1968,13 +1977,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(); }, /** @@ -2100,13 +2103,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 1af721ab..229cebff 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 0736e1ae2f06febce08c2f1163011e11a2d83e28 Mon Sep 17 00:00:00 2001 From: lior Date: Sat, 25 Jul 2026 22:33:06 +0300 Subject: [PATCH 03/49] fix(input): preserve rapid submission boundaries --- src/web/public/keyboard-accessory.js | 7 +++--- src/web/public/terminal-ui.js | 11 +++++----- src/web/public/voice-input.js | 2 +- test/mobile/keyboard.test.ts | 32 ++++++++++++++++++++++++++++ 4 files changed, 41 insertions(+), 11 deletions(-) diff --git a/src/web/public/keyboard-accessory.js b/src/web/public/keyboard-accessory.js index 467a92ae..3d2167f8 100644 --- a/src/web/public/keyboard-accessory.js +++ b/src/web/public/keyboard-accessory.js @@ -247,10 +247,9 @@ const KeyboardAccessoryBar = { * Sends text and Enter separately so Ink processes them as distinct events. */ sendCommand(command) { if (!app.activeSessionId) return; - // Send command text first (without Enter) + // The durable input queue preserves these as two ordered records. app.sendInput(command); - // Send Enter separately after a brief delay so Ink has time to process the text. - setTimeout(() => app.sendInput('\r'), 120); + app.sendInput('\r'); }, /** Send a special key (arrow, escape, etc.) directly to the PTY. @@ -304,7 +303,7 @@ const KeyboardAccessoryBar = { close(); if (text) { app.sendInput(text); - setTimeout(() => app.sendInput('\r'), 80); + app.sendInput('\r'); } }; diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index fb666a97..a6307608 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -766,7 +766,7 @@ Object.assign(CodemanApp.prototype, { return; } if (/^[\r\n]+$/.test(data)) { - // Enter: send full buffered text + \r to PTY in one shot + // Enter: queue the buffered text followed by \r. const text = this._localEchoOverlay?.pendingText || ''; this._localEchoOverlay?.clear(); // Suppress detection so PTY-echoed text isn't re-detected as user input @@ -782,11 +782,10 @@ Object.assign(CodemanApp.prototype, { this._pendingInput += text; flushInput(); } - // Send \r after a short delay so text arrives first - setTimeout(() => { - this._pendingInput += '\r'; - flushInput(); - }, 80); + // The durable input queue preserves call order. Queue Enter + // immediately so another fast submission cannot interleave before it. + this._pendingInput += '\r'; + flushInput(); return; } if (data.length > 1 && data.charCodeAt(0) >= 32) { diff --git a/src/web/public/voice-input.js b/src/web/public/voice-input.js index 4f79d038..9bfb6b67 100644 --- a/src/web/public/voice-input.js +++ b/src/web/public/voice-input.js @@ -610,7 +610,7 @@ const VoiceInput = { app._localEchoOverlay.clear(); app._localEchoOverlay.suppressBufferDetection(); if (text) app.sendInput(text).catch(() => {}); - setTimeout(() => app.sendInput('\r').catch(() => {}), 80); + app.sendInput('\r').catch(() => {}); } else { app.sendInput('\r').catch(() => {}); } diff --git a/test/mobile/keyboard.test.ts b/test/mobile/keyboard.test.ts index 5032f653..f1499f64 100644 --- a/test/mobile/keyboard.test.ts +++ b/test/mobile/keyboard.test.ts @@ -742,6 +742,38 @@ describe('Virtual Keyboard', () => { expect(afterEnter.sentInputs.join('')).toBe('find bug\r'); }); + it('keeps back-to-back local echo submissions in text-then-Enter order', async () => { + await page.evaluate(() => { + window.__sentInputs = []; + app.activeSessionId = 'mobile-rapid-submit-test'; + app.sessions.set('mobile-rapid-submit-test', { + id: 'mobile-rapid-submit-test', + mode: 'codex', + status: 'running', + }); + app.hideWelcome(); + app._sendInputAsync = (_sessionId: string, input: string) => { + window.__sentInputs.push(input); + }; + const settings = app.loadAppSettingsFromStorage(); + settings.cjkInputEnabled = false; + settings.localEchoEnabled = true; + app.saveAppSettingsToStorage(settings); + app._updateCjkInputState(); + app._updateLocalEchoState(); + app.terminal.focus(); + }); + + await page.keyboard.type('first'); + await page.keyboard.press('Enter'); + await page.keyboard.type('second'); + await page.keyboard.press('Enter'); + + await expect + .poll(() => page.evaluate(() => window.__sentInputs)) + .toEqual(['first', '\r', 'second', '\r']); + }); + it('shows terminal local echo at the cursor when no prompt marker is visible', async () => { await page.evaluate(async () => { app.activeSessionId = 'mobile-cursor-fallback-test'; From c6afa9c0d5f82cac089466cc4bb860259d592a66 Mon Sep 17 00:00:00 2001 From: lior Date: Sat, 25 Jul 2026 22:41:02 +0300 Subject: [PATCH 04/49] fix(mobile): keep keyboard focus taps non-activating --- src/web/public/terminal-ui.js | 31 +++++++++++++++++++++++++++++-- test/mobile/keyboard.test.ts | 12 ++++++++++-- test/terminal-touch-tap.test.ts | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 4 deletions(-) diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index a6307608..7c3068c5 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -432,6 +432,7 @@ Object.assign(CodemanApp.prototype, { let didScroll = false; // track whether touchmove fired (tap vs scroll) let touchStartY = 0; + let tapCanActivateTerminal = false; const TAP_THRESHOLD = 8; // px — ignore micro-drift to distinguish tap from scroll container.addEventListener( 'touchstart', @@ -443,6 +444,7 @@ Object.assign(CodemanApp.prototype, { pixelAccum = 0; isTouching = true; didScroll = false; + tapCanActivateTerminal = this._shouldForwardMobileTapToApp(); lastTime = 0; if (scrollFrame) { cancelAnimationFrame(scrollFrame); @@ -510,9 +512,13 @@ Object.assign(CodemanApp.prototype, { if (touch) { this._suppressTrustedTapMouseEvents(); } - if (touch && mouseTrackingOn) { + if (touch && tapCanActivateTerminal && mouseTrackingOn) { this._dispatchSyntheticTerminalClick(touch.clientX, touch.clientY); - } else if (touch && this._sessionUsesServerMouseStrip()) { + } else if ( + touch && + tapCanActivateTerminal && + this._sessionUsesServerMouseStrip() + ) { // The server strips mouse-tracking DECSETs from claude/codex/gemini // output (isAltScreenStripMode, session.ts) so the wheel keeps // scrolling scrollback — which leaves THIS xterm permanently at @@ -532,6 +538,7 @@ Object.assign(CodemanApp.prototype, { this.terminal.focus(); } } + tapCanActivateTerminal = false; }, { passive: true } ); @@ -2493,6 +2500,26 @@ Object.assign(CodemanApp.prototype, { } catch {} }, + /** + * A tap that opens the phone keyboard is focus-only. Forwarding that same + * gesture as a mouse click would activate the highlighted CLI menu option. + * Once the keyboard and terminal input were already active at touchstart, + * later taps may intentionally position the cursor or select a TUI row. + */ + _shouldForwardMobileTapToApp() { + const keyboardVisible = + (typeof KeyboardHandler !== 'undefined' && KeyboardHandler.keyboardVisible) || + document.body?.classList?.contains('keyboard-visible'); + if (!keyboardVisible) return false; + + const active = document.activeElement; + return ( + active === this.terminal?.textarea || + active?.classList?.contains('xterm-helper-textarea') || + active?.id === 'cjkInput' + ); + }, + // ═══════════════════════════════════════════════════════════════ // Synthetic tap → mouse report // ═══════════════════════════════════════════════════════════════ diff --git a/test/mobile/keyboard.test.ts b/test/mobile/keyboard.test.ts index f1499f64..3d23e37b 100644 --- a/test/mobile/keyboard.test.ts +++ b/test/mobile/keyboard.test.ts @@ -607,12 +607,16 @@ describe('Virtual Keyboard', () => { it('focuses the terminal helper textarea when the terminal is tapped', async () => { await page.evaluate(() => { + window.__sentInputs = []; app.activeSessionId = 'mobile-focus-visible-input-test'; app.sessions.set('mobile-focus-visible-input-test', { id: 'mobile-focus-visible-input-test', mode: 'codex', status: 'running', }); + app._sendInputAsync = (_sessionId: string, input: string) => { + window.__sentInputs.push(input); + }; app.hideWelcome(); const settings = app.loadAppSettingsFromStorage(); settings.cjkInputEnabled = false; @@ -622,8 +626,12 @@ describe('Virtual Keyboard', () => { await page.locator('#terminalContainer').tap({ position: { x: 40, y: 40 } }); - const activeClass = await page.evaluate(() => document.activeElement?.className); - expect(activeClass).toContain('xterm-helper-textarea'); + const state = await page.evaluate(() => ({ + activeClass: document.activeElement?.className, + sentInputs: window.__sentInputs, + })); + expect(state.activeClass).toContain('xterm-helper-textarea'); + expect(state.sentInputs).toEqual([]); }); it('keeps terminal touch drag available for scrollback with the visible textarea enabled', async () => { diff --git a/test/terminal-touch-tap.test.ts b/test/terminal-touch-tap.test.ts index 7c4ad557..903b9a60 100644 --- a/test/terminal-touch-tap.test.ts +++ b/test/terminal-touch-tap.test.ts @@ -6,8 +6,16 @@ import { describe, expect, it, vi } from 'vitest'; function loadTerminalUiHarness() { const CodemanApp = function CodemanApp(this: any) {}; let now = 1_000; + let keyboardVisible = false; + let activeElement: unknown = null; const context = vm.createContext({ window: {}, + document: { + body: { classList: { contains: () => false } }, + get activeElement() { + return activeElement; + }, + }, CodemanApp, console: { warn: vi.fn(), log: vi.fn() }, _crashDiag: { log: vi.fn() }, @@ -25,6 +33,11 @@ function loadTerminalUiHarness() { MobileDetection: { isTouchDevice: () => true, }, + KeyboardHandler: { + get keyboardVisible() { + return keyboardVisible; + }, + }, DEC_SYNC_STRIP_RE: /\x1b\[\?2026[hl]/g, TERMINAL_CHUNK_SIZE: 32 * 1024, }); @@ -38,6 +51,12 @@ function loadTerminalUiHarness() { setNow: (value: number) => { now = value; }, + setKeyboardVisible: (visible: boolean) => { + keyboardVisible = visible; + }, + setActiveElement: (element: unknown) => { + activeElement = element; + }, }; } @@ -56,6 +75,19 @@ function createElementHarness() { } describe('terminal touch tap mouse guard', () => { + it('keeps the keyboard-opening tap focus-only', () => { + const { app, setActiveElement, setKeyboardVisible } = loadTerminalUiHarness(); + const textarea = { classList: { contains: () => true } }; + app.terminal = { textarea }; + setActiveElement(textarea); + + setKeyboardVisible(false); + expect(app._shouldForwardMobileTapToApp()).toBe(false); + + setKeyboardVisible(true); + expect(app._shouldForwardMobileTapToApp()).toBe(true); + }); + it('suppresses browser trusted compatibility mouse events during the tap window', () => { const { app } = loadTerminalUiHarness(); const { element, dispatch } = createElementHarness(); From 98136e59e70522e0b5ba47d5062bb37990f7f96e Mon Sep 17 00:00:00 2001 From: lior Date: Sat, 25 Jul 2026 23:15:15 +0300 Subject: [PATCH 05/49] feat(mobile): unify terminal navigation controls - Replace the split extended-keyboard option with responsive Esc, arrows, Enter, and Tab controls for keyboard-hidden and keyboard-open layouts. - Add swipe navigation, Up+Down Enter chords, best-effort volume-key input, and optional haptic/sound feedback. - Keep the accessory layout keyboard-safe, phone-width contained, and hidden behind application modals. Verified: 21 unit tests, 64 mobile browser tests, public asset/syntax checks, and npm run build. --- src/web/public/app.js | 12 +- src/web/public/i18n.js | 14 +- src/web/public/index.html | 42 +- src/web/public/keyboard-accessory.js | 683 +++++++++++++++++++++++---- src/web/public/mobile-handlers.js | 84 +++- src/web/public/mobile.css | 163 ++++++- src/web/public/settings-ui.js | 38 +- src/web/public/styles.css | 74 ++- src/web/public/terminal-ui.js | 10 + test/mobile-navigation-pad.test.ts | 379 +++++++++++++++ test/mobile/helpers/constants.ts | 3 + test/mobile/keyboard.test.ts | 183 ++++++- test/mobile/navigation-pad.test.ts | 517 ++++++++++++++++++++ test/mobile/settings.test.ts | 58 ++- 14 files changed, 2056 insertions(+), 204 deletions(-) create mode 100644 test/mobile-navigation-pad.test.ts create mode 100644 test/mobile/navigation-pad.test.ts diff --git a/src/web/public/app.js b/src/web/public/app.js index 314c08cb..1b1c6472 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -41,7 +41,7 @@ * @dependency mobile-handlers.js (MobileDetection, KeyboardHandler, SwipeHandler) * @dependency voice-input.js (VoiceInput, DeepgramProvider) * @dependency notification-manager.js (NotificationManager class) - * @dependency keyboard-accessory.js (KeyboardAccessoryBar, FocusTrap) + * @dependency keyboard-accessory.js (MobileTerminalControls, FocusTrap) * @dependency vendor/xterm.js, vendor/xterm-addon-fit.js, vendor/xterm-addon-webgl.js * @dependency vendor/xterm-zerolag-input.iife.js (LocalEchoOverlay) * @loadorder 6 of 15 — loaded after keyboard-accessory.js, before terminal-ui.js @@ -787,10 +787,14 @@ class CodemanApp { KeyboardHandler.init(); SwipeHandler.init(); VoiceInput.init(); - KeyboardAccessoryBar.init(); - // Apply keyboard bar mode from settings + // One setting controls both mobile terminal-control surfaces: the + // keyboard-hidden menu pad and the keyboard-open accessory bar. const _kbSettings = this.loadAppSettingsFromStorage(); - if (_kbSettings.extendedKeyboardBar) KeyboardAccessoryBar.setMode('extended'); + const _kbDefaults = this.getDefaultSettings(); + MobileTerminalControls.configureFeedback(_kbSettings, _kbDefaults); + MobileTerminalControls.init( + MobileTerminalControls.resolveEnabled(_kbSettings, _kbDefaults) + ); this.applyHeaderVisibilitySettings(); this.restorePlanUsageChip(); this.applySkin(); diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index 4ea11c50..0b35ef8e 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -281,12 +281,16 @@ Input: '输入', 'Local Echo': '本地回显', 'CJK Input': '中日韩输入', - 'Extended Keyboard Bar': '扩展键盘栏', + 'Mobile Terminal Controls': '移动终端控制', + 'Control Haptics': '控制振动', + 'Control Sounds': '控制音效', 'Gesture Control (beta)': '手势控制(测试版)', 'Wheel Scrolls Local History': '滚轮滚动本地历史', 'Instant typing feedback with local echo': '通过本地回显即时显示输入', 'Dedicated IME input field for CJK languages': '为中日韩语言提供专用输入法文本框', - 'Extra keys: Tab, Esc, arrows, Ctrl+O': '附加按键:Tab、Esc、方向键、Ctrl+O', + 'Esc, menu navigation, Tab, and keyboard-open shortcuts': 'Esc、菜单导航、Tab 和键盘打开时的快捷键', + 'Brief vibration on terminal controls': '操作终端控件时短暂振动', + 'Quiet tone on terminal controls': '操作终端控件时播放轻提示音', // CLI / model settings 'Startup Mode': '启动模式', @@ -665,8 +669,10 @@ '通过覆盖层即时显示输入,同时在后台把按键转发到服务器。支持 Tab 补全、切换标签时保留输入并防止会话崩溃丢字;推荐移动端和高延迟连接使用。', "Show a dedicated input field below the terminal for CJK (Chinese/Japanese/Korean) IME composition. Recommended for mobile devices with Chinese input methods where xterm's native input handling may drop characters.": '在终端下方显示中日韩输入法专用文本框。推荐在可能因 xterm 原生输入而丢字的移动端中文输入法中使用。', - 'Show additional buttons (Tab, Shift+Tab, Ctrl+O, Esc, Alt+Enter, left/right arrows) in the mobile keyboard accessory bar.': - '在移动端键盘工具栏显示附加按键(Tab、Shift+Tab、Ctrl+O、Esc、Alt+Enter、左右方向键)。', + 'Show Esc, Up, Enter, Down, and Tab controls while the mobile keyboard is hidden, plus the full terminal key bar while it is open. Hardware volume keys are used when the browser exposes them; press both directions together for Enter.': + '移动键盘隐藏时显示 Esc、上、Enter、下和 Tab 控件,键盘打开时显示完整终端按键栏。浏览器支持时可使用硬件音量键;同时按下两个方向键可发送 Enter。', + 'Vibrate briefly after a mobile terminal control is accepted.': '移动终端控件生效后短暂振动。', + 'Play a short, quiet tone after a mobile terminal control is accepted.': '移动终端控件生效后播放短促轻柔的提示音。', 'Scroll local history (when mouse passthrough is active)': '滚动本地历史(鼠标直通启用时)', 'Plain wheel/trackpad pages the terminal scrollback': '使用普通滚轮/触控板翻阅终端历史', 'Camera hand-tracking overlay (applied on reload)': '摄像头手势跟踪覆盖层(重新加载后生效)', diff --git a/src/web/public/index.html b/src/web/public/index.html index b51c96d1..13b444c9 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -6,7 +6,7 @@ served at /session/:id (detached single-session window) without 404ing on relative + diff --git a/src/web/public/input-cjk.js b/src/web/public/input-cjk.js index f35af9bd..616118a2 100644 --- a/src/web/public/input-cjk.js +++ b/src/web/public/input-cjk.js @@ -43,7 +43,7 @@ * * @dependency index.html (#cjkInput textarea) * @globals {object} CjkInput — window.cjkActive (boolean) signals app.js to block xterm onData - * @loadorder 5.5 of 15 — loaded after keyboard-accessory.js, before app.js + * @loadorder 5.5 of 16 — loaded after keyboard-accessory.js, before terminal-input-state.js */ // eslint-disable-next-line no-unused-vars @@ -51,6 +51,7 @@ const CjkInput = (() => { let _textarea = null; let _send = null; let _paste = null; + let _draftChanged = null; let _initialized = false; let _composing = false; let _flushTimer = null; @@ -139,6 +140,7 @@ const CjkInput = (() => { } _textarea.value = PHANTOM; _textarea.setSelectionRange(1, 1); + _draftChanged?.(); } function _isEffectivelyEmpty() { @@ -197,11 +199,12 @@ const CjkInput = (() => { } return { - init({ send, paste }) { + init({ send, paste, draftChanged }) { if (_initialized) this.destroy(); _send = send; _paste = typeof paste === 'function' ? paste : send; + _draftChanged = typeof draftChanged === 'function' ? draftChanged : null; _composing = false; _flushTimer = null; _textarea = document.getElementById('cjkInput'); @@ -370,6 +373,10 @@ const CjkInput = (() => { // ── Input event: primary path for virtual keyboards + dictation ── _listeners.input = (e) => { + // The DOM value has already changed by the time this event runs. Capture + // it before an immediate flush/reset so a lifecycle event in this turn + // cannot lose an unfinished IME or dictation update. + _draftChanged?.(); _t(`input ${e.inputType || '?'} ic=${e.isComposing} c=${_composing} ${_vdesc(_textarea.value)}`); // ── Stuck-composition recovery ── // Some IMEs (WeChat/Sogou keyboards) fire compositionstart without a @@ -467,6 +474,31 @@ const CjkInput = (() => { _resetToPhantom(); }, + /** Real editable text currently held behind the phantom character. */ + getPendingText() { + return _initialized && _textarea ? _strip(_textarea.value) : ''; + }, + + /** + * Restore committed draft text without trying to recreate an OS-level IME + * composition. The next input/Enter event treats it as ordinary text. + */ + restorePendingText(text) { + if (!_initialized || !_textarea) return; + _cancelDebouncedFlush(); + clearTimeout(_compositionFlushTimer); + _compositionFlushTimer = null; + _composing = false; + const restored = String(text || ''); + if (!restored) { + _resetToPhantom(); + return; + } + _textarea.value = PHANTOM + restored; + const end = _textarea.value.length; + _textarea.setSelectionRange(end, end); + }, + /** Diagnostic: recent IME event trace (ring buffer). */ getTrace() { return _trace.slice(); @@ -487,6 +519,7 @@ const CjkInput = (() => { _composing = false; _send = null; _paste = null; + _draftChanged = null; for (const key of Object.keys(_listeners)) delete _listeners[key]; _initialized = false; }, diff --git a/src/web/public/keyboard-accessory.js b/src/web/public/keyboard-accessory.js index c4b974fa..c8a44ead 100644 --- a/src/web/public/keyboard-accessory.js +++ b/src/web/public/keyboard-accessory.js @@ -16,8 +16,9 @@ * Only initializes on touch devices (MobileDetection.isTouchDevice guard). * * - MobileNavigationPad (singleton object) — Keyboard-hidden Esc/Up/Enter/Down/Tab controls - * for terminal menus. A simultaneous Up+Down press emits Enter, and vertical swipes on - * the surrounding bar emit arrow keys without focusing xterm or opening the keyboard. + * for terminal menus plus a contextual jump-to-latest action. A simultaneous Up+Down + * press emits Enter, and vertical swipes on the surrounding bar emit arrow keys without + * focusing xterm or opening the keyboard. * * - FocusTrap (class) — Traps Tab/Shift+Tab keyboard focus within a modal element. * Saves and restores previously focused element on deactivate. Used by Ralph wizard @@ -358,6 +359,16 @@ const MobileNavigationPad = { _swipeTime: 600, _buttons: ` + '; + body.appendChild(reply); + document.body.appendChild(body); + + const copied: string[] = []; + const originalCopy = app._copyText; + const originalToast = app.showToast; + const originalFeedback = MobileTerminalControls.feedback; + app._copyText = async (text: string) => { + copied.push(text); + return true; + }; + app.showToast = () => {}; + MobileTerminalControls.feedback = () => {}; + app._bindResponseViewerInteractions(body); + + const replyText = reply.querySelector('.rv-text')!; + const replyDispatchResult = replyText.dispatchEvent( + new MouseEvent('dblclick', { + bubbles: true, + cancelable: true, + detail: 2, + }) + ); + await Promise.resolve(); + + reply.querySelector('button')!.dispatchEvent( + new MouseEvent('dblclick', { + bubbles: true, + cancelable: true, + detail: 2, + }) + ); + await Promise.resolve(); + + const replyFeedback = reply.classList.contains('rv-copy-feedback'); + const touchAction = getComputedStyle(reply).touchAction; + if (reply._rvCopyFeedbackTimer) clearTimeout(reply._rvCopyFeedbackTimer); + + body.innerHTML = '

Rendered last response

'; + body._codemanCopyText = '# Raw last response'; + body.querySelector('p')!.dispatchEvent( + new MouseEvent('dblclick', { + bubbles: true, + cancelable: true, + detail: 2, + }) + ); + await Promise.resolve(); + + if (body._rvCopyFeedbackTimer) clearTimeout(body._rvCopyFeedbackTimer); + app._copyText = originalCopy; + app.showToast = originalToast; + MobileTerminalControls.feedback = originalFeedback; + body.remove(); + + return { + copied, + replyDefaultPrevented: !replyDispatchResult, + replyFeedback, + touchAction, + }; + }); + + expect(state).toEqual({ + copied: ['**Raw reply**\n\n```js\nconst answer = 42;\n```', '# Raw last response'], + replyDefaultPrevented: true, + replyFeedback: true, + touchAction: 'manipulation', + }); + }); + + it('copies a whole response chunk from two real mobile touch taps', async () => { + await page.evaluate(() => { + const testWindow = window as typeof window & { + __rvDoubleTapCopyTest?: { + copied: string[]; + originalCopy: typeof app._copyText; + originalToast: typeof app.showToast; + originalFeedback: typeof MobileTerminalControls.feedback; + }; + }; + const body = document.createElement('div'); + body.id = 'rv-double-tap-copy-test'; + body.className = 'response-viewer-body'; + body.style.cssText = + 'position:fixed;inset:16px auto auto 16px;width:280px;height:120px;z-index:2147483647;background:#111'; + + const reply = document.createElement('div') as HTMLDivElement & { + _codemanCopyText?: string; + }; + reply.className = 'rv-message rv-msg-assistant'; + reply._codemanCopyText = 'Raw touch reply'; + reply.innerHTML = '
Rendered touch reply
'; + body.appendChild(reply); + document.body.appendChild(body); + + testWindow.__rvDoubleTapCopyTest = { + copied: [], + originalCopy: app._copyText, + originalToast: app.showToast, + originalFeedback: MobileTerminalControls.feedback, + }; + app._copyText = async (text: string) => { + testWindow.__rvDoubleTapCopyTest!.copied.push(text); + return true; + }; + app.showToast = () => {}; + MobileTerminalControls.feedback = () => {}; + app._bindResponseViewerInteractions(body); + }); + + try { + const replyText = page.locator('#rv-double-tap-copy-test .rv-text'); + await replyText.tap(); + await page.waitForTimeout(80); + await replyText.tap(); + await page.waitForTimeout(50); + + const copied = await page.evaluate(() => { + const testWindow = window as typeof window & { + __rvDoubleTapCopyTest?: { copied: string[] }; + }; + return testWindow.__rvDoubleTapCopyTest?.copied ?? []; + }); + expect(copied).toEqual(['Raw touch reply']); + } finally { + await page.evaluate(() => { + const testWindow = window as typeof window & { + __rvDoubleTapCopyTest?: { + originalCopy: typeof app._copyText; + originalToast: typeof app.showToast; + originalFeedback: typeof MobileTerminalControls.feedback; + }; + }; + const state = testWindow.__rvDoubleTapCopyTest; + if (state) { + app._copyText = state.originalCopy; + app.showToast = state.originalToast; + MobileTerminalControls.feedback = state.originalFeedback; + } + document.getElementById('rv-double-tap-copy-test')?.remove(); + delete testWindow.__rvDoubleTapCopyTest; + }); + } + }); + it('clears main padding when the phone keyboard opens', async () => { const initialPadding = await page.evaluate(() => { const main = document.querySelector('.main') as HTMLElement | null; @@ -326,6 +490,36 @@ describe('Virtual Keyboard', () => { expect(newPx).toBe(0); }); + it('locks the handheld app as soon as terminal focus requests the keyboard', async () => { + const state = await page.evaluate(() => { + const focusTarget = document.createElement('button'); + document.body.appendChild(focusTarget); + focusTarget.focus(); + KeyboardHandler.keyboardVisible = false; + KeyboardHandler._terminalInputRequested = false; + document.body.classList.remove('keyboard-visible', 'keyboard-opening'); + + app.terminal.focus(); + + const appElement = document.querySelector('.app'); + const result = { + terminalRequested: KeyboardHandler._terminalInputRequested, + openingClass: document.body.classList.contains('keyboard-opening'), + appPosition: appElement ? getComputedStyle(appElement).position : '', + }; + clearTimeout(KeyboardHandler._keyboardOpeningTimer); + KeyboardHandler._keyboardOpeningTimer = null; + focusTarget.remove(); + return result; + }); + + expect(state).toEqual({ + terminalRequested: true, + openingClass: true, + appPosition: 'fixed', + }); + }); + it('marks keyboard-driven resizing as active viewport control', async () => { const call = await page.evaluate(`(async function() { var originalSendResize = app.sendResize; @@ -401,14 +595,59 @@ describe('Virtual Keyboard', () => { expect(mainPadding).toBe(''); }); + it('reveals and focuses the live terminal input as soon as its keyboard opens', async () => { + const state = await page.evaluate(() => { + const originalScrollToBottom = app.terminal.scrollToBottom.bind(app.terminal); + const originalFocusInput = app._focusMobileTerminalInput.bind(app); + let bottomRestores = 0; + let inputFocusRestores = 0; + app.terminal.scrollToBottom = () => { + bottomRestores++; + }; + app._focusMobileTerminalInput = () => { + inputFocusRestores++; + }; + app._terminalScrollLocked = true; + app._wasAtBottomBeforeWrite = false; + KeyboardHandler.keyboardVisible = true; + KeyboardHandler._terminalInputRequested = true; + document.body.classList.add('keyboard-visible'); + + KeyboardHandler.onKeyboardShow(); + const immediate = { + bottomRestores, + inputFocusRestores, + scrollLocked: app._terminalScrollLocked, + followsBottom: app._wasAtBottomBeforeWrite, + }; + + clearTimeout(KeyboardHandler._viewportSettleTimer); + KeyboardHandler._viewportSettleTimer = null; + KeyboardHandler._settleScrollToBottom = false; + KeyboardHandler._settleFocusInput = false; + app.terminal.scrollToBottom = originalScrollToBottom; + app._focusMobileTerminalInput = originalFocusInput; + return immediate; + }); + + expect(state).toEqual({ + bottomRestores: 1, + inputFocusRestores: 1, + scrollLocked: false, + followsBottom: true, + }); + }); + 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); + const originalFocusInput = app._focusMobileTerminalInput.bind(app); let fits = 0; let resizes = 0; let bottomRestores = 0; + let inputFocusRestores = 0; app.fitAddon.fit = () => { fits++; }; @@ -418,25 +657,89 @@ describe('Virtual Keyboard', () => { app.terminal.scrollToBottom = () => { bottomRestores++; }; + app._focusMobileTerminalInput = () => { + inputFocusRestores++; + }; + app._terminalScrollLocked = true; + app._wasAtBottomBeforeWrite = false; + KeyboardHandler.keyboardVisible = true; + document.body.classList.add('keyboard-visible'); - KeyboardHandler._scheduleViewportSettle({ scrollToBottom: true }); + KeyboardHandler._scheduleViewportSettle({ scrollToBottom: true, focusInput: 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 }; + const beforeFinalSettle = { fits, resizes, bottomRestores, inputFocusRestores }; await new Promise((resolve) => setTimeout(resolve, KeyboardHandler.VIEWPORT_SETTLE_MS)); - const afterFinalSettle = { fits, resizes, bottomRestores }; + const afterFinalSettle = { + fits, + resizes, + bottomRestores, + inputFocusRestores, + scrollLocked: app._terminalScrollLocked, + followsBottom: app._wasAtBottomBeforeWrite, + }; app.fitAddon.fit = originalFit; KeyboardHandler._sendTerminalResize = originalSendResize; app.terminal.scrollToBottom = originalScrollToBottom; + app._focusMobileTerminalInput = originalFocusInput; return { beforeFinalSettle, afterFinalSettle }; }); - expect(result.beforeFinalSettle).toEqual({ fits: 0, resizes: 0, bottomRestores: 0 }); - expect(result.afterFinalSettle).toEqual({ fits: 1, resizes: 1, bottomRestores: 1 }); + expect(result.beforeFinalSettle).toEqual({ + fits: 0, + resizes: 0, + bottomRestores: 0, + inputFocusRestores: 0, + }); + expect(result.afterFinalSettle).toEqual({ + fits: 1, + resizes: 1, + bottomRestores: 1, + inputFocusRestores: 1, + scrollLocked: false, + followsBottom: true, + }); + }); + + it('does not steal focus or resize the PTY when the keyboard belongs to a form field', async () => { + const state = await page.evaluate(async () => { + const originalFocusInput = app._focusMobileTerminalInput.bind(app); + const originalScrollToBottom = app.terminal.scrollToBottom.bind(app.terminal); + const originalSendResize = KeyboardHandler._sendTerminalResize.bind(KeyboardHandler); + let inputFocusRestores = 0; + let bottomRestores = 0; + let resizes = 0; + app._focusMobileTerminalInput = () => { + inputFocusRestores++; + }; + app.terminal.scrollToBottom = () => { + bottomRestores++; + }; + KeyboardHandler._sendTerminalResize = () => { + resizes++; + }; + KeyboardHandler.keyboardVisible = true; + KeyboardHandler._terminalInputRequested = false; + document.body.classList.add('keyboard-visible'); + + KeyboardHandler.onKeyboardShow(); + await new Promise((resolve) => setTimeout(resolve, KeyboardHandler.VIEWPORT_SETTLE_MS + 20)); + + app._focusMobileTerminalInput = originalFocusInput; + app.terminal.scrollToBottom = originalScrollToBottom; + KeyboardHandler._sendTerminalResize = originalSendResize; + return { inputFocusRestores, bottomRestores, resizes }; + }); + + expect(state).toEqual({ + inputFocusRestores: 0, + bottomRestores: 0, + resizes: 0, + }); }); it('accessory bar has the unified terminal-control action set', async () => { @@ -550,14 +853,16 @@ describe('Virtual Keyboard', () => { expect(activeTag).toBe('BODY'); }); - it('terminal fit called on keyboard toggle', async () => { + it('keeps keyboard fits inside the coalesced keyboard settle', async () => { // Inject spy on fitAddon.fit await page.evaluate(` if (typeof app !== 'undefined' && app.fitAddon) { window.__fitCallCount = 0; + window.__fitCallStacks = []; var orig = app.fitAddon.fit; app.fitAddon.fit = function () { window.__fitCallCount++; + window.__fitCallStacks.push(String(new Error().stack || '')); try { orig.call(this); } catch(e) {} }; } @@ -567,9 +872,14 @@ describe('Virtual Keyboard', () => { // Wait for the setTimeout(150) in onKeyboardShow await page.waitForTimeout(300); - const callCount = await page.evaluate(() => (window as any).__fitCallCount ?? 0); - // Soft assertion — fitAddon may not be initialized without real terminal - expect(callCount).toBeGreaterThanOrEqual(0); + const calls = await page.evaluate(() => ({ + count: (window as any).__fitCallCount ?? 0, + stacks: (window as any).__fitCallStacks ?? [], + })); + // One settled fit plus, when needed, one synchronous row-gap correction. + expect(calls.count).toBeGreaterThanOrEqual(1); + expect(calls.count).toBeLessThanOrEqual(2); + expect(calls.stacks.every((stack: string) => stack.includes('mobile-handlers.js'))).toBe(true); }); it('keeps xterm helper textarea focusable near the terminal cursor on touch devices', async () => { @@ -990,45 +1300,50 @@ describe('Virtual Keyboard', () => { expect(calls.some((lines) => lines !== 0)).toBe(true); }); - it('routes Claude touch drags to its transcript without moving local xterm history', async () => { + it('keeps a focused draft visible while a keyboard-open transcript drag reads history', async () => { const result = await page.evaluate(async () => { - app.activeSessionId = 'mobile-claude-scroll-test'; - app.sessions.set('mobile-claude-scroll-test', { - id: 'mobile-claude-scroll-test', - mode: 'claude', - cliVersion: '2.1.220', + app.activeSessionId = 'mobile-focused-draft-scroll-test'; + app.sessions.set('mobile-focused-draft-scroll-test', { + id: 'mobile-focused-draft-scroll-test', + mode: 'codex', status: 'running', }); app.hideWelcome(); - - const sgrCalls: number[] = []; - const localCalls: number[] = []; - app._sendSyntheticSgrWheel = (_x: number, _y: number, lines: number) => { - sgrCalls.push(lines); - }; - app.terminal.scrollLines = (lines: number) => { - localCalls.push(lines); - }; + const settings = app.loadAppSettingsFromStorage(); + settings.cjkInputEnabled = false; + settings.localEchoEnabled = true; + app.saveAppSettingsToStorage(settings); + app._updateCjkInputState(); + app._updateLocalEchoState(); + app.terminal.reset(); + const history = Array.from( + { length: app.terminal.rows + 20 }, + (_, index) => `conversation line ${index + 1}` + ).join('\r\n'); + await new Promise((resolve) => app.terminal.write(`${history}\r\n› `, resolve)); + app.terminal.focus(); + app._localEchoOverlay.appendText('draft remains visible'); + KeyboardHandler.keyboardVisible = true; + KeyboardHandler._terminalInputRequested = true; + document.body.classList.add('keyboard-visible'); const target = document.querySelector('#terminalContainer .xterm-screen') ?? document.getElementById('terminalContainer'); - if (!target) return { sgrCalls, localCalls }; + if (!target) return null; const rect = target.getBoundingClientRect(); const x = rect.left + rect.width / 2; - const startY = rect.top + Math.min(100, rect.height - 20); - const endY = startY + 100; + const startY = rect.top + Math.min(80, rect.height / 3); + const endY = Math.min(rect.bottom - 10, startY + 120); - function createTouch(y: number) { - return new Touch({ - identifier: 2, + const createTouch = (y: number) => + new Touch({ + identifier: 8, target, clientX: x, clientY: y, pageX: x, pageY: y, }); - } - target.dispatchEvent( new TouchEvent('touchstart', { touches: [createTouch(startY)], @@ -1054,120 +1369,819 @@ describe('Virtual Keyboard', () => { }) ); await new Promise((resolve) => setTimeout(resolve, 50)); - return { sgrCalls, localCalls }; + + return { + keyboardVisible: KeyboardHandler.keyboardVisible, + terminalFocused: document.activeElement === app.terminal.textarea, + viewportY: app.terminal.buffer.active.viewportY, + baseY: app.terminal.buffer.active.baseY, + pendingText: app._localEchoOverlay.pendingText, + draftVisible: app._localEchoOverlay.state.visible, + }; }); - expect(result.sgrCalls.some((lines) => lines < 0)).toBe(true); - expect(result.localCalls).toEqual([]); + expect(result).toEqual( + expect.objectContaining({ + keyboardVisible: true, + terminalFocused: true, + pendingText: 'draft remains visible', + draftVisible: true, + }) + ); + expect(result!.viewportY).toBeLessThan(result!.baseY); }); - it('keeps typed phone text in the terminal local echo path', async () => { - await page.evaluate(() => { - window.__sentInputs = []; - app.activeSessionId = 'mobile-visible-input-test'; - app.sessions.set('mobile-visible-input-test', { - id: 'mobile-visible-input-test', + it('keeps the previous terminal frame over a keyboard refit until nonblank output renders', async () => { + const state = await page.evaluate(async () => { + app.activeSessionId = 'mobile-keyboard-frame-cover-test'; + app.sessions.set('mobile-keyboard-frame-cover-test', { + id: 'mobile-keyboard-frame-cover-test', mode: 'codex', status: 'running', }); app.hideWelcome(); - app._sendInputAsync = (_sessionId: string, input: string) => { - window.__sentInputs.push(input); + app.terminal.reset(); + await new Promise((resolve) => app.terminal.write('frame before keyboard\r\n› ', resolve)); + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))); + + KeyboardHandler._beginTerminalFrameCover(); + KeyboardHandler._armTerminalFrameCover(); + const cover = app.terminal.element?.querySelector('.terminal-resize-frame-cover'); + const before = { + exists: Boolean(cover), + text: cover?.textContent || '', }; - const settings = app.loadAppSettingsFromStorage(); - settings.cjkInputEnabled = false; - settings.localEchoEnabled = true; - app.saveAppSettingsToStorage(settings); - app._updateCjkInputState(); - app._updateLocalEchoState(); - app.terminal.focus(); - }); - - await page.locator('#terminalContainer').tap({ position: { x: 40, y: 40 } }); - // The tap itself emits a valid SGR mouse report. This assertion is about - // typed text, so discard pointer setup traffic before entering text. - await page.evaluate(() => { - window.__sentInputs = []; - }); - await page.keyboard.type('find bug'); - const beforeEnter = await page.evaluate(() => ({ - activeClass: document.activeElement?.className, - cjkDisplay: getComputedStyle(document.getElementById('cjkInput') as HTMLElement).display, - pendingText: app._localEchoOverlay?.pendingText, - sentInputs: window.__sentInputs, - })); - expect(beforeEnter.activeClass).toContain('xterm-helper-textarea'); - expect(beforeEnter.cjkDisplay).toBe('none'); - expect(beforeEnter.pendingText).toBe('find bug'); - expect(beforeEnter.sentInputs).toEqual([]); + await new Promise((resolve) => app.terminal.write('\x1b[2J\x1b[H', resolve)); + await new Promise((resolve) => setTimeout(resolve, KeyboardHandler.FRAME_COVER_MIN_MS + 30)); + const survivedBlank = Boolean(app.terminal.element?.querySelector('.terminal-resize-frame-cover')); - await page.keyboard.press('Enter'); - await page.waitForFunction(() => window.__sentInputs?.join('') === 'find bug\r'); + app.batchTerminalWrite('frame after keyboard\r\n› '); + await new Promise((resolve) => setTimeout(resolve, 100)); + return { + before, + survivedBlank, + removedAfterFrame: !app.terminal.element?.querySelector('.terminal-resize-frame-cover'), + }; + }); - const afterEnter = await page.evaluate(() => ({ - pendingText: app._localEchoOverlay?.pendingText, - sentInputs: window.__sentInputs, - })); - expect(afterEnter.pendingText).toBe(''); - expect(afterEnter.sentInputs.join('')).toBe('find bug\r'); + expect(state.before.exists).toBe(true); + expect(state.before.text).toContain('frame before keyboard'); + expect(state.survivedBlank).toBe(true); + expect(state.removedAfterFrame).toBe(true); }); - it('shows and commits each live Android composition after the first word', async () => { - const preview = await page.evaluate(async () => { - app.activeSessionId = 'mobile-composition-test'; - app.sessions.set('mobile-composition-test', { - id: 'mobile-composition-test', - mode: 'claude', + it('keeps the frame opaque through local resize renders and swaps only after terminal output paints', async () => { + const state = await page.evaluate(async () => { + app.activeSessionId = 'mobile-keyboard-atomic-frame-test'; + app.sessions.set('mobile-keyboard-atomic-frame-test', { + id: 'mobile-keyboard-atomic-frame-test', + mode: 'codex', status: 'running', }); app.hideWelcome(); - const settings = app.loadAppSettingsFromStorage(); - settings.cjkInputEnabled = false; - settings.localEchoEnabled = true; - app.saveAppSettingsToStorage(settings); - app._updateCjkInputState(); - app._updateLocalEchoState(); app.terminal.reset(); - await new Promise((resolve) => { - app.terminal.write('\x1b[2J\x1b[H\u276f ', resolve); - }); - app._localEchoOverlay.clear(); - app._localEchoOverlay.appendText('first '); + await new Promise((resolve) => app.terminal.write('old stable frame\r\n› ', resolve)); + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))); + + KeyboardHandler._beginTerminalFrameCover(); + KeyboardHandler._armTerminalFrameCover(); + app.terminal.refresh(0, app.terminal.rows - 1); + await new Promise((resolve) => setTimeout(resolve, KeyboardHandler.FRAME_COVER_MIN_MS + 80)); + const survivedLocalRender = Boolean(app.terminal.element?.querySelector('.terminal-resize-frame-cover')); + + const opacitySamples: Array = []; + app.batchTerminalWrite('\x1b[2J\x1b[Hnew stable frame\r\n› '); + for (let frame = 0; frame < 12; frame++) { + await new Promise((resolve) => requestAnimationFrame(() => resolve())); + const cover = app.terminal.element?.querySelector('.terminal-resize-frame-cover') as HTMLElement | null; + opacitySamples.push(cover ? Number(getComputedStyle(cover).opacity) : null); + } - const textarea = app.terminal.textarea; - textarea.value = ''; - textarea.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true })); - textarea.value = 'sec'; - textarea.dispatchEvent( - new CompositionEvent('compositionupdate', { - bubbles: true, - data: 'sec', - }) - ); + return { + survivedLocalRender, + opacitySamples, + removedAfterOutput: !app.terminal.element?.querySelector('.terminal-resize-frame-cover'), + }; + }); - const overlay = Array.from(app.terminal.element.querySelector('.xterm-screen')?.children || []).find( - (element) => (element as HTMLElement).style.zIndex === '7' - ); + expect(state.survivedLocalRender).toBe(true); + expect(state.opacitySamples.every((opacity) => opacity === null || opacity === 1)).toBe(true); + expect(state.removedAfterOutput).toBe(true); + }); + + it('slides the captured frame during keyboard shrink instead of snapping its bottom edge', async () => { + const state = await page.evaluate(async () => { + app.activeSessionId = 'mobile-keyboard-frame-motion-test'; + app.sessions.set('mobile-keyboard-frame-motion-test', { + id: 'mobile-keyboard-frame-motion-test', + mode: 'codex', + status: 'running', + }); + app.hideWelcome(); + app.terminal.reset(); + await new Promise((resolve) => app.terminal.write('stable frame before keyboard\r\n› ', resolve)); + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))); + + KeyboardHandler._beginTerminalFrameCover(); + const cover = app.terminal.element?.querySelector('.terminal-resize-frame-cover') as HTMLElement | null; + const frame = cover?.querySelector('.terminal-resize-frame') as HTMLElement | null; + if (!cover || !frame) return null; + const initialHeight = cover.getBoundingClientRect().height; + const nextHeight = Math.max(180, initialHeight - 180); + + KeyboardHandler.keyboardVisible = true; + document.body.classList.add('keyboard-visible'); + document.documentElement.style.setProperty('--app-height', `${nextHeight + 42}px`); + KeyboardHandler._updateTerminalFrameCoverGeometry(); + + const style = getComputedStyle(frame); return { - pendingText: app._localEchoOverlay.pendingText, - compositionText: app._localEchoOverlay.compositionText, - visibleText: overlay?.textContent, + shift: parseFloat(frame.style.getPropertyValue('--terminal-frame-shift') || '0'), + top: style.top, + bottom: style.bottom, + transitionProperty: style.transitionProperty, + transitionDuration: parseFloat(style.transitionDuration) || 0, }; }); - expect(preview).toEqual({ - pendingText: 'first ', - compositionText: 'sec', - visibleText: expect.stringContaining('first sec'), + expect(state).not.toBeNull(); + expect(state!.shift).toBeLessThan(-100); + expect(state!.top).toBe('0px'); + expect(state!.bottom).not.toBe('0px'); + expect(state!.transitionProperty).toContain('transform'); + expect(state!.transitionDuration).toBeGreaterThan(0); + }); + + it('holds the outgoing frame and avoids a second keyboard fit during a tab switch', async () => { + await page.route('**/api/sessions/*/terminal*', async (route) => { + const sessionId = new URL(route.request().url()).pathname.split('/')[3]; + if (sessionId === 'smooth-tab-b') { + await new Promise((resolve) => setTimeout(resolve, 140)); + } + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + data: { + terminalBuffer: `${sessionId} stable frame\r\n${sessionId} prompt`, + truncated: false, + }, + }), + }); }); - await page.evaluate(() => { - const textarea = app.terminal.textarea; - textarea.value = 'second'; - textarea.dispatchEvent( - new CompositionEvent('compositionupdate', { - bubbles: true, + const state = await page.evaluate(async () => { + app._initialFullBufferLoad = false; + app.sessions.set('smooth-tab-a', { + id: 'smooth-tab-a', + name: 'Smooth A', + mode: 'codex', + status: 'idle', + pid: 1, + workingDir: '/tmp', + }); + app.sessions.set('smooth-tab-b', { + id: 'smooth-tab-b', + name: 'Smooth B', + mode: 'codex', + status: 'idle', + pid: 1, + workingDir: '/tmp', + }); + app.sessionOrder = ['smooth-tab-a', 'smooth-tab-b']; + app.renderSessionTabs(); + await app.selectSession('smooth-tab-a'); + await new Promise((resolve) => app.terminal.write('\r\nsmooth-tab-a outgoing frame\r\n', resolve)); + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))); + + KeyboardHandler.keyboardVisible = true; + KeyboardHandler._terminalInputRequested = true; + document.body.classList.add('keyboard-visible'); + const originalFit = app.fitAddon.fit.bind(app.fitAddon); + const originalFrameCoverMax = KeyboardHandler.FRAME_COVER_MAX_MS; + KeyboardHandler.FRAME_COVER_MAX_MS = 60; + const fitStacks: string[] = []; + app.fitAddon.fit = () => { + fitStacks.push(String(new Error().stack || '')); + originalFit(); + }; + + const switching = app.selectSession('smooth-tab-b'); + await new Promise((resolve) => setTimeout(resolve, 90)); + const cover = app.terminal.element?.querySelector('.terminal-resize-frame-cover'); + const duringSwitch = { + coverVisible: Boolean(cover), + coverText: cover?.textContent || '', + }; + await switching; + await new Promise((resolve) => setTimeout(resolve, KeyboardHandler.FRAME_COVER_MIN_MS + 30)); + app.fitAddon.fit = originalFit; + KeyboardHandler.FRAME_COVER_MAX_MS = originalFrameCoverMax; + + return { + duringSwitch, + removedAfterTargetFrame: !app.terminal.element?.querySelector('.terminal-resize-frame-cover'), + keyboardFitCalls: fitStacks.filter((stack) => stack.includes('mobile-handlers.js')).length, + }; + }); + + expect(state.duringSwitch.coverVisible).toBe(true); + expect(state.duringSwitch.coverText).toContain('smooth-tab-a'); + expect(state.removedAfterTargetFrame).toBe(true); + expect(state.keyboardFitCalls).toBe(0); + }); + + it('shows the latest frame while a bounded history page downloads off-screen', async () => { + const state = await page.evaluate(async () => { + const sourceId = 'history-replay-source'; + const targetId = 'history-replay-target'; + const originalFetch = window.fetch; + const originalSendResize = app.sendResize; + const originalConnectWs = app._connectWs; + const streamHeaders = (end: number, extra: Record = {}) => ({ + 'content-type': 'text/plain; charset=utf-8', + 'x-codeman-terminal-format': 'stream-v1', + 'x-codeman-terminal-stream': 'history-replay-stream', + 'x-codeman-terminal-generation': '1', + 'x-codeman-terminal-start': '0', + 'x-codeman-terminal-end': String(end), + 'x-codeman-terminal-status': 'idle', + 'x-codeman-terminal-full-size': String(end), + 'x-codeman-terminal-truncated': '0', + 'x-codeman-terminal-source': 'mux-visible', + ...extra, + }); + const latestFrame = 'LATEST TARGET FRAME\r\n› current prompt'; + const historicalChunks = Array.from( + { length: 4 }, + (_, chunkIndex) => + Array.from( + { length: 90 }, + (_, lineIndex) => `HISTORY_${chunkIndex}_${String(lineIndex).padStart(3, '0')} background scrollback` + ).join('\r\n') + '\r\n' + ); + let historyChunksSent = 0; + + try { + app.sessions.set(sourceId, { + id: sourceId, + name: 'Replay source', + mode: 'codex', + status: 'idle', + pid: 1, + workingDir: '/tmp', + }); + app.sessions.set(targetId, { + id: targetId, + name: 'Replay target', + mode: 'codex', + status: 'idle', + pid: 1, + workingDir: '/tmp', + }); + app.sessionOrder = [sourceId, targetId]; + app.activeSessionId = sourceId; + app._initialFullBufferLoad = false; + app.terminalBufferCache.delete(targetId); + app._xtermSnapshots.delete(targetId); + app._warmTerminalCache.remove(targetId); + app.renderSessionTabs(); + app.hideWelcome(); + app.terminal.reset(); + await new Promise((resolve) => app.terminal.write('OUTGOING SOURCE FRAME\r\n› source prompt', resolve)); + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))); + + app.sendResize = async () => false; + app._connectWs = () => {}; + window.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (!url.includes(`/api/sessions/${targetId}/terminal`)) { + return originalFetch.call(window, input, init); + } + if (url.includes('latest=1')) { + return new Response(latestFrame, { + status: 200, + headers: streamHeaders(latestFrame.length), + }); + } + + const encoder = new TextEncoder(); + const body = new ReadableStream({ + start(controller) { + void (async () => { + for (const chunk of historicalChunks) { + await new Promise((resolve) => setTimeout(resolve, 70)); + controller.enqueue(encoder.encode(chunk)); + historyChunksSent += 1; + } + controller.close(); + })(); + }, + }); + const totalLength = historicalChunks.reduce((sum, chunk) => sum + chunk.length, 0); + return new Response(body, { + status: 200, + headers: streamHeaders(totalLength, { + 'x-codeman-terminal-source': 'mux-history-page', + 'x-codeman-history-start': '0', + 'x-codeman-history-end': '360', + 'x-codeman-history-total': '360', + 'x-codeman-history-more-before': '0', + 'x-codeman-history-more-after': '0', + 'x-codeman-history-origin': 'mobile-history-origin', + }), + }); + }) as typeof window.fetch; + + const switching = app.selectSession(targetId); + const samples: Array<{ + coverText: string; + baseY: number; + viewportY: number; + coverRight: number; + screenRight: number; + }> = []; + for (let attempt = 0; attempt < 40; attempt++) { + await new Promise((resolve) => setTimeout(resolve, 25)); + const cover = app.terminal.element?.querySelector('.terminal-history-replay-cover') as HTMLElement | null; + if (!cover || !cover.textContent?.includes('LATEST TARGET FRAME')) continue; + const screen = app.terminal.element?.querySelector('.xterm-screen') as HTMLElement | null; + const buffer = app.terminal.buffer.active; + const coverRect = cover.getBoundingClientRect(); + const screenRect = screen?.getBoundingClientRect(); + samples.push({ + coverText: cover.textContent || '', + baseY: buffer.baseY, + viewportY: buffer.viewportY, + coverRight: coverRect.right, + screenRight: screenRect?.right || 0, + }); + if (historyChunksSent >= historicalChunks.length - 1) break; + } + + await switching; + await new Promise((resolve) => setTimeout(resolve, 180)); + const buffer = app.terminal.buffer.active; + const visibleRows = Array.from( + { length: app.terminal.rows }, + (_, row) => buffer.getLine(buffer.viewportY + row)?.translateToString(true) || '' + ).join('\n'); + const paging = app._terminalHistoryPaging.get(targetId); + + return { + samples, + historyChunksSent, + finalBaseY: buffer.baseY, + finalViewportY: buffer.viewportY, + finalVisibleRows: visibleRows, + paging: paging + ? { + start: paging.start, + end: paging.end, + total: paging.total, + pages: paging.pages.length, + } + : null, + coverRemoved: !app.terminal.element?.querySelector('.terminal-history-replay-cover'), + }; + } finally { + window.fetch = originalFetch; + app.sendResize = originalSendResize; + app._connectWs = originalConnectWs; + } + }); + + expect(state.historyChunksSent).toBeGreaterThan(2); + expect(state.samples.length).toBeGreaterThan(2); + expect(state.samples.every((sample) => sample.coverText.includes('LATEST TARGET FRAME'))).toBe(true); + expect(state.samples.every((sample) => !sample.coverText.includes('HISTORY_'))).toBe(true); + expect(state.samples.every((sample) => sample.viewportY === sample.baseY)).toBe(true); + expect(Math.max(...state.samples.map((sample) => sample.baseY))).toBe( + Math.min(...state.samples.map((sample) => sample.baseY)) + ); + expect(state.samples.every((sample) => Math.abs(sample.coverRight - sample.screenRight) < 1)).toBe(true); + expect(state.finalBaseY).toBeGreaterThan(state.samples[0].baseY); + expect(state.finalViewportY).toBe(state.finalBaseY); + expect(state.finalVisibleRows).toContain('LATEST TARGET FRAME'); + expect(state.paging).toEqual({ start: 0, end: 360, total: 360, pages: 1 }); + expect(state.coverRemoved).toBe(true); + }); + + it('does not reset the shared terminal while the previous session is still parsing', async () => { + await page.route('**/api/sessions/parser-fence-target/terminal*', async (route) => { + const frame = '\x1b[2J\x1b[HDESTINATION FRAME\r\n› target prompt'; + const end = String(frame.length); + await route.fulfill({ + status: 200, + contentType: 'text/plain; charset=utf-8', + headers: { + 'x-codeman-terminal-format': 'stream-v1', + 'x-codeman-terminal-stream': 'parser-fence-stream', + 'x-codeman-terminal-generation': '1', + 'x-codeman-terminal-start': '0', + 'x-codeman-terminal-end': end, + 'x-codeman-terminal-status': 'idle', + 'x-codeman-terminal-full-size': end, + 'x-codeman-terminal-truncated': '0', + 'x-codeman-terminal-source': route.request().url().includes('historyPage=1') + ? 'mux-history-page' + : 'mux-visible', + ...(route.request().url().includes('historyPage=1') + ? { + 'x-codeman-history-start': '0', + 'x-codeman-history-end': '1', + 'x-codeman-history-total': '1', + 'x-codeman-history-more-before': '0', + 'x-codeman-history-more-after': '0', + 'x-codeman-history-origin': 'parser-fence-origin', + } + : {}), + }, + body: frame, + }); + }); + + const state = await page.evaluate(async () => { + const sourceId = 'parser-fence-source'; + const targetId = 'parser-fence-target'; + app.sessions.set(sourceId, { + id: sourceId, + name: 'Parser source', + mode: 'codex', + status: 'busy', + pid: 1, + workingDir: '/tmp', + }); + app.sessions.set(targetId, { + id: targetId, + name: 'Parser target', + mode: 'codex', + status: 'idle', + pid: 1, + workingDir: '/tmp', + }); + app.sessionOrder = [sourceId, targetId]; + app.activeSessionId = sourceId; + app._initialFullBufferLoad = false; + app._warmTerminalCache.remove(targetId); + app._xtermSnapshots.delete(targetId); + app.terminalBufferCache.delete(targetId); + app.sendResize = async () => false; + app._connectWs = () => {}; + app.hideWelcome(); + app.terminal.reset(); + await new Promise((resolve) => app.terminal.write('SOURCE FRAME\r\n', resolve)); + + let releaseParser: ((value: boolean) => void) | null = null; + let parserEnteredResolve: (() => void) | null = null; + const parserEntered = new Promise((resolve) => { + parserEnteredResolve = resolve; + }); + const parserBlock = new Promise((resolve) => { + releaseParser = resolve; + }); + const blocker = app.terminal.parser.registerOscHandler(777, () => { + parserEnteredResolve?.(); + return parserBlock; + }); + + const originalReset = app.terminal.reset.bind(app.terminal); + let resetCount = 0; + app.terminal.reset = () => { + resetCount += 1; + originalReset(); + }; + + try { + app.batchTerminalWrite('\x1b]777;hold\x07OLD SESSION DATA AFTER BLOCK'); + await parserEntered; + + const switching = app.selectSession(targetId, { takeControl: false }); + await new Promise((resolve) => setTimeout(resolve, 80)); + const resetBeforeRelease = resetCount; + + releaseParser?.(true); + await switching; + await new Promise((resolve) => app.terminal.write('', resolve)); + + const buffer = app.terminal.buffer.active; + const visibleRows = Array.from( + { length: app.terminal.rows }, + (_, row) => buffer.getLine(buffer.viewportY + row)?.translateToString(true) || '' + ).join('\n'); + return { + resetBeforeRelease, + visibleRows, + }; + } finally { + releaseParser?.(true); + blocker.dispose(); + app.terminal.reset = originalReset; + } + }); + + expect(state.resetBeforeRelease).toBe(0); + expect(state.visibleRows).toContain('DESTINATION FRAME'); + expect(state.visibleRows).not.toContain('OLD SESSION DATA AFTER BLOCK'); + }); + + it('routes Claude touch drags to its transcript without moving local xterm history', async () => { + const result = await page.evaluate(async () => { + app.activeSessionId = 'mobile-claude-scroll-test'; + app.sessions.set('mobile-claude-scroll-test', { + id: 'mobile-claude-scroll-test', + mode: 'claude', + cliVersion: '2.1.220', + status: 'running', + }); + app.hideWelcome(); + + const sgrCalls: number[] = []; + const localCalls: number[] = []; + app._sendSyntheticSgrWheel = (_x: number, _y: number, lines: number) => { + sgrCalls.push(lines); + }; + app.terminal.scrollLines = (lines: number) => { + localCalls.push(lines); + }; + + const target = + document.querySelector('#terminalContainer .xterm-screen') ?? document.getElementById('terminalContainer'); + if (!target) return { sgrCalls, localCalls }; + const rect = target.getBoundingClientRect(); + const x = rect.left + rect.width / 2; + const startY = rect.top + Math.min(100, rect.height - 20); + const endY = startY + 100; + + function createTouch(y: number) { + return new Touch({ + identifier: 2, + target, + clientX: x, + clientY: y, + pageX: x, + pageY: y, + }); + } + + target.dispatchEvent( + new TouchEvent('touchstart', { + touches: [createTouch(startY)], + changedTouches: [createTouch(startY)], + bubbles: true, + cancelable: true, + }) + ); + target.dispatchEvent( + new TouchEvent('touchmove', { + touches: [createTouch(endY)], + changedTouches: [createTouch(endY)], + bubbles: true, + cancelable: true, + }) + ); + target.dispatchEvent( + new TouchEvent('touchend', { + touches: [], + changedTouches: [createTouch(endY)], + bubbles: true, + cancelable: true, + }) + ); + await new Promise((resolve) => setTimeout(resolve, 50)); + return { sgrCalls, localCalls }; + }); + + expect(result.sgrCalls.some((lines) => lines < 0)).toBe(true); + expect(result.localCalls).toEqual([]); + }); + + it('keeps typed phone text in the terminal local echo path', async () => { + await page.evaluate(() => { + window.__sentInputs = []; + app.activeSessionId = 'mobile-visible-input-test'; + app.sessions.set('mobile-visible-input-test', { + id: 'mobile-visible-input-test', + mode: 'codex', + status: 'running', + }); + app.hideWelcome(); + app._sendInputAsync = (_sessionId: string, input: string) => { + window.__sentInputs.push(input); + }; + const settings = app.loadAppSettingsFromStorage(); + settings.cjkInputEnabled = false; + settings.localEchoEnabled = true; + app.saveAppSettingsToStorage(settings); + app._updateCjkInputState(); + app._updateLocalEchoState(); + app.terminal.focus(); + }); + + await page.locator('#terminalContainer').tap({ position: { x: 40, y: 40 } }); + // The tap itself emits a valid SGR mouse report. This assertion is about + // typed text, so discard pointer setup traffic before entering text. + await page.evaluate(() => { + window.__sentInputs = []; + }); + await page.keyboard.type('find bug'); + + const beforeEnter = await page.evaluate(() => ({ + activeClass: document.activeElement?.className, + cjkDisplay: getComputedStyle(document.getElementById('cjkInput') as HTMLElement).display, + pendingText: app._localEchoOverlay?.pendingText, + sentInputs: window.__sentInputs, + })); + expect(beforeEnter.activeClass).toContain('xterm-helper-textarea'); + expect(beforeEnter.cjkDisplay).toBe('none'); + expect(beforeEnter.pendingText).toBe('find bug'); + expect(beforeEnter.sentInputs).toEqual([]); + + await page.keyboard.press('Enter'); + await page.waitForFunction(() => window.__sentInputs?.join('') === 'find bug\r'); + + const afterEnter = await page.evaluate(() => ({ + pendingText: app._localEchoOverlay?.pendingText, + sentInputs: window.__sentInputs, + })); + expect(afterEnter.pendingText).toBe(''); + expect(afterEnter.sentInputs.join('')).toBe('find bug\r'); + }); + + it('rehydrates an unsent session draft after the page is backgrounded', async () => { + const state = await page.evaluate(() => { + const sessionId = 'mobile-durable-draft-test'; + localStorage.removeItem('codeman:sessionDrafts'); + app._inputState.clearAll({ persist: false }); + app.activeSessionId = sessionId; + app.sessions.set(sessionId, { + id: sessionId, + mode: 'codex', + status: 'running', + }); + app._localEchoOverlay.clear(); + app._localEchoOverlay.appendText('first paragraph\n\nsecond paragraph'); + + window.dispatchEvent(new PageTransitionEvent('pagehide')); + const persisted = JSON.parse(localStorage.getItem('codeman:sessionDrafts') || '{}'); + + // Simulate the in-memory state loss caused by a discarded mobile tab. + app._localEchoOverlay.clear(); + app._inputState.clearAll({ persist: false }); + app._inputState.load(); + app._restoreSessionDraft(sessionId, false); + + return { + persisted: persisted.drafts?.[sessionId], + restored: app._localEchoOverlay.state, + }; + }); + + expect(state.persisted).toMatchObject({ + pendingText: 'first paragraph\n\nsecond paragraph', + flushedText: '', + cjkText: '', + }); + expect(state.restored).toMatchObject({ + pendingText: 'first paragraph\n\nsecond paragraph', + flushedLength: 0, + flushedText: '', + }); + }); + + it('keeps a switched-away draft editable after browser state is reloaded', async () => { + const state = await page.evaluate(() => { + const firstId = 'mobile-draft-session-a'; + const secondId = 'mobile-draft-session-b'; + localStorage.removeItem('codeman:sessionDrafts'); + app._inputState.clearAll({ persist: false }); + app.sessions.set(firstId, { id: firstId, mode: 'codex', status: 'running' }); + app.sessions.set(secondId, { id: secondId, mode: 'codex', status: 'running' }); + app.activeSessionId = firstId; + app._localEchoEnabled = true; + app._localEchoOverlay.clear(); + app._localEchoOverlay.appendText('switch-safe draft'); + const sent: string[] = []; + app._sendInputAsync = (_sessionId: string, input: string) => sent.push(input); + + app._cleanupPreviousSession(secondId); + app._inputState.persistNow(); + + // Rehydrate the input store exactly as a fresh browser page does. + app._localEchoOverlay.clear(); + app._inputState.clearAll({ persist: false }); + app._inputState.load(); + app.activeSessionId = firstId; + app._restoreSessionDraft(firstId, false); + const restoredDraft = app._inputState.get(firstId); + + return { + sent, + restored: app._localEchoOverlay.state, + flushedOffset: restoredDraft?.flushedText.length, + flushedText: restoredDraft?.flushedText, + }; + }); + + expect(state).toMatchObject({ + sent: ['switch-safe draft'], + restored: { + pendingText: '', + flushedLength: 'switch-safe draft'.length, + flushedText: 'switch-safe draft', + }, + flushedOffset: 'switch-safe draft'.length, + flushedText: 'switch-safe draft', + }); + }); + + it('removes a persisted draft once Enter submits it', async () => { + const state = await page.evaluate(() => { + const sessionId = 'mobile-cleared-draft-test'; + localStorage.removeItem('codeman:sessionDrafts'); + app._inputState.clearAll({ persist: false }); + app.activeSessionId = sessionId; + app.sessions.set(sessionId, { id: sessionId, mode: 'codex', status: 'running' }); + app._localEchoEnabled = true; + app._localEchoOverlay.clear(); + app._localEchoOverlay.appendText('submit me'); + app._captureActiveSessionDraft(); + app._inputState.persistNow(); + const before = JSON.parse(localStorage.getItem('codeman:sessionDrafts') || '{}'); + + app._sendInputAsync = () => {}; + app.terminal.input('\r'); + app._inputState.persistNow(); + const after = JSON.parse(localStorage.getItem('codeman:sessionDrafts') || '{}'); + + return { + before: before.drafts?.[sessionId]?.pendingText, + after: after.drafts?.[sessionId] ?? null, + }; + }); + + expect(state).toEqual({ + before: 'submit me', + after: null, + }); + }); + + it('shows and commits each live Android composition after the first word', async () => { + const preview = await page.evaluate(async () => { + app.activeSessionId = 'mobile-composition-test'; + app.sessions.set('mobile-composition-test', { + id: 'mobile-composition-test', + mode: 'claude', + status: 'running', + }); + app.hideWelcome(); + const settings = app.loadAppSettingsFromStorage(); + settings.cjkInputEnabled = false; + settings.localEchoEnabled = true; + app.saveAppSettingsToStorage(settings); + app._updateCjkInputState(); + app._updateLocalEchoState(); + app.terminal.reset(); + await new Promise((resolve) => { + app.terminal.write('\x1b[2J\x1b[H\u276f ', resolve); + }); + app._localEchoOverlay.clear(); + app._localEchoOverlay.appendText('first '); + + const textarea = app.terminal.textarea; + textarea.value = ''; + textarea.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true })); + textarea.value = 'sec'; + textarea.dispatchEvent( + new CompositionEvent('compositionupdate', { + bubbles: true, + data: 'sec', + }) + ); + + const overlay = Array.from(app.terminal.element.querySelector('.xterm-screen')?.children || []).find( + (element) => (element as HTMLElement).style.zIndex === '7' + ); + return { + pendingText: app._localEchoOverlay.pendingText, + compositionText: app._localEchoOverlay.compositionText, + visibleText: overlay?.textContent, + }; + }); + + expect(preview).toEqual({ + pendingText: 'first ', + compositionText: 'sec', + visibleText: expect.stringContaining('first sec'), + }); + + await page.evaluate(() => { + const textarea = app.terminal.textarea; + textarea.value = 'second'; + textarea.dispatchEvent( + new CompositionEvent('compositionupdate', { + bubbles: true, data: 'second', }) ); @@ -1216,6 +2230,9 @@ describe('Virtual Keyboard', () => { compositionText: app._localEchoOverlay.compositionText, nativeCompositionActive: nativeComposition?.classList.contains('active'), nativeCompositionDisplay: nativeComposition ? getComputedStyle(nativeComposition).display : null, + nativeCompositionOpacity: nativeComposition ? getComputedStyle(nativeComposition).opacity : null, + nativeCompositionMeasurable: (nativeComposition?.getBoundingClientRect().height || 0) > 0, + helperLineHeightPositive: parseFloat(app.terminal.textarea.style.lineHeight || '0') > 0, localEchoClass: app.terminal.element.classList.contains('codeman-local-echo'), }; }); @@ -1224,7 +2241,10 @@ describe('Virtual Keyboard', () => { pendingText: 'first second', compositionText: 'third', nativeCompositionActive: true, - nativeCompositionDisplay: 'none', + nativeCompositionDisplay: 'block', + nativeCompositionOpacity: '0', + nativeCompositionMeasurable: true, + helperLineHeightPositive: true, localEchoClass: true, }); @@ -1260,6 +2280,68 @@ describe('Virtual Keyboard', () => { }); }); + it('commits an Android word once when a delayed insertText follows composition finalization', async () => { + const state = await page.evaluate(async () => { + app.activeSessionId = 'mobile-composition-late-input-test'; + app.sessions.set('mobile-composition-late-input-test', { + id: 'mobile-composition-late-input-test', + mode: 'claude', + status: 'running', + }); + app.hideWelcome(); + const settings = app.loadAppSettingsFromStorage(); + settings.cjkInputEnabled = false; + settings.localEchoEnabled = true; + app.saveAppSettingsToStorage(settings); + app._updateCjkInputState(); + app._updateLocalEchoState(); + app.terminal.reset(); + await new Promise((resolve) => app.terminal.write('\x1b[2J\x1b[H\u276f ', resolve)); + app._localEchoOverlay.clear(); + + const textarea = app.terminal.textarea; + textarea.value = ''; + textarea.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true })); + textarea.value = 'its'; + textarea.dispatchEvent( + new CompositionEvent('compositionupdate', { + bubbles: true, + data: 'its', + }) + ); + textarea.dispatchEvent( + new CompositionEvent('compositionend', { + bubbles: true, + data: 'its', + }) + ); + + // xterm finalizes composition in a zero-delay task. Some Android + // keyboards then emit the same committed word as a late insertText. + await new Promise((resolve) => setTimeout(resolve, 20)); + textarea.value = 'its'; + textarea.dispatchEvent( + new InputEvent('input', { + bubbles: true, + composed: false, + inputType: 'insertText', + data: 'its', + }) + ); + await new Promise((resolve) => setTimeout(resolve, 20)); + + return { + pendingText: app._localEchoOverlay.pendingText, + compositionText: app._localEchoOverlay.compositionText, + }; + }); + + expect(state).toEqual({ + pendingText: 'its', + compositionText: '', + }); + }); + it('keeps routing Android textarea text after an interrupted composition', async () => { const state = await page.evaluate(async () => { app.activeSessionId = 'mobile-null-input-data-test'; @@ -1328,6 +2410,111 @@ describe('Virtual Keyboard', () => { }); }); + it('applies only the new Android helper-textarea mutation from cumulative values', async () => { + const state = await page.evaluate(async () => { + app.activeSessionId = 'mobile-cumulative-input-test'; + app.sessions.set('mobile-cumulative-input-test', { + id: 'mobile-cumulative-input-test', + mode: 'claude', + status: 'running', + }); + app.hideWelcome(); + const settings = app.loadAppSettingsFromStorage(); + settings.cjkInputEnabled = false; + settings.localEchoEnabled = true; + app.saveAppSettingsToStorage(settings); + app._updateCjkInputState(); + app._updateLocalEchoState(); + app._localEchoOverlay.clear(); + app._localEchoOverlay.appendText('seed'); + + const textarea = app.terminal.textarea; + const beginMutation = (value: string) => { + textarea.value = value; + textarea.setSelectionRange(value.length, value.length); + textarea.dispatchEvent( + new KeyboardEvent('keydown', { + bubbles: true, + key: 'Process', + keyCode: 229, + }) + ); + textarea.dispatchEvent( + new InputEvent('beforeinput', { + bubbles: true, + cancelable: true, + inputType: 'insertText', + data: null, + }) + ); + }; + + beginMutation('seed'); + textarea.value = 'seed next'; + textarea.setSelectionRange(textarea.value.length, textarea.value.length); + textarea.dispatchEvent( + new InputEvent('input', { + bubbles: true, + composed: true, + inputType: 'insertText', + data: null, + }) + ); + await Promise.resolve(); + const afterNullData = app._localEchoOverlay.pendingText; + + beginMutation('seed next'); + textarea.value = 'seed next more'; + textarea.setSelectionRange(textarea.value.length, textarea.value.length); + textarea.dispatchEvent( + new InputEvent('input', { + bubbles: true, + composed: false, + inputType: 'insertText', + data: 'seed next more', + }) + ); + await Promise.resolve(); + + return { + afterNullData, + pendingText: app._localEchoOverlay.pendingText, + helperValue: textarea.value, + }; + }); + + expect(state).toEqual({ + afterNullData: 'seed next', + pendingText: 'seed next more', + helperValue: '', + }); + }); + + it('filters xterm status replies without swallowing user navigation keys', async () => { + const state = await page.evaluate(() => { + const suppress = window.CodemanTerminalInput.shouldSuppressTerminalQueryResponse; + return { + deviceAttributes: suppress('\x1b[>0;276;0c'), + modeReport: suppress('\x1b[?2026;1$y'), + windowReport: suppress('\x1b[8;24;80t'), + statusString: suppress('\x1bP1$r0m\x1b\\'), + oscColor: suppress('\x1b]10;rgb:ffff/ffff/ffff\x1b\\'), + arrowUp: suppress('\x1b[A'), + escape: suppress('\x1b'), + }; + }); + + expect(state).toEqual({ + deviceAttributes: true, + modeReport: true, + windowReport: true, + statusString: true, + oscColor: true, + arrowUp: false, + escape: false, + }); + }); + it('routes Backspace past a stale autocomplete textarea buffer', async () => { const state = await page.evaluate(() => { window.__sentInputs = []; @@ -1407,20 +2594,28 @@ describe('Virtual Keyboard', () => { app._updateCjkInputState(); app._updateLocalEchoState(); app.terminal.reset(); + window.__draftPromptRow = Math.max(2, app.terminal.rows - 5); await new Promise((resolve) => { - app.terminal.write('\x1b[2J\x1b[H\u276f ', resolve); + app.terminal.write(`\x1b[2J\x1b[H\x1b[${window.__draftPromptRow + 1};1H\u276f `, resolve); }); app.terminal.focus(); }); await page.keyboard.type('keep this draft'); - await expect.poll(() => page.evaluate(() => app._localEchoOverlay?.state.promptPosition?.row)).toBe(0); + await expect + .poll(() => page.evaluate(() => app._localEchoOverlay?.state.promptPosition?.row)) + .toBe(await page.evaluate(() => window.__draftPromptRow)); await page.evaluate(() => { - app.batchTerminalWrite('\x1b[2J\x1b[Hagent output\r\ncontinues here\r\n\u276f '); + window.__updatedDraftPromptRow = Math.max(2, app.terminal.rows - 4); + app.batchTerminalWrite( + `\x1b[2J\x1b[Hagent output\r\ncontinues here\x1b[${window.__updatedDraftPromptRow + 1};1H\u276f ` + ); }); - await expect.poll(() => page.evaluate(() => app._localEchoOverlay?.state.promptPosition?.row)).toBe(2); + await expect + .poll(() => page.evaluate(() => app._localEchoOverlay?.state.promptPosition?.row)) + .toBe(await page.evaluate(() => window.__updatedDraftPromptRow)); const state = await page.evaluate(() => ({ pendingText: app._localEchoOverlay?.pendingText, sentInputs: window.__sentInputs, @@ -1642,9 +2837,102 @@ describe('Virtual Keyboard', () => { it('keeps back-to-back local echo submissions in text-then-Enter order', async () => { await page.evaluate(() => { window.__sentInputs = []; - app.activeSessionId = 'mobile-rapid-submit-test'; - app.sessions.set('mobile-rapid-submit-test', { - id: 'mobile-rapid-submit-test', + app.activeSessionId = 'mobile-rapid-submit-test'; + app.sessions.set('mobile-rapid-submit-test', { + id: 'mobile-rapid-submit-test', + mode: 'codex', + status: 'running', + }); + app.hideWelcome(); + app._sendInputAsync = (_sessionId: string, input: string) => { + window.__sentInputs.push(input); + }; + const settings = app.loadAppSettingsFromStorage(); + settings.cjkInputEnabled = false; + settings.localEchoEnabled = true; + app.saveAppSettingsToStorage(settings); + app._updateCjkInputState(); + app._updateLocalEchoState(); + app.terminal.focus(); + }); + + await page.keyboard.type('first'); + await page.keyboard.press('Enter'); + await page.keyboard.type('second'); + await page.keyboard.press('Enter'); + + await expect.poll(() => page.evaluate(() => window.__sentInputs)).toEqual(['first', '\r', 'second', '\r']); + }); + + it('inserts Android keyCode 229 line-break input into the current draft', async () => { + const state = await page.evaluate(async () => { + window.__sentInputs = []; + app.activeSessionId = 'mobile-android-enter-test'; + app.sessions.set('mobile-android-enter-test', { + id: 'mobile-android-enter-test', + mode: 'codex', + status: 'running', + }); + app.hideWelcome(); + app._sendInputAsync = (_sessionId: string, input: string) => { + window.__sentInputs.push(input); + }; + const settings = app.loadAppSettingsFromStorage(); + settings.cjkInputEnabled = false; + settings.localEchoEnabled = true; + app.saveAppSettingsToStorage(settings); + app._updateCjkInputState(); + app._updateLocalEchoState(); + app.terminal.reset(); + await new Promise((resolve) => { + app.terminal.write('\x1b[2J\x1b[H\u276f ', resolve); + }); + app._localEchoOverlay.clear(); + + app.terminal._core.coreService.triggerDataEvent('stringA stringB', true); + const textarea = app.terminal.textarea; + textarea.dispatchEvent( + new KeyboardEvent('keydown', { + bubbles: true, + key: 'Enter', + code: 'Enter', + keyCode: 229, + }) + ); + textarea.dispatchEvent( + new InputEvent('beforeinput', { + bubbles: true, + cancelable: true, + inputType: 'insertLineBreak', + }) + ); + textarea.dispatchEvent( + new InputEvent('input', { + bubbles: true, + inputType: 'insertLineBreak', + }) + ); + app.terminal._core.coreService.triggerDataEvent('stringC', true); + await Promise.resolve(); + + return { + sentInputs: window.__sentInputs, + pendingText: app._localEchoOverlay.pendingText, + }; + }); + + expect(state).toEqual({ + sentInputs: [], + pendingText: 'stringA stringB\nstringC', + }); + }); + + it('inserts an Android line break when compositionend is omitted', async () => { + const state = await page.evaluate(async () => { + window.__sentInputs = []; + app.activeSessionId = 'mobile-android-composing-enter-test'; + app.sessions.set('mobile-android-composing-enter-test', { + id: 'mobile-android-composing-enter-test', mode: 'codex', status: 'running', }); @@ -1658,15 +2946,53 @@ describe('Virtual Keyboard', () => { app.saveAppSettingsToStorage(settings); app._updateCjkInputState(); app._updateLocalEchoState(); - app.terminal.focus(); - }); + app.terminal.reset(); + await new Promise((resolve) => { + app.terminal.write('\x1b[2J\x1b[H\u276f ', resolve); + }); + app._localEchoOverlay.clear(); - await page.keyboard.type('first'); - await page.keyboard.press('Enter'); - await page.keyboard.type('second'); - await page.keyboard.press('Enter'); + app.terminal._core.coreService.triggerDataEvent('stringA ', true); + const textarea = app.terminal.textarea; + textarea.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true })); + textarea.dispatchEvent( + new CompositionEvent('compositionupdate', { + bubbles: true, + data: 'stringB', + }) + ); + textarea.dispatchEvent( + new KeyboardEvent('keydown', { + bubbles: true, + key: 'Enter', + code: 'Enter', + keyCode: 229, + isComposing: true, + }) + ); + textarea.dispatchEvent( + new InputEvent('beforeinput', { + bubbles: true, + cancelable: true, + inputType: 'insertLineBreak', + isComposing: true, + }) + ); + await new Promise((resolve) => setTimeout(resolve, 100)); + app.terminal._core.coreService.triggerDataEvent('stringC', true); - await expect.poll(() => page.evaluate(() => window.__sentInputs)).toEqual(['first', '\r', 'second', '\r']); + return { + sentInputs: window.__sentInputs, + pendingText: app._localEchoOverlay.pendingText, + compositionText: app._localEchoOverlay.compositionText, + }; + }); + + expect(state).toEqual({ + sentInputs: [], + pendingText: 'stringA stringB\nstringC', + compositionText: '', + }); }); it('flushes local echo before mobile-control Enter and accepts the next draft', async () => { @@ -1756,13 +3082,19 @@ describe('Virtual Keyboard', () => { expect(loadingState.sentInputs).toEqual([]); await page.evaluate(async () => { + window.__loadedPromptRow = Math.max(2, app.terminal.rows - 5); await new Promise((resolve) => { - app.terminal.write('\x1b[2J\x1b[Hagent output\r\nready\r\n\u203a ', resolve); + app.terminal.write( + `\x1b[2J\x1b[Hagent output\r\nready\x1b[${window.__loadedPromptRow + 1};1H\u203a `, + resolve + ); }); app._finishBufferLoad('mobile-initial-draft-load'); }); - await expect.poll(() => page.evaluate(() => app._localEchoOverlay?.state.promptPosition?.row)).toBe(2); + await expect + .poll(() => page.evaluate(() => app._localEchoOverlay?.state.promptPosition?.row)) + .toBe(await page.evaluate(() => window.__loadedPromptRow)); const readyState = await page.evaluate(() => ({ pendingText: app._localEchoOverlay?.pendingText, visible: app._localEchoOverlay?.state.visible, @@ -1791,7 +3123,10 @@ describe('Virtual Keyboard', () => { app.terminal.reset(); window.__cursorFallbackRow = Math.max(0, app.terminal.rows - 2); await new Promise((resolve) => { - app.terminal.write(`\x1b[${window.__cursorFallbackRow + 1};1Hworking without prompt marker`, resolve); + app.terminal.write( + `\x1b[2J\x1b[Hworking without prompt marker\x1b[${window.__cursorFallbackRow + 1};1H`, + resolve + ); }); app.terminal.focus(); }); @@ -1810,6 +3145,313 @@ describe('Virtual Keyboard', () => { expect(state.overlayState?.visible).toBe(true); expect(state.overlayState?.promptPosition?.row).toBe(state.expectedRow); }); + + it('ignores a stale historical prompt above the live input cursor', async () => { + await page.evaluate(async () => { + app.activeSessionId = 'mobile-stale-prompt-test'; + app.sessions.set('mobile-stale-prompt-test', { + id: 'mobile-stale-prompt-test', + mode: 'codex', + status: 'running', + }); + app.hideWelcome(); + const settings = app.loadAppSettingsFromStorage(); + settings.cjkInputEnabled = false; + settings.localEchoEnabled = true; + app.saveAppSettingsToStorage(settings); + app._updateCjkInputState(); + app._updateLocalEchoState(); + app.terminal.reset(); + app._localEchoOverlay.clear(); + window.__liveInputRow = Math.max(2, app.terminal.rows - 2); + await new Promise((resolve) => { + app.terminal.write( + `\x1b[2J\x1b[H\u276f old submitted input\r\nagent output\r\nworking without prompt marker\x1b[${window.__liveInputRow + 1};1H`, + resolve + ); + }); + app.terminal.focus(); + }); + + await page.keyboard.type('abc'); + + const state = await page.evaluate(() => ({ + pendingText: app._localEchoOverlay?.pendingText, + promptRow: app._localEchoOverlay?.state.promptPosition?.row, + expectedRow: window.__liveInputRow, + })); + + expect(state.pendingText).toBe('abc'); + expect(state.promptRow).toBe(state.expectedRow); + }); + + it('prefers the live cursor when a short keyboard viewport makes a stale prompt look current', async () => { + await page.evaluate(async () => { + app.activeSessionId = 'mobile-short-viewport-prompt-test'; + app.sessions.set('mobile-short-viewport-prompt-test', { + id: 'mobile-short-viewport-prompt-test', + mode: 'codex', + status: 'running', + }); + app.hideWelcome(); + const settings = app.loadAppSettingsFromStorage(); + settings.cjkInputEnabled = false; + settings.localEchoEnabled = true; + app.saveAppSettingsToStorage(settings); + app._updateCjkInputState(); + app._updateLocalEchoState(); + app.terminal.resize(app.terminal.cols, 6); + app.terminal.reset(); + app._localEchoOverlay.clear(); + app._localEchoPromptAnchor = null; + await new Promise((resolve) => { + app.terminal.write('\x1b[2J\x1b[H\u276f old submitted input\x1b[6;3H', resolve); + }); + app.terminal.focus(); + }); + + await page.keyboard.type('abc'); + + const state = await page.evaluate(() => ({ + pendingText: app._localEchoOverlay?.pendingText, + promptRow: app._localEchoOverlay?.state.promptPosition?.row, + cursorRow: app.terminal.buffer.active.cursorY, + })); + + expect(state.pendingText).toBe('abc'); + expect(state.promptRow).toBe(state.cursorRow); + expect(state.cursorRow).toBe(5); + }); + + it('discards a remembered prompt anchor that no longer fits after keyboard shrink', async () => { + await page.evaluate(async () => { + app.activeSessionId = 'mobile-invalid-prompt-anchor-test'; + app.sessions.set('mobile-invalid-prompt-anchor-test', { + id: 'mobile-invalid-prompt-anchor-test', + mode: 'claude', + status: 'running', + }); + app.hideWelcome(); + const settings = app.loadAppSettingsFromStorage(); + settings.cjkInputEnabled = false; + settings.localEchoEnabled = true; + app.saveAppSettingsToStorage(settings); + app._updateCjkInputState(); + app._updateLocalEchoState(); + app.terminal.resize(app.terminal.cols, 6); + app.terminal.reset(); + app._localEchoOverlay.clear(); + app._localEchoPromptAnchor = { + sessionId: 'mobile-invalid-prompt-anchor-test', + rowsFromBottom: 7, + col: 2, + }; + await new Promise((resolve) => { + app.terminal.write('\x1b[2J\x1b[H\x1b[3;1Hagent output\x1b[6;3H', resolve); + }); + app.terminal.focus(); + }); + + await page.keyboard.type('abc'); + + const state = await page.evaluate(() => ({ + pendingText: app._localEchoOverlay?.pendingText, + promptRow: app._localEchoOverlay?.state.promptPosition?.row, + cursorRow: app.terminal.buffer.active.cursorY, + })); + + expect(state.pendingText).toBe('abc'); + expect(state.promptRow).toBe(state.cursorRow); + expect(state.cursorRow).toBe(5); + }); + + it('uses a bottom fallback for the first mobile draft when the replay prompt remains at row zero', async () => { + await page.evaluate(async () => { + app.activeSessionId = 'mobile-row-zero-prompt-test'; + app.sessions.set('mobile-row-zero-prompt-test', { + id: 'mobile-row-zero-prompt-test', + mode: 'codex', + status: 'running', + }); + app.hideWelcome(); + const settings = app.loadAppSettingsFromStorage(); + settings.cjkInputEnabled = false; + settings.localEchoEnabled = true; + app.saveAppSettingsToStorage(settings); + app._updateCjkInputState(); + app._updateLocalEchoState(); + app.terminal.reset(); + app._localEchoOverlay.clear(); + app._localEchoPromptAnchor = null; + window.__fallbackPromptRow = Math.max(0, app.terminal.rows - 5); + await new Promise((resolve) => { + app.terminal.write('\x1b[2J\x1b[H\u276f replayed prompt\x1b[H', resolve); + }); + app.terminal.focus(); + }); + + await page.keyboard.type('first draft'); + + const state = await page.evaluate(() => ({ + pendingText: app._localEchoOverlay?.pendingText, + promptRow: app._localEchoOverlay?.state.promptPosition?.row, + expectedRow: window.__fallbackPromptRow, + })); + + expect(state.pendingText).toBe('first draft'); + expect(state.promptRow).toBe(state.expectedRow); + }); + + it('keeps the first post-Enter draft at the remembered prompt while a resized frame is stale', async () => { + await page.evaluate(async () => { + window.__sentInputs = []; + app.activeSessionId = 'mobile-stale-resize-prompt-test'; + app.sessions.set('mobile-stale-resize-prompt-test', { + id: 'mobile-stale-resize-prompt-test', + mode: 'claude', + status: 'running', + }); + app.hideWelcome(); + app._sendInputAsync = (_sessionId: string, input: string) => { + window.__sentInputs.push(input); + }; + const settings = app.loadAppSettingsFromStorage(); + settings.cjkInputEnabled = false; + settings.localEchoEnabled = true; + app.saveAppSettingsToStorage(settings); + app._updateCjkInputState(); + app._updateLocalEchoState(); + app.terminal.reset(); + app._localEchoOverlay.clear(); + window.__rememberedPromptRow = Math.max(2, app.terminal.rows - 5); + await new Promise((resolve) => { + app.terminal.write(`\x1b[2J\x1b[H\x1b[${window.__rememberedPromptRow + 1};1H\u276f `, resolve); + }); + window.__promptAnchorCaptured = app._captureLocalEchoPromptAnchor?.(); + app.terminal._core.coreService.triggerDataEvent('stringA', true); + app.terminal._core.coreService.triggerDataEvent('\r', true); + + await new Promise((resolve) => { + app.terminal.write('\x1b[2J\x1b[H\u276f old submitted input\x1b[H', resolve); + }); + app.terminal.focus(); + }); + + await page.keyboard.type('first draft'); + + const state = await page.evaluate(() => ({ + pendingText: app._localEchoOverlay?.pendingText, + promptRow: app._localEchoOverlay?.state.promptPosition?.row, + expectedRow: window.__rememberedPromptRow, + captured: window.__promptAnchorCaptured, + sentInputs: window.__sentInputs, + })); + + expect(state).toMatchObject({ + pendingText: 'first draft', + captured: true, + promptRow: state.expectedRow, + sentInputs: ['stringA', '\r'], + }); + }); + + it('does not paint a mobile draft over output while the resized prompt frame is pending', async () => { + await page.evaluate(async () => { + app.activeSessionId = 'mobile-occupied-anchor-test'; + app.sessions.set('mobile-occupied-anchor-test', { + id: 'mobile-occupied-anchor-test', + mode: 'codex', + status: 'running', + }); + app.hideWelcome(); + const settings = app.loadAppSettingsFromStorage(); + settings.cjkInputEnabled = false; + settings.localEchoEnabled = true; + app.saveAppSettingsToStorage(settings); + app._updateCjkInputState(); + app._updateLocalEchoState(); + app.terminal.reset(); + app._localEchoOverlay.clear(); + window.__occupiedAnchorRow = Math.max(2, app.terminal.rows - 3); + await new Promise((resolve) => { + app.terminal.write(`\x1b[2J\x1b[H\x1b[${window.__occupiedAnchorRow + 1};1H\u276f `, resolve); + }); + window.__occupiedAnchorCaptured = app._captureLocalEchoPromptAnchor?.(); + await new Promise((resolve) => { + app.terminal.write(`\x1b[2J\x1b[Hagent output\x1b[${window.__occupiedAnchorRow + 1};1Hstill output`, resolve); + }); + app.terminal.focus(); + }); + + await page.keyboard.type('held draft'); + + const pendingFrame = await page.evaluate(() => ({ + captured: window.__occupiedAnchorCaptured, + pendingText: app._localEchoOverlay.pendingText, + visible: app._localEchoOverlay.state.visible, + promptRow: app._localEchoOverlay.state.promptPosition?.row ?? null, + })); + expect(pendingFrame).toEqual({ + captured: true, + pendingText: 'held draft', + visible: false, + promptRow: null, + }); + + await page.evaluate(async () => { + await new Promise((resolve) => { + app.terminal.write(`\x1b[2J\x1b[H\x1b[${window.__occupiedAnchorRow + 1};1H\u276f `, resolve); + }); + app._localEchoOverlay.rerender(); + }); + + await expect + .poll(() => page.evaluate(() => app._localEchoOverlay.state.promptPosition?.row)) + .toBe(await page.evaluate(() => window.__occupiedAnchorRow)); + await expect.poll(() => page.evaluate(() => app._localEchoOverlay.state.visible)).toBe(true); + }); + + it('follows the live mobile cursor when it diverges from a remembered anchor', async () => { + await page.evaluate(async () => { + app.activeSessionId = 'mobile-remembered-anchor-drift-test'; + app.sessions.set('mobile-remembered-anchor-drift-test', { + id: 'mobile-remembered-anchor-drift-test', + mode: 'claude', + status: 'running', + }); + app.hideWelcome(); + const settings = app.loadAppSettingsFromStorage(); + settings.cjkInputEnabled = false; + settings.localEchoEnabled = true; + app.saveAppSettingsToStorage(settings); + app._updateCjkInputState(); + app._updateLocalEchoState(); + app.terminal.reset(); + app._localEchoOverlay.clear(); + window.__stableAnchorRow = Math.max(2, app.terminal.rows - 3); + window.__driftCursorRow = Math.max(2, app.terminal.rows - 8); + await new Promise((resolve) => { + app.terminal.write(`\x1b[2J\x1b[H\x1b[${window.__stableAnchorRow + 1};1H\u276f `, resolve); + }); + window.__stableAnchorCaptured = app._captureLocalEchoPromptAnchor?.(); + await new Promise((resolve) => { + app.terminal.write(`\x1b[2J\x1b[Hagent output\x1b[${window.__driftCursorRow + 1};1H`, resolve); + }); + app.terminal.focus(); + }); + + await page.keyboard.type('stable draft'); + + const state = await page.evaluate(() => ({ + captured: window.__stableAnchorCaptured, + promptRow: app._localEchoOverlay.state.promptPosition?.row, + expectedRow: window.__driftCursorRow, + })); + expect(state).toMatchObject({ + captured: true, + promptRow: state.expectedRow, + }); + }); }); // ── Cross-device keyboard behavior ──────────────────────────────────── diff --git a/test/mobile/navigation-pad.test.ts b/test/mobile/navigation-pad.test.ts index 9c86957c..a930ea3d 100644 --- a/test/mobile/navigation-pad.test.ts +++ b/test/mobile/navigation-pad.test.ts @@ -48,8 +48,12 @@ describe('Mobile Navigation Pad', () => { resizeLog.length = 0; await page.evaluate(` document.getElementById('appSettingsModal')?.classList.remove('active'); - document.body.classList.remove('keyboard-visible'); + document.body.classList.remove('keyboard-visible', 'keyboard-opening'); KeyboardHandler.keyboardVisible = false; + KeyboardHandler._terminalInputRequested = false; + clearTimeout(KeyboardHandler._keyboardOpeningTimer); + KeyboardHandler._keyboardOpeningTimer = null; + KeyboardHandler._discardTerminalFrameCover?.(); app.sendResize = function(_sessionId, options = {}) { window.__recordMobileNavigationResize(options); return Promise.resolve(true); @@ -88,8 +92,9 @@ describe('Mobile Navigation Pad', () => { expect((navBox?.y ?? 0) + (navBox?.height ?? 0)).toBeLessThanOrEqual((toolbarBox?.y ?? 0) + 1); expect(navBox?.height).toBe(48); - const buttons = navigation.locator('button'); - expect(await buttons.count()).toBe(5); + expect(await navigation.locator('button').count()).toBe(6); + expect(await navigation.locator('[data-nav-key="jump-bottom"]').isHidden()).toBe(true); + const buttons = navigation.locator('button:not([data-nav-key="jump-bottom"])'); const buttonBoxes = []; for (let index = 0; index < 5; index++) { const box = await buttons.nth(index).boundingBox(); @@ -139,6 +144,44 @@ describe('Mobile Navigation Pad', () => { } }); + it('shows jump-to-latest above the three central controls only while reading history', async () => { + const navigation = page.locator(SELECTORS.MOBILE_NAVIGATION); + const jump = navigation.locator('[data-nav-key="jump-bottom"]'); + + await page.evaluate(async () => { + app.terminal.reset(); + const lines = Array.from({ length: app.terminal.rows + 24 }, (_, index) => `history line ${index + 1}`).join( + '\r\n' + ); + await new Promise((resolve) => app.terminal.write(lines, resolve)); + }); + expect(await jump.isHidden()).toBe(true); + + await page.evaluate(() => app._scrollTerminalLines(-8)); + await page.waitForFunction( + () => !(document.querySelector('[data-nav-key="jump-bottom"]') as HTMLButtonElement)?.hidden + ); + + const navBox = await navigation.boundingBox(); + const jumpBox = await jump.boundingBox(); + const upBox = await navigation.locator('[data-nav-key="up"]').boundingBox(); + const downBox = await navigation.locator('[data-nav-key="down"]').boundingBox(); + expect(navBox).not.toBeNull(); + expect(jumpBox).not.toBeNull(); + expect(jumpBox?.height).toBeGreaterThanOrEqual(44); + expect((jumpBox?.y ?? 0) + (jumpBox?.height ?? 0)).toBeLessThanOrEqual((navBox?.y ?? 0) - 4); + + const navigationCenter = (navBox?.x ?? 0) + (navBox?.width ?? 0) / 2; + const centralControlsCenter = (upBox?.x ?? 0) + ((downBox?.x ?? 0) + (downBox?.width ?? 0) - (upBox?.x ?? 0)) / 2; + const jumpCenter = (jumpBox?.x ?? 0) + (jumpBox?.width ?? 0) / 2; + expect(Math.abs(navigationCenter - centralControlsCenter)).toBeLessThanOrEqual(1); + expect(Math.abs(jumpCenter - centralControlsCenter)).toBeLessThanOrEqual(1); + + await jump.click(); + await page.waitForFunction(() => app.isTerminalAtBottom()); + expect(await jump.isHidden()).toBe(true); + }); + it('sends raw Up without focusing xterm or showing the keyboard', async () => { await page.evaluate(` app.terminal.focus(); @@ -361,6 +404,7 @@ describe('Mobile Navigation Pad', () => { const viewportHeight = await page.evaluate(() => window.visualViewport?.height); await page.evaluate(` KeyboardHandler.keyboardVisible = true; + KeyboardHandler._terminalInputRequested = true; document.body.classList.add('keyboard-visible'); KeyboardHandler.onKeyboardShow(); `); @@ -406,7 +450,12 @@ describe('Mobile Navigation Pad', () => { expect(keyboardLayout.primaryButtonsContained).toBe(true); await vi.waitFor(() => { - expect(resizeLog).toContainEqual({ takeControl: true, refit: false }); + expect(resizeLog).toContainEqual( + expect.objectContaining({ + takeControl: true, + refit: false, + }) + ); }); resizeLog.length = 0; inputLog.length = 0; diff --git a/test/mobile/vitest.config.ts b/test/mobile/vitest.config.ts index 7b5cfb8b..a5ed8e9e 100644 --- a/test/mobile/vitest.config.ts +++ b/test/mobile/vitest.config.ts @@ -12,6 +12,7 @@ export default defineConfig({ setupFiles: ['./test/setup.ts', './test/mobile/setup.ts'], fileParallelism: false, testTimeout: 60_000, + hookTimeout: 60_000, teardownTimeout: 60_000, }, }); diff --git a/test/server-init-lifecycle.test.ts b/test/server-init-lifecycle.test.ts new file mode 100644 index 00000000..02da7daa --- /dev/null +++ b/test/server-init-lifecycle.test.ts @@ -0,0 +1,144 @@ +/** + * @fileoverview Client init lifecycle regression tests. + * + * A same-process SSE reconnect must reconcile metadata without destroying and + * replaying the active terminal. A changed process epoch must persist input and + * reload once so an already-open browser does not keep running stale assets. + */ +import { readFileSync } from 'node:fs'; +import { performance } from 'node:perf_hooks'; +import { resolve } from 'node:path'; +import vm from 'node:vm'; +import { describe, expect, it, vi } from 'vitest'; + +type InitAction = 'legacy' | 'initial' | 'reconnect' | 'reload'; + +type InitApp = { + _serverStartedAt: number | null; + _serverReloadRequested: boolean; + _serverRestartRecovery: boolean; + _initGeneration: number; + _handleServerInitEpoch: (serverStartedAt: unknown) => InitAction; + _reconcileSameServerInit: (data: unknown) => void; + handleInit: (data: Record) => void; + _onSessionTerminal: (data: { id: string; data: string }) => void; + _clearTimer: ReturnType; + _captureActiveSessionDraft: ReturnType; + _persistReliableNow: ReturnType; + _releaseTerminalTransportFreeze: ReturnType; + batchTerminalWrite: ReturnType; + activeSessionId: string | null; + _inputState: { persistNow: ReturnType }; + _resetAllAppState: ReturnType; +}; + +function loadHarness() { + const reload = vi.fn(); + const sessionStorage = { + getItem: vi.fn(), + setItem: vi.fn(), + removeItem: vi.fn(), + }; + const constants = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8'); + const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8'); + const context = vm.createContext({ + console, + performance, + setInterval: vi.fn(), + clearInterval: vi.fn(), + setTimeout: vi.fn(), + clearTimeout: vi.fn(), + requestAnimationFrame: vi.fn(), + HTMLCanvasElement: class HTMLCanvasElement {}, + location: { protocol: 'https:', host: 'test.local' }, + fetch: vi.fn(), + document: { addEventListener: vi.fn() }, + localStorage: { + length: 0, + key: vi.fn(), + getItem: vi.fn(), + setItem: vi.fn(), + removeItem: vi.fn(), + }, + sessionStorage, + window: { + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + location: { reload }, + }, + MobileDetection: {}, + }); + vm.runInContext(`${constants}\n${source}\nglobalThis.__CodemanApp = CodemanApp;`, context); + const CodemanApp = (context as { __CodemanApp: new () => unknown }).__CodemanApp; + return { CodemanApp, reload, sessionStorage }; +} + +function makeApp(CodemanApp: new () => unknown): InitApp { + const app = Object.create((CodemanApp as { prototype: object }).prototype) as InitApp; + app._serverStartedAt = null; + app._serverReloadRequested = false; + app._serverRestartRecovery = false; + app._initGeneration = 1; + app._clearTimer = vi.fn(); + app._captureActiveSessionDraft = vi.fn(); + app._persistReliableNow = vi.fn(); + app._releaseTerminalTransportFreeze = vi.fn(); + app.batchTerminalWrite = vi.fn(); + app.activeSessionId = 'session-1'; + app._inputState = { persistNow: vi.fn() }; + app._resetAllAppState = vi.fn(); + return app; +} + +describe('client server-init lifecycle', () => { + it('distinguishes initial load and same-process reconnect by server epoch', () => { + const { CodemanApp } = loadHarness(); + const app = makeApp(CodemanApp); + + expect(app._handleServerInitEpoch(undefined)).toBe('legacy'); + expect(app._handleServerInitEpoch(100)).toBe('initial'); + expect(app._handleServerInitEpoch(100)).toBe('reconnect'); + }); + + it('persists editable and queued input before reloading stale assets once', () => { + const { CodemanApp, reload, sessionStorage } = loadHarness(); + const app = makeApp(CodemanApp); + app._serverStartedAt = 100; + + expect(app._handleServerInitEpoch(200)).toBe('reload'); + expect(app._captureActiveSessionDraft).toHaveBeenCalledOnce(); + expect(app._inputState.persistNow).toHaveBeenCalledOnce(); + expect(app._persistReliableNow).toHaveBeenCalledOnce(); + expect(sessionStorage.setItem).toHaveBeenCalledWith('codeman-server-restart-recovery', '1'); + expect(reload).toHaveBeenCalledOnce(); + + expect(app._handleServerInitEpoch(300)).toBe('reload'); + expect(reload).toHaveBeenCalledOnce(); + }); + + it('routes a same-process init through metadata reconciliation without terminal reset', () => { + const { CodemanApp } = loadHarness(); + const app = makeApp(CodemanApp); + const reconcile = vi.fn(); + app._serverStartedAt = 100; + app._reconcileSameServerInit = reconcile; + + const snapshot = { serverStartedAt: 100, sessions: [] }; + app.handleInit(snapshot); + + expect(reconcile).toHaveBeenCalledWith(snapshot); + expect(app._releaseTerminalTransportFreeze).toHaveBeenCalledWith('session-1'); + expect(app._resetAllAppState).not.toHaveBeenCalled(); + expect(app._initGeneration).toBe(1); + }); + + it('drops terminal output after a replacement page has been requested', () => { + const { CodemanApp } = loadHarness(); + const app = makeApp(CodemanApp); + app._serverReloadRequested = true; + + app._onSessionTerminal({ id: 'session-1', data: 'stale reconnect redraw' }); + + expect(app.batchTerminalWrite).not.toHaveBeenCalled(); + }); +}); diff --git a/test/terminal-buffer-flush.test.ts b/test/terminal-buffer-flush.test.ts index c620fa3f..6f6e243c 100644 --- a/test/terminal-buffer-flush.test.ts +++ b/test/terminal-buffer-flush.test.ts @@ -41,6 +41,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() }, @@ -56,10 +58,48 @@ 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; + _terminalHistoryPageFromHeaders: (headers: Headers) => { + start: number; + end: number; + total: number; + hasMoreBefore: boolean; + hasMoreAfter: boolean; + origin: string; + } | null; + _readTerminalSnapshotResponse: ( + response: Response, + options?: { + paint?: boolean; + loadOwner?: string; + chunkSize?: number; + beforePaint?: () => void; + isCancelled?: () => boolean; + followBottom?: boolean; + } + ) => Promise<{ + terminalBuffer: string; + cursor?: TerminalCursor; + streamed: boolean; + painted: boolean; + aborted: boolean; + }>; +}; + +type TerminalCursor = { + stream: string; + generation: number; + start: number; + end: number; }; /** @@ -80,14 +120,24 @@ 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'], + _terminalHistoryPageFromHeaders: + mixin._terminalHistoryPageFromHeaders as BufferLoadApp['_terminalHistoryPageFromHeaders'], + _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)', () => { @@ -170,6 +220,167 @@ describe('buffer-load flush (COD-144)', () => { expect(writes).toEqual([]); }); + it('discards cursor-covered events and replays only a batch 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(['defgh', 'after']); + expect(app.batchTerminalWrite).toHaveBeenNthCalledWith(1, 'defgh', { + 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('drops stale stream/generation events and preserves output from 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('slices overlap inside the SSE synchronized-output wrapper', () => { + const { app, writes } = makeApp(); + const owner = app._beginBufferLoad('cursor-sync-overlap'); + pushWhileLoading(app, '\x1b[?2026habcdefgh\x1b[?2026l', { + 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']); + }); + + it('decodes a streamed snapshot losslessly across UTF-8 chunk boundaries while painting', async () => { + const { app } = makeApp(); + const text = 'alpha \u05e9\u05dc\u05d5\u05dd \ud83d\ude80 omega'; + const bytes = new TextEncoder().encode(text); + const wireChunks = [bytes.slice(0, 8), bytes.slice(8, 13), bytes.slice(13)]; + 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-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 painted: string[] = []; + const beforePaint = vi.fn(); + Object.assign(app, { + chunkedTerminalWrite: vi.fn(async (chunk: string) => { + painted.push(chunk); + }), + }); + + const result = await app._readTerminalSnapshotResponse(response, { + paint: true, + loadOwner: 'stream-load', + beforePaint, + followBottom: true, + }); + + expect(result.terminalBuffer).toBe(text); + expect(painted.join('')).toBe(text); + expect(beforePaint).toHaveBeenCalledOnce(); + expect(app.chunkedTerminalWrite).toHaveBeenCalledWith(expect.any(String), 32 * 1024, 'stream-load', { + followBottom: true, + }); + expect(result).toMatchObject({ + streamed: true, + painted: true, + aborted: false, + cursor: { stream: 'stream-a', generation: 4, start: 0, end: text.length }, + }); + }); + + it('exposes bounded history-page coordinates from streamed response headers', async () => { + const { app } = makeApp(); + const response = new Response('bounded history page', { + status: 200, + headers: { + 'x-codeman-terminal-format': 'stream-v1', + 'x-codeman-terminal-stream': 'stream-a', + 'x-codeman-terminal-generation': '2', + 'x-codeman-terminal-start': '100', + 'x-codeman-terminal-end': '120', + 'x-codeman-history-start': '3000', + 'x-codeman-history-end': '4000', + 'x-codeman-history-total': '9000', + 'x-codeman-history-more-before': '1', + 'x-codeman-history-more-after': '1', + 'x-codeman-history-origin': 'pane-1:origin', + }, + }); + + await expect(app._readTerminalSnapshotResponse(response)).resolves.toMatchObject({ + terminalBuffer: 'bounded history page', + historyPage: { + start: 3000, + end: 4000, + total: 9000, + hasMoreBefore: true, + hasMoreAfter: true, + origin: 'pane-1:origin', + }, + }); + }); + + 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, { paint: true })).resolves.toMatchObject({ + terminalBuffer: 'legacy snapshot', + truncated: false, + streamed: false, + painted: false, + }); + }); + it('repositions a pending local draft after the loaded frame becomes authoritative', () => { const { app } = makeApp(); const rerender = vi.fn(); diff --git a/test/terminal-flush-budget.test.ts b/test/terminal-flush-budget.test.ts index 54910730..7553024f 100644 --- a/test/terminal-flush-budget.test.ts +++ b/test/terminal-flush-budget.test.ts @@ -3,16 +3,32 @@ import { resolve } from 'node:path'; import vm from 'node:vm'; import { describe, expect, it, vi } from 'vitest'; -function loadTerminalUiHarness(mode: string) { +function loadTerminalUiHarness( + mode: string, + { + deviceType = 'desktop', + visibilityState = 'visible', + }: { deviceType?: 'mobile' | 'tablet' | 'desktop'; visibilityState?: 'visible' | 'hidden' } = {} +) { const CodemanApp = function CodemanApp(this: any) {}; + const frameCallbacks: Array<() => void> = []; + const timeoutCallbacks: Array<() => void> = []; const context = vm.createContext({ window: {}, + document: { visibilityState }, CodemanApp, console: { warn: vi.fn(), log: vi.fn() }, _crashDiag: { log: vi.fn() }, performance: { now: () => 0 }, - requestAnimationFrame: (_fn: () => void) => 1, - setTimeout: (_fn: () => void) => 1, + MobileDetection: { getDeviceType: () => deviceType }, + requestAnimationFrame: (fn: () => void) => { + frameCallbacks.push(fn); + return 1; + }, + setTimeout: (fn: () => void) => { + timeoutCallbacks.push(fn); + return 1; + }, Blob: function Blob() {}, URL: { createObjectURL: () => 'blob:yield', @@ -23,6 +39,8 @@ function loadTerminalUiHarness(mode: string) { }, DEC_SYNC_STRIP_RE: /\x1b\[\?2026[hl]/g, TERMINAL_CHUNK_SIZE: 32 * 1024, + CODEX_POST_SWITCH_MAX_HOLD_MS: 1500, + CODEX_RESTART_RECOVERY_MAX_HOLD_MS: 3000, }); const code = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-ui.js'), 'utf8'); @@ -38,12 +56,15 @@ function loadTerminalUiHarness(mode: string) { app._workerYield = () => {}; app._chunkedWriteGen = 0; app.terminal = { - write: (data: string) => writes.push(data), + write: (data: string, callback?: () => void) => { + writes.push(data); + callback?.(); + }, scrollToBottom: () => {}, scrollToLine: () => {}, }; - return { app, writes }; + return { app, writes, frameCallbacks, timeoutCallbacks }; } describe('terminal flush budget', () => { @@ -89,6 +110,199 @@ describe('terminal flush budget', () => { expect(app.pendingWrites.join('')).toHaveLength(32 * 1024); }); + it('uses a display-frame-sized budget for Claude output on mobile', () => { + const { app, writes } = loadTerminalUiHarness('claude', { deviceType: 'mobile' }); + app.pendingWrites.push('x'.repeat(96 * 1024)); + + app.flushPendingWrites(); + + expect(writes).toHaveLength(1); + expect(writes[0]).toHaveLength(16 * 1024); + expect(app.pendingWrites.join('')).toHaveLength(80 * 1024); + }); + + it('publishes mobile output on complete synchronized-update boundaries', () => { + const { app, writes } = loadTerminalUiHarness('claude', { deviceType: 'mobile' }); + const block = (char: string) => `\x1b[?2026h${char.repeat(8 * 1024)}\x1b[?2026l`; + const first = block('a'); + const second = block('b'); + app.pendingWrites.push(first + second); + + app.flushPendingWrites(); + + expect(writes).toEqual([first]); + expect(app.pendingWrites).toEqual([second]); + }); + + it('does not schedule the next live chunk until xterm parses the current one', () => { + const { app, writes } = loadTerminalUiHarness('codex'); + const scheduled: Array<() => void> = []; + let writeDone: (() => void) | undefined; + app._safeYield = (callback: () => void) => { + scheduled.push(callback); + }; + app.terminal.write = (data: string, callback?: () => void) => { + writes.push(data); + writeDone = callback; + }; + app.pendingWrites.push('x'.repeat(64 * 1024)); + + app.flushPendingWrites(); + + expect(writes.map((write) => write.length)).toEqual([32 * 1024]); + expect(app.pendingWrites.join('')).toHaveLength(32 * 1024); + expect(scheduled).toEqual([]); + + writeDone?.(); + + expect(scheduled).toHaveLength(1); + }); + + it('keeps the Codex switch cover until post-attach redraw output becomes quiet', () => { + const { app } = loadTerminalUiHarness('codex'); + const scheduled: Array<() => void> = []; + const remove = vi.fn(); + app._safeYield = (callback: () => void) => { + scheduled.push(callback); + }; + app._terminalHistoryReplayCover = { remove }; + app._terminalHistoryReplayCoverOwner = 7; + app._terminalHistoryReplayCoverVersion = 1; + app._terminalHistoryReplayCoverComplete = true; + app._terminalHistoryReplayCoverCompleteAt = Date.now(); + app._terminalHistoryReplayCoverCheckScheduled = false; + app._terminalHistoryReplayFencePending = false; + app._terminalHistoryReplayQuietUntil = 0; + app._wsState = 'connected'; + app._isLoadingBuffer = false; + app._terminalWriteInFlight = null; + app.writeFrameScheduled = false; + app.pendingWrites = []; + + app._deferTerminalHistoryReplayCover('session-1', 500); + app._tryFinishTerminalHistoryReplayCover(); + + expect(app._terminalHistoryReplayQuietUntil).toBeGreaterThan(Date.now()); + expect(remove).not.toHaveBeenCalled(); + expect(scheduled).toHaveLength(1); + + // Once the quiet barrier expires, the existing xterm paint fence owns the + // final removal. Every redraw byte was parsed while the cover stayed opaque. + app._terminalHistoryReplayQuietUntil = 0; + while (scheduled.length > 0) scheduled.shift()?.(); + + expect(remove).toHaveBeenCalledOnce(); + expect(app._terminalHistoryReplayCover).toBeNull(); + }); + + it('invalidates an armed cover-removal fence when later Codex output arrives', () => { + const { app } = loadTerminalUiHarness('codex'); + let paintFence: (() => void) | undefined; + app.terminal.write = (_data: string, callback?: () => void) => { + paintFence = callback; + }; + app._terminalHistoryReplayCover = { remove: vi.fn() }; + app._terminalHistoryReplayCoverOwner = 7; + app._terminalHistoryReplayCoverVersion = 4; + app._terminalHistoryReplayCoverComplete = true; + app._terminalHistoryReplayCoverCompleteAt = Date.now(); + app._terminalHistoryReplayCoverCheckScheduled = false; + app._terminalHistoryReplayFencePending = false; + app._terminalHistoryReplayQuietUntil = 0; + app._wsState = 'connected'; + app._isLoadingBuffer = false; + app._terminalWriteInFlight = null; + app.writeFrameScheduled = false; + app.pendingWrites = []; + + app._tryFinishTerminalHistoryReplayCover(); + expect(paintFence).toBeTypeOf('function'); + expect(app._terminalHistoryReplayFencePending).toBe(true); + const armedVersion = app._terminalHistoryReplayCoverVersion; + + app._deferTerminalHistoryReplayCover('session-1', 500); + + expect(app._terminalHistoryReplayFencePending).toBe(false); + expect(app._terminalHistoryReplayCoverVersion).toBeGreaterThan(armedVersion); + }); + + it('allows restart recovery to hold a stable frame beyond the normal switch cap', () => { + const { app } = loadTerminalUiHarness('codex'); + const writes = vi.fn(); + app.terminal.write = writes; + app._serverRestartRecovery = true; + app._terminalHistoryReplayCover = { remove: vi.fn() }; + app._terminalHistoryReplayCoverOwner = 7; + app._terminalHistoryReplayCoverVersion = 1; + app._terminalHistoryReplayCoverComplete = true; + app._terminalHistoryReplayCoverCompleteAt = Date.now() - 2000; + app._terminalHistoryReplayCoverCheckScheduled = false; + app._terminalHistoryReplayFencePending = false; + app._terminalHistoryReplayQuietUntil = Date.now() + 500; + app._wsState = 'connected'; + app._isLoadingBuffer = false; + app._terminalWriteInFlight = null; + app.writeFrameScheduled = false; + app.pendingWrites = []; + + app._tryFinishTerminalHistoryReplayCover(); + + expect(writes).not.toHaveBeenCalled(); + }); + + it('holds a transport-loss cover until server init releases it', () => { + const { app } = loadTerminalUiHarness('codex'); + const remove = vi.fn(); + app._captureTerminalHistoryReplayCover = () => { + app._terminalHistoryReplayCover = { remove }; + app._terminalHistoryReplayCoverVersion += 1; + return true; + }; + app._terminalHistoryReplayCover = null; + app._terminalHistoryReplayCoverVersion = 0; + app._terminalHistoryReplayCoverCheckScheduled = false; + app._terminalHistoryReplayFencePending = false; + app._terminalHistoryReplayQuietUntil = 0; + + expect(app._freezeTerminalForTransportLoss('session-1')).toBe(true); + expect(app._terminalTransportFreezeSessionId).toBe('session-1'); + expect(app._terminalHistoryReplayCoverComplete).toBe(false); + + expect(app._releaseTerminalTransportFreeze('session-1', 500)).toBe(true); + expect(app._terminalTransportFreezeSessionId).toBeNull(); + expect(app._terminalHistoryReplayCoverComplete).toBe(true); + expect(app._terminalHistoryReplayQuietUntil).toBeGreaterThan(Date.now()); + expect(remove).not.toHaveBeenCalled(); + }); + + it('does not delay the history cover for Claude output', () => { + const { app } = loadTerminalUiHarness('claude'); + app._terminalHistoryReplayCover = { remove: vi.fn() }; + app._terminalHistoryReplayQuietUntil = 0; + + app._deferTerminalHistoryReplayCover('session-1', 500); + + expect(app._terminalHistoryReplayQuietUntil).toBe(0); + }); + + it('invalidates a stale live-write callback when a buffer replay takes ownership', () => { + const { app } = loadTerminalUiHarness('claude'); + let writeDone: (() => void) | undefined; + const rerender = vi.fn(); + app._localEchoOverlay = { hasPending: true, rerender }; + app.terminal.write = (_data: string, callback?: () => void) => { + writeDone = callback; + }; + app.pendingWrites.push('old session output'); + + app.flushPendingWrites(); + app._beginBufferLoad('new-session-replay'); + writeDone?.(); + + expect(app._terminalWriteInFlight).toBeNull(); + expect(rerender).not.toHaveBeenCalled(); + }); + it('repositions a pending local draft only after xterm applies agent output', () => { const { app, writes } = loadTerminalUiHarness('claude'); let writeDone: (() => void) | undefined; @@ -140,6 +354,87 @@ describe('terminal flush budget', () => { expect(finishBufferLoad).toHaveBeenCalledOnce(); }); + it('waits for each replay chunk to parse before yielding the next one', async () => { + const { app, writes } = loadTerminalUiHarness('codex'); + const scheduled: Array<() => void> = []; + const writeCallbacks: Array<() => void> = []; + app._safeYield = (callback: () => void) => { + scheduled.push(callback); + }; + app.terminal.write = (data: string, callback?: () => void) => { + writes.push(data); + if (callback) writeCallbacks.push(callback); + }; + + const promise = app.chunkedTerminalWrite('x'.repeat(64 * 1024), 32 * 1024); + expect(scheduled).toHaveLength(1); + + scheduled.shift()?.(); + expect(writes.map((write) => write.length)).toEqual([32 * 1024]); + expect(scheduled).toHaveLength(0); + + writeCallbacks.shift()?.(); + expect(scheduled).toHaveLength(1); + + scheduled.shift()?.(); + expect(writes.map((write) => write.length)).toEqual([32 * 1024, 32 * 1024]); + + writeCallbacks.shift()?.(); + scheduled.shift()?.(); + scheduled.shift()?.(); + await promise; + }); + + it('keeps the viewport at the bottom after every parsed history chunk', async () => { + const { app, writes } = loadTerminalUiHarness('codex'); + const scheduled: Array<() => void> = []; + const scrollToBottom = vi.fn(); + app._safeYield = (callback: () => void) => { + scheduled.push(callback); + }; + app.terminal.scrollToBottom = scrollToBottom; + + const promise = app.chunkedTerminalWrite('x'.repeat(64 * 1024), 32 * 1024, 'history-load', { followBottom: true }); + while (scheduled.length > 0) { + scheduled.shift()?.(); + } + await promise; + + expect(writes.map((write) => write.length)).toEqual([32 * 1024, 32 * 1024]); + expect(scrollToBottom).toHaveBeenCalledTimes(2); + }); + + it('uses animation frames instead of the Worker while the page is visible', () => { + const { app, frameCallbacks, timeoutCallbacks } = loadTerminalUiHarness('claude'); + const callback = vi.fn(); + app._workerYield = vi.fn(); + + app._safeYield(callback); + + expect(frameCallbacks).toHaveLength(1); + expect(timeoutCallbacks).toHaveLength(1); + expect(app._workerYield).not.toHaveBeenCalled(); + + frameCallbacks.shift()?.(); + timeoutCallbacks.shift()?.(); + + expect(callback).toHaveBeenCalledOnce(); + }); + + it('uses the Worker wake-up path while the page is hidden', () => { + const { app, frameCallbacks, timeoutCallbacks } = loadTerminalUiHarness('claude', { + visibilityState: 'hidden', + }); + const callback = vi.fn(); + app._workerYield = vi.fn(); + + app._safeYield(callback); + + expect(frameCallbacks).toHaveLength(0); + expect(timeoutCallbacks).toHaveLength(1); + expect(app._workerYield).toHaveBeenCalledOnce(); + }); + it('leaves a caller-owned buffer load open after replaying a chunk', async () => { const { app } = loadTerminalUiHarness('codex'); const beginBufferLoad = vi.fn(() => 'nested-owner'); @@ -185,9 +480,11 @@ describe('terminal flush budget', () => { it('restores the user scroll position when Codex Working redraws move the viewport', () => { const { app } = loadTerminalUiHarness('codex'); const buffer = { viewportY: 40, baseY: 100 }; + let writeDone: (() => void) | undefined; app.terminal.buffer = { active: buffer }; - app.terminal.write = vi.fn(() => { + app.terminal.write = vi.fn((_data: string, callback?: () => void) => { buffer.viewportY = buffer.baseY; + writeDone = callback; }); app.terminal.scrollToLine = vi.fn((line: number) => { buffer.viewportY = line; @@ -198,6 +495,10 @@ describe('terminal flush budget', () => { app.flushPendingWrites(); + expect(buffer.viewportY).toBe(100); + + writeDone?.(); + expect(buffer.viewportY).toBe(40); }); diff --git a/test/terminal-history-paging.test.ts b/test/terminal-history-paging.test.ts new file mode 100644 index 00000000..83152101 --- /dev/null +++ b/test/terminal-history-paging.test.ts @@ -0,0 +1,48 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +describe('client terminal history paging', () => { + const appSource = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8'); + const terminalSource = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-ui.js'), 'utf8'); + const constantsSource = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8'); + + it('uses a bounded history page for cold selection instead of full history', () => { + const start = appSource.indexOf('async selectSession(sessionId'); + const body = appSource.slice(start, start + 18_000); + + expect(body).toContain('historyPage=1'); + expect(body).toContain('TERMINAL_HISTORY_PAGE_LINES'); + expect(body).not.toContain('terminal?full=1'); + expect(constantsSource).toContain('const TERMINAL_HISTORY_PAGE_LINES = 400;'); + }); + + it('loads older pages from terminal scroll events and keeps a bounded client window', () => { + expect(terminalSource).toContain('_maybeLoadTerminalHistoryPage'); + expect(terminalSource).toContain('_loadTerminalHistoryPage'); + expect(terminalSource).toContain('TERMINAL_HISTORY_WINDOW_PAGES'); + expect(terminalSource).toContain('historyPage=1'); + expect(terminalSource).toContain('const threshold = rows * 3;'); + expect(terminalSource).toContain('state.invalidated = true'); + expect(terminalSource).not.toContain('state.start = 0;\n state.end = state.total;'); + }); + + it('reconciles live output against the latest-frame cursor after a page rebuild', () => { + const start = terminalSource.indexOf('async _loadTerminalHistoryPage'); + const body = terminalSource.slice(start, start + 10_000); + + expect(start).toBeGreaterThan(-1); + expect(body).toContain('_beginBufferLoad'); + expect(body).toContain('snapshotCursor: latest.cursor'); + expect(body).toContain('flushQueued: true'); + }); + + it('uses a bounded tail and discards page coordinates when latest-frame composition fails', () => { + const start = appSource.indexOf('if (data.historyPage && !latestFrame?.terminalBuffer)'); + const body = appSource.slice(start, start + 1_800); + + expect(start).toBeGreaterThan(-1); + expect(body).toContain('tail=${TERMINAL_TAIL_SIZE}'); + expect(body).toContain('this._terminalHistoryPaging.delete(sessionId)'); + }); +}); diff --git a/test/terminal-touch-tap.test.ts b/test/terminal-touch-tap.test.ts index 7ed22bce..eb159913 100644 --- a/test/terminal-touch-tap.test.ts +++ b/test/terminal-touch-tap.test.ts @@ -126,6 +126,56 @@ function createTerminalGrid(lines: string[], cursorY: number, wrappedRows = new } describe('terminal touch tap mouse guard', () => { + it('returns local scrollback to the bottom and unlocks live-output following', () => { + const { app } = loadTerminalUiHarness(); + const scrollToBottom = vi.fn(); + app.activeSessionId = 'sess-1'; + app.sessions = new Map([['sess-1', { mode: 'codex', cliVersion: '1.0.0' }]]); + app.terminal = { scrollToBottom }; + app._terminalScrollLocked = true; + app._wasAtBottomBeforeWrite = false; + app.sendTerminalKey = vi.fn(); + + app.jumpTerminalToLatest(); + + expect(scrollToBottom).toHaveBeenCalledOnce(); + expect(app._terminalScrollLocked).toBe(false); + expect(app._wasAtBottomBeforeWrite).toBe(true); + expect(app.sendTerminalKey).not.toHaveBeenCalled(); + }); + + it('also asks Claude fullscreen history to jump to its latest message', () => { + const { app } = loadTerminalUiHarness(); + app.activeSessionId = 'sess-1'; + app.sessions = new Map([['sess-1', { mode: 'claude', cliVersion: '2.1.220' }]]); + app.terminal = { scrollToBottom: vi.fn() }; + app.sendTerminalKey = vi.fn(); + + app.jumpTerminalToLatest(); + + expect(app.sendTerminalKey).toHaveBeenCalledWith('\x1b[1;5F'); + }); + + it('tracks Claude-owned transcript history until jump-to-latest clears it', () => { + const { app } = loadTerminalUiHarness(); + app.activeSessionId = 'sess-1'; + app.sessions = new Map([['sess-1', { mode: 'claude', cliVersion: '2.1.220' }]]); + app.terminal = { + rows: 10, + buffer: { active: { viewportY: 20, baseY: 20 } }, + scrollToBottom: vi.fn(), + }; + app._clientPointToCell = () => ({ col: 1, row: 1 }); + app.sendTerminalKey = vi.fn(); + + app._sendSyntheticSgrWheel(8, 8, -3); + expect(app.isTerminalAtBottom()).toBe(true); + expect(app.isTerminalReadingHistory()).toBe(true); + + app.jumpTerminalToLatest(); + expect(app.isTerminalReadingHistory()).toBe(false); + }); + it('recognizes focus only when a terminal input owns the active element', () => { const { app, setActiveElement } = loadTerminalUiHarness(); const textarea = { classList: { contains: () => true } }; @@ -686,6 +736,30 @@ describe('terminal viewport sizing claims', () => { }); }); + it('does not force a second redraw when the trusted pointer selects a session tab', () => { + const { app, runFrame } = loadTerminalUiHarness(); + app.activeSessionId = 'sess-1'; + app.fitAddon = {}; + app.sendResize = vi.fn(() => Promise.resolve(true)); + + app._handleTerminalSizingPointerDown({ + isTrusted: true, + button: 0, + isPrimary: true, + target: { + closest: (selector: string) => + selector === '#terminalContainer, .session-tab' ? { className: 'session-tab' } : null, + }, + }); + runFrame(); + + expect(app.sendResize).toHaveBeenCalledOnce(); + expect(app.sendResize).toHaveBeenCalledWith('sess-1', { + takeControl: true, + refit: true, + }); + }); + it('sends a keyboard-open control key with one no-refit takeover', () => { const { app, setKeyboardVisible, cancelAnimationFrame } = loadTerminalUiHarness(); app.activeSessionId = 'sess-1'; diff --git a/test/terminal-viewport-resize.test.ts b/test/terminal-viewport-resize.test.ts index cd5a01e6..6715fc8c 100644 --- a/test/terminal-viewport-resize.test.ts +++ b/test/terminal-viewport-resize.test.ts @@ -36,7 +36,7 @@ async function freshPage(): Promise<{ context: BrowserContext; page: Page }> { } async function navigateAndWait(page: Page): Promise { - await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' }); + await page.goto(`${BASE_URL}?nowebgl`, { waitUntil: 'domcontentloaded' }); await page.waitForFunction(() => document.body.classList.contains('app-loaded'), { timeout: 5000, }); @@ -387,6 +387,114 @@ describe('Session initialization and viewport resize', () => { }, sessionId); }); + it('restores a recent busy session from its snapshot and streamed delta without refetching', async () => { + ({ context, page } = await freshPage()); + await navigateAndWait(page); + + const terminalRequests: string[] = []; + await page.route('**/api/sessions/*/terminal*', async (route) => { + const url = new URL(route.request().url()); + const sessionId = url.pathname.split('/')[3]; + terminalRequests.push(sessionId); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + data: { + terminalBuffer: `${sessionId} initial history\r\n${sessionId} prompt`, + truncated: false, + }, + }), + }); + }); + + await page.evaluate(async () => { + const app = ( + window as unknown as { + app: { + sessions: Map; + sessionOrder: string[]; + renderSessionTabs: () => void; + selectSession: (id: string) => Promise; + _onSessionTerminal: (data: { id: string; data: string }) => void; + }; + } + ).app; + app.sessions.set('warm-a', { + id: 'warm-a', + name: 'Warm A', + mode: 'codex', + status: 'working', + pid: 1, + workingDir: '/tmp', + }); + app.sessions.set('warm-b', { + id: 'warm-b', + name: 'Warm B', + mode: 'codex', + status: 'working', + pid: 1, + workingDir: '/tmp', + }); + app.sessionOrder = ['warm-a', 'warm-b']; + app.renderSessionTabs(); + + await app.selectSession('warm-a'); + await app.selectSession('warm-b'); + app._onSessionTerminal({ id: 'warm-a', data: '\r\nwarm-a streamed while away\r\n' }); + }); + + const requestsBeforeReturn = terminalRequests.length; + const restored = await page.evaluate(async () => { + const app = ( + window as unknown as { + app: { + selectSession: (id: string) => Promise; + terminal: { + buffer: { + active: { + length: number; + getLine: (index: number) => { translateToString: (trimRight: boolean) => string } | undefined; + }; + }; + }; + _warmTerminalCache: { ids: () => string[] }; + _terminalSseSubscriptionIds: () => string[]; + _wsReady: boolean; + _wsSessionId: string | null; + }; + } + ).app; + await app.selectSession('warm-a'); + const buffer = app.terminal.buffer.active; + const lines: string[] = []; + for (let index = 0; index < buffer.length; index++) { + lines.push(buffer.getLine(index)?.translateToString(true) || ''); + } + const previousWsReady = app._wsReady; + const previousWsSessionId = app._wsSessionId; + app._wsReady = true; + app._wsSessionId = 'warm-a'; + const sseIdsWithActiveWs = app._terminalSseSubscriptionIds(); + app._wsReady = previousWsReady; + app._wsSessionId = previousWsSessionId; + return { + text: lines.join('\n'), + warmIds: app._warmTerminalCache.ids(), + sseIdsWithActiveWs, + }; + }); + + expect(terminalRequests).toHaveLength(requestsBeforeReturn); + expect(terminalRequests).toEqual(['warm-a', 'warm-a', 'warm-b', 'warm-b']); + expect(restored.text).toContain('warm-a initial history'); + expect(restored.text).toContain('warm-a streamed while away'); + expect(restored.warmIds).toEqual(expect.arrayContaining(['warm-a', 'warm-b'])); + // The desired SSE filter keeps both sessions. The server suppresses warm-a + // while its WebSocket owns output and resumes it atomically on handoff. + expect(restored.sseIdsWithActiveWs).toEqual(expect.arrayContaining(['warm-a', 'warm-b'])); + }); + it('needsRefresh handler sends resize after restoring buffered output', async () => { ({ context, page } = await freshPage()); await navigateAndWait(page); @@ -439,6 +547,156 @@ describe('Session initialization and viewport resize', () => { expect(resizeCalls).toEqual([sessionId]); console.log(`[terminal-viewport-resize] needsRefresh triggered ${resizeCalls.length} resize call(s)`); }); + + it('cold-loads bounded history and preserves the reader anchor when prepending a page', async () => { + ({ context, page } = await freshPage()); + await navigateAndWait(page); + + const result = await page.evaluate(async () => { + const app = (window as unknown as { app: any }).app; + const sessionId = 'paged-history-browser-test'; + const originalFetch = window.fetch; + const originalSendResize = app.sendResize; + const originalConnectWs = app._connectWs; + const requested: string[] = []; + const latestFrame = '\x1b[1;1HLATEST PAGED FRAME' + '\x1b[2;1H› current prompt' + '\x1b[2;17H'; + const rows = (prefix: string, start: number, end: number) => + Array.from({ length: end - start }, (_, offset) => `${prefix}_${String(start + offset).padStart(4, '0')}`).join( + '\r\n' + ); + const recentPage = rows('RECENT', 800, 1200); + const olderPage = rows('OLDER', 400, 800); + const streamHeaders = (body: string, extra: Record = {}): Record => ({ + 'content-type': 'text/plain; charset=utf-8', + 'x-codeman-terminal-format': 'stream-v1', + 'x-codeman-terminal-stream': 'paged-browser-stream', + 'x-codeman-terminal-generation': '1', + 'x-codeman-terminal-start': '0', + 'x-codeman-terminal-end': String(body.length), + 'x-codeman-terminal-status': 'idle', + 'x-codeman-terminal-full-size': String(body.length), + 'x-codeman-terminal-truncated': '0', + 'x-codeman-terminal-source': 'mux-history-page', + ...extra, + }); + const pageHeaders = (body: string, start: number, end: number): Record => + streamHeaders(body, { + 'x-codeman-history-start': String(start), + 'x-codeman-history-end': String(end), + 'x-codeman-history-total': '1200', + 'x-codeman-history-more-before': start > 0 ? '1' : '0', + 'x-codeman-history-more-after': end < 1200 ? '1' : '0', + 'x-codeman-history-origin': 'stable-browser-origin', + }); + const visibleText = () => { + const buffer = app.terminal.buffer.active; + return Array.from( + { length: app.terminal.rows }, + (_, row) => buffer.getLine(buffer.viewportY + row)?.translateToString(true) || '' + ).join('\n'); + }; + + try { + app.sessions.set(sessionId, { + id: sessionId, + name: 'Paged browser history', + mode: 'codex', + status: 'idle', + pid: 1, + workingDir: '/tmp', + }); + app.sessionOrder = [sessionId]; + app.activeSessionId = null; + app.terminalBufferCache.delete(sessionId); + app._xtermSnapshots.delete(sessionId); + app._warmTerminalCache.remove(sessionId); + app._terminalHistoryPaging.delete(sessionId); + app.renderSessionTabs(); + + app.sendResize = async () => false; + app._connectWs = () => {}; + window.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (!url.includes(`/api/sessions/${sessionId}/terminal`)) { + return originalFetch.call(window, input, init); + } + requested.push(url); + if (url.includes('latest=1')) { + return new Response(latestFrame, { + status: 200, + headers: streamHeaders(latestFrame, { + 'x-codeman-terminal-source': 'mux-visible', + }), + }); + } + if (url.includes('before=800')) { + return new Response(olderPage, { + status: 200, + headers: pageHeaders(olderPage, 400, 800), + }); + } + return new Response(recentPage, { + status: 200, + headers: pageHeaders(recentPage, 800, 1200), + }); + }) as typeof window.fetch; + + await app.selectSession(sessionId); + const initialState = app._terminalHistoryPaging.get(sessionId); + const initialStart = initialState.start; + initialState.loading = true; + const buffer = app.terminal.buffer.active; + let markerRow = -1; + for (let row = 0; row < buffer.length; row++) { + if (buffer.getLine(row)?.translateToString(true).includes('RECENT_0800')) { + markerRow = row; + break; + } + } + if (markerRow >= 0) app.terminal.scrollToLine(markerRow); + await new Promise((resolve) => app.terminal.write('', resolve)); + const beforeText = visibleText(); + initialState.loading = false; + + const loaded = await app._loadTerminalHistoryPage('older'); + const afterState = app._terminalHistoryPaging.get(sessionId); + const afterText = visibleText(); + app.terminal.scrollToBottom(); + const latestText = visibleText(); + + return { + loaded, + requested, + initialStart, + markerRow, + afterStart: afterState.start, + afterEnd: afterState.end, + pageCount: afterState.pages.length, + beforeText, + afterText, + latestText, + }; + } finally { + window.fetch = originalFetch; + app.sendResize = originalSendResize; + app._connectWs = originalConnectWs; + app.sessions.delete(sessionId); + app._terminalHistoryPaging.delete(sessionId); + } + }); + + expect(result.loaded).toBe(true); + expect(result.requested.some((url: string) => url.includes('historyPage=1'))).toBe(true); + expect(result.requested.every((url: string) => !url.includes('full=1'))).toBe(true); + expect(result.initialStart).toBe(800); + expect(result.markerRow).toBeGreaterThanOrEqual(0); + expect(result.afterStart).toBe(400); + expect(result.afterEnd).toBe(1200); + expect(result.pageCount).toBe(2); + expect(result.beforeText).toContain('RECENT_0800'); + expect(result.afterText).toContain('RECENT_0800'); + expect(result.latestText).toContain('LATEST PAGED FRAME'); + }); }); describe('OpenCode close modal text', () => { diff --git a/test/warm-terminal-cache.test.ts b/test/warm-terminal-cache.test.ts new file mode 100644 index 00000000..afe2f119 --- /dev/null +++ b/test/warm-terminal-cache.test.ts @@ -0,0 +1,104 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import vm from 'node:vm'; +import { describe, expect, it } from 'vitest'; + +type WarmTerminalCache = { + activate: (sessionId: string, now?: number) => void; + append: (sessionId: string, data: string, now?: number) => boolean; + consume: (sessionId: string, now?: number) => string | null; + ids: (now?: number) => string[]; + isWarm: (sessionId: string, now?: number) => boolean; + nextExpiryDelay: (now?: number) => number | null; + remove: (sessionId: string) => void; +}; + +type WarmTerminalCacheConstructor = new (options?: { + limit?: number; + ttlMs?: number; + maxDeltaBytes?: number; +}) => WarmTerminalCache; + +function loadWarmTerminalCache(): WarmTerminalCacheConstructor { + const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8'); + const context = vm.createContext({ + console, + setTimeout, + clearTimeout, + }); + vm.runInContext(source, context, { filename: 'constants.js' }); + return (context as unknown as { WarmTerminalCache: WarmTerminalCacheConstructor }).WarmTerminalCache; +} + +describe('WarmTerminalCache', () => { + it('keeps the active session and a bounded recent-session range', () => { + const WarmTerminalCache = loadWarmTerminalCache(); + const cache = new WarmTerminalCache({ limit: 3, ttlMs: 30_000 }); + + cache.activate('a', 0); + cache.activate('b', 1_000); + cache.activate('c', 2_000); + cache.activate('d', 3_000); + + expect(cache.ids(3_000)).toEqual(['b', 'c', 'd']); + expect(cache.isWarm('a', 3_000)).toBe(false); + expect(cache.isWarm('d', 60_000)).toBe(true); + }); + + it('expires inactive sessions without expiring the active session', () => { + const WarmTerminalCache = loadWarmTerminalCache(); + const cache = new WarmTerminalCache({ ttlMs: 30_000 }); + + cache.activate('a', 10_000); + cache.activate('b', 20_000); + + expect(cache.nextExpiryDelay(20_000)).toBe(30_000); + expect(cache.ids(49_999)).toEqual(['a', 'b']); + expect(cache.ids(50_000)).toEqual(['b']); + expect(cache.nextExpiryDelay(50_000)).toBeNull(); + }); + + it('replays inactive deltas once and in arrival order', () => { + const WarmTerminalCache = loadWarmTerminalCache(); + const cache = new WarmTerminalCache({ maxDeltaBytes: 32 }); + + cache.activate('a', 0); + cache.activate('b', 1); + + expect(cache.append('a', 'first-', 2)).toBe(true); + expect(cache.append('a', 'second', 3)).toBe(true); + expect(cache.consume('a', 4)).toBe('first-second'); + expect(cache.consume('a', 5)).toBe(''); + }); + + it('invalidates an overflowing delta so the caller can fetch canonically', () => { + const WarmTerminalCache = loadWarmTerminalCache(); + const cache = new WarmTerminalCache({ maxDeltaBytes: 8 }); + + cache.activate('a', 0); + cache.activate('b', 1); + + expect(cache.append('a', '1234', 2)).toBe(true); + expect(cache.append('a', '56789', 3)).toBe(false); + expect(cache.isWarm('a', 3)).toBe(false); + expect(cache.consume('a', 3)).toBeNull(); + + cache.remove('a'); + expect(cache.ids(3)).toEqual(['b']); + }); + + it('is wired into inactive terminal delivery and session selection', () => { + const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8'); + const inactiveDelivery = source.indexOf('else if (this._warmTerminalCache.isWarm(data.id))'); + const targetCheck = source.indexOf('const targetWasWarm = this._warmTerminalCache.isWarm(sessionId);'); + const deltaReplay = source.indexOf('const warmDelta = this._warmTerminalCache.consume(sessionId);', targetCheck); + const fetchGate = source.indexOf('if (!usedWarmRestore)', deltaReplay); + const fetchStart = source.indexOf("FETCH_START'", fetchGate); + + expect(inactiveDelivery).toBeGreaterThan(-1); + expect(targetCheck).toBeGreaterThan(-1); + expect(deltaReplay).toBeGreaterThan(targetCheck); + expect(fetchGate).toBeGreaterThan(deltaReplay); + expect(fetchStart).toBeGreaterThan(fetchGate); + }); +}); diff --git a/test/ws-state-lifecycle.test.ts b/test/ws-state-lifecycle.test.ts index 39923f3e..835b7a86 100644 --- a/test/ws-state-lifecycle.test.ts +++ b/test/ws-state-lifecycle.test.ts @@ -106,6 +106,7 @@ type LifecycleApp = { _wsReady: boolean; _wsReconnectAttempts: number | undefined; _ws: FakeWebSocket | null; + _freezeTerminalForTransportLoss: ReturnType; activeSessionId: string | null; }; @@ -134,6 +135,7 @@ function makeApp( app.isOnline = true; app.sendResize = vi.fn(); app._onWsReady = vi.fn(); + app._freezeTerminalForTransportLoss = vi.fn(); Object.assign(app, overrides); return { app: app as LifecycleApp, els }; } @@ -257,6 +259,25 @@ describe('WS reconnect backoff — attempts survive the _connectWs → _disconne }); }); +describe('WS reconnect frame ownership', () => { + it('freezes the active Codex frame before a transient reconnect can deliver output', () => { + const { CodemanApp } = loadHarness(); + const freeze = vi.fn(); + const { app } = makeApp(CodemanApp, { + _freezeTerminalForTransportLoss: freeze, + }); + + app._connectWs('s1'); + const ws = FakeWebSocket.instances[0]; + ws.readyState = 1; + ws.onopen?.(); + ws.onclose?.({ code: 1006, reason: '' }); + + expect(freeze).toHaveBeenCalledOnce(); + expect(freeze).toHaveBeenCalledWith('s1'); + }); +}); + describe('WS upgrade cid — per-TAB identity (clientId:tabNonce)', () => { it('sends the composite cid on the upgrade URL, keeping the bare clientId for input frames', () => { const { CodemanApp } = loadHarness(); From 0511be34f531594822cf2a90cfa2a5c4778493d6 Mon Sep 17 00:00:00 2001 From: lior Date: Tue, 28 Jul 2026 05:02:37 +0300 Subject: [PATCH 33/49] feat(viewer): add repository and response inspection --- src/web/public/i18n.js | 11 + src/web/public/index.html | 54 ++- src/web/public/panels-ui.js | 746 ++++++++++++++++++++++++++++++-- test/mobile/file-viewer.test.ts | 575 ++++++++++++++++++++++++ test/response-viewer.test.ts | 84 ++++ 5 files changed, 1429 insertions(+), 41 deletions(-) create mode 100644 test/mobile/file-viewer.test.ts create mode 100644 test/response-viewer.test.ts diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index 0b35ef8e..48bd7c01 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -68,6 +68,17 @@ 'Open attachment history': '打开附件历史', 'File Viewer': '文件查看器', 'Open file viewer': '打开文件查看器', + Repository: '代码仓库', + 'Refresh repository': '刷新代码仓库', + 'Expand or collapse all directories': '展开或折叠所有目录', + 'Close file viewer': '关闭文件查看器', + 'Repository view': '代码仓库视图', + 'Repository worktree': '代码仓库工作树', + Changes: '更改', + 'Diff view': '差异视图', + Compact: '紧凑', + Full: '完整', + 'Close preview': '关闭预览', 'Open Codeman across all displays': '在所有显示器上打开 {name}', 'Ultracode / Workflow agents': 'Ultracode / Workflow 智能体', 'Open ultracode workflow agents': '打开 Ultracode 工作流智能体', diff --git a/src/web/public/index.html b/src/web/public/index.html index c81f3dfd..9b07a63c 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -134,7 +134,7 @@ - +
@@ -392,14 +392,24 @@

Resume Conversation

- Files + Repository
- - - + + + +
-