Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 55 additions & 31 deletions docs/terminal-anti-flicker.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ PTY Output → Server Batching → DEC 2026 Wrap → SSE → Client rAF → Sync
| **3. SSE Broadcast** | `server.ts:broadcast()` | JSON serialize once, send to all clients | 0ms |
| **4. Client rAF** | `app.js:batchTerminalWrite()` | `requestAnimationFrame` batching | 0-16ms |
| **5. Sync Block Parser** | `app.js:extractSyncSegments()` | Strips DEC 2026 markers, waits for complete blocks | 0-50ms |
| **6. Chunked Loading** | `app.js:chunkedTerminalWrite()` | 64KB/frame for large buffers | variable |
| **6. Chunked Loading** | `terminal-ui.js:chunkedTerminalWrite()` | 32KB/frame for large buffers | variable |

## Server-Side Implementation (`server.ts`)

Expand Down Expand Up @@ -43,43 +43,27 @@ 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)`
### `chunkedTerminalWrite(buffer, chunkSize=32KB)`

- For large buffer restoration (session switch, reconnect)
- Writes 128KB per `requestAnimationFrame` to avoid UI jank
- Writes 32KB per safe yield to avoid UI jank
- Strips any embedded DEC 2026 markers from historical data

### `selectSession()` Optimizations
Expand All @@ -89,6 +73,46 @@ Note: Segments starting with `DEC_SYNC_START` are incomplete blocks awaiting mor
- Parallelizes session attach with buffer fetch
- Fire-and-forget resize (doesn't block tab switch)

## Mobile Keyboard Resize Cover

Phone keyboard animation changes xterm's row count before the foreground TUI
finishes its `SIGWINCH` redraw. During that interval the DOM renderer can expose
blank or partially reflowed rows even though terminal state is valid.

`KeyboardHandler` keeps the last painted `.xterm-rows` in an inert
`.terminal-resize-frame-cover` while the viewport settles. The clone is only a
visual cover: xterm remains the authoritative buffer, pointer events pass
through it, and shell sessions do not use it. The cover is armed immediately
before the resize and removed after xterm's parser callback, a short Codex
redraw quiet window, and two animation frames. A bounded expiry removes it when
no replacement output arrives; an active terminal buffer load keeps it in place
until that load finishes.

## Authoritative Mobile Frame Reconciliation

The cover hides transition frames, but it cannot determine whether xterm's
underlying pane is current. Touch-device Codex sessions therefore reconcile
after keyboard resizes, WebSocket attachment, and foreground dialogue
submission:

1. Coalesce overlapping requests so only the newest session/viewport transition
can publish a frame. A newer request aborts the older settle or fetch.
2. Hold live SSE/WS writes behind the existing buffer-load ownership gate.
3. Fetch the bounded 128KB current pane from
`/api/sessions/:id/terminal?latest=1&format=stream`.
4. Require a `mux-visible` source and terminal stream cursor, then cross xterm's
parser fence.
5. Clear only the viewport, preserving xterm scrollback, and paint the pane
inside one DEC 2026 synchronized update.
6. Replay only queued output after the snapshot cursor. Covered bytes are
discarded and a cursor-crossing event contributes only its uncovered suffix.
7. Release the visual cover after the authoritative pane and any pending local
input overlay have painted.

The stream endpoint and cursor metadata are the server contract; the client
keeps the JSON response as a compatibility fallback. Fetches have a bounded
timeout so a stalled capture cannot block later transitions indefinitely.

## Optional Flicker Filter

Per-session toggle via Session Settings. Adds ~50ms latency but eliminates remaining flicker on problematic terminals.
Expand Down Expand Up @@ -116,17 +140,16 @@ When detected, buffers 50ms of subsequent output before flushing atomically.

## Edge Cases

- **Incomplete sync blocks**: 50ms timeout forces flush (content discarded to prevent freeze)
- **Incomplete sync blocks**: xterm.js retains synchronized output until its closing marker
- **Large buffers**: Chunked writing prevents UI freeze
- **Server shutdown**: Skips batching via `_isStopping` flag
- **Session switch**: Clears flicker filter state, pending writes, and sync timeout (prevents cross-session data bleed)
- **SSE reconnect**: `handleInit()` clears all pending write state

**Trade-off:** If a sync block is split across SSE packets and the end marker doesn't arrive within 50ms, the incomplete content is discarded. This prioritizes responsiveness over completeness. In practice this is rare since the server always sends complete `SYNC_START...SYNC_END` pairs and SSE typically delivers them atomically.
- **Superseded frame capture**: Aborts its settle/fetch and releases its buffer-load gate

## 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

Expand All @@ -135,4 +158,5 @@ Terminals that natively support DEC 2026 will buffer and render atomically. Term
| File | Key Functions |
|------|---------------|
| `src/web/server.ts` | `batchTerminalData()`, `flushTerminalBatches()`, `broadcast()` |
| `src/web/public/app.js` | `batchTerminalWrite()`, `extractSyncSegments()`, `flushPendingWrites()`, `flushFlickerBuffer()`, `chunkedTerminalWrite()` |
| `src/web/public/terminal-ui.js` | `batchTerminalWrite()`, `flushPendingWrites()`, `chunkedTerminalWrite()`, `_requestTerminalFrameReconcile()` |
| `src/web/public/mobile-handlers.js` | `_beginTerminalFrameCover()`, `_armTerminalFrameCover()`, `onTerminalFrameAuthoritative()` |
63 changes: 57 additions & 6 deletions src/web/public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -619,6 +619,10 @@ class CodemanApp {
this._loadBufferQueue = null; // queued SSE events during buffer load
this._bufferLoadSeq = 0;
this._bufferLoadOwner = null;
this._terminalFrameReconcileSeq = 0;
this._terminalFrameReconcilePending = null;
this._terminalFrameReconcilePromise = null;
this._terminalFrameReconcileAbortController = null;

// Flicker filter state (buffers output after screen clears)
this.flickerFilterBuffer = '';
Expand Down Expand Up @@ -1654,7 +1658,7 @@ class CodemanApp {
return;
}

this.batchTerminalWrite(data.data);
this.batchTerminalWrite(data.data, data.cursor);
}
}

Expand Down Expand Up @@ -2321,7 +2325,21 @@ class CodemanApp {
// (re)connects AND registers the desktop sizing claim server-side —
// selectSession's earlier resizes ran before this WS existed, so they
// went over HTTP, which never claims (see ws-routes sizingToken).
this.sendResize(sessionId)?.catch?.(() => {});
const session = this.sessions?.get?.(sessionId);
const reconcileMobileCodex =
session?.mode === 'codex' &&
typeof MobileDetection !== 'undefined' &&
MobileDetection.isTouchDevice?.();
if (reconcileMobileCodex) {
void this._requestTerminalFrameReconcile?.({
captureWhenUnchanged: true,
reason: 'ws-attach',
resizeOptions: {},
settleMs: TUI_REDRAW_SETTLE_MS,
});
} else {
this.sendResize(sessionId)?.catch?.(() => {});
}
this._startMobileResizeRetry(sessionId);
// Flush any durably-queued input over the fresh socket (covers frames a
// prior half-open socket silently dropped, and input typed while offline).
Expand All @@ -2338,7 +2356,7 @@ class CodemanApp {
const msg = JSON.parse(event.data);
if (msg.t === 'o') {
// Terminal output — route through the same batching pipeline as SSE
this._onSessionTerminal({ id: sessionId, data: msg.d });
this._onSessionTerminal({ id: sessionId, data: msg.d, cursor: msg.cursor });
} else if (msg.t === 'c') {
this._onSessionClearTerminal({ id: sessionId });
} else if (msg.t === 'r') {
Expand Down Expand Up @@ -2478,6 +2496,15 @@ class CodemanApp {
*/
_sendInputAsync(sessionId, input, opts) {
if (!sessionId || !input) return;
if (this._shouldReconcileTerminalAction?.(sessionId, input)) {
if (typeof KeyboardHandler !== 'undefined') {
KeyboardHandler._beginTerminalFrameCover?.({ restart: true, arm: true });
}
void this._requestTerminalFrameReconcile?.({
reason: 'dialogue-selection',
settleMs: TUI_ACTION_REDRAW_SETTLE_MS,
});
}
this._reliableSend(sessionId, input, opts?.useMux === true);
}

Expand Down Expand Up @@ -2958,6 +2985,10 @@ class CodemanApp {
this._isLoadingBuffer = false;
this._loadBufferQueue = null;
this._bufferLoadOwner = null;
this._terminalFrameReconcileSeq = (this._terminalFrameReconcileSeq || 0) + 1;
this._terminalFrameReconcilePending = null;
this._terminalFrameReconcileAbortController?.abort?.();
this._terminalFrameReconcileAbortController = null;
// Abort any in-flight chunkedTerminalWrite (SSE reconnect reloads buffers)
this._chunkedWriteGen = (this._chunkedWriteGen || 0) + 1;
// Preserve local echo overlay text across SSE reconnect — just hide until
Expand Down Expand Up @@ -4053,6 +4084,22 @@ class CodemanApp {
}
const forceReload = options?.forceReload === true;
if (this.activeSessionId === sessionId && !forceReload) return;
const targetSession = this.sessions.get(sessionId);
const coverMobileCodex =
targetSession?.mode === 'codex' &&
typeof MobileDetection !== 'undefined' &&
MobileDetection.isTouchDevice?.();
if (
(this.activeSessionId !== sessionId || forceReload) &&
coverMobileCodex &&
typeof KeyboardHandler !== 'undefined'
) {
KeyboardHandler._beginTerminalFrameCover?.({
includeShell: true,
restart: true,
arm: true,
});
}
if (this.activeSessionId === sessionId && forceReload) {
this.terminalBufferCache?.delete(sessionId);
this._xtermSnapshots?.delete(sessionId);
Expand All @@ -4065,6 +4112,10 @@ class CodemanApp {
this._chunkedWriteGen = (this._chunkedWriteGen || 0) + 1;
this.activeSessionId = null;
}
this._terminalFrameReconcileSeq = (this._terminalFrameReconcileSeq || 0) + 1;
this._terminalFrameReconcilePending = null;
this._terminalFrameReconcileAbortController?.abort?.();
this._terminalFrameReconcileAbortController = null;
// Focus terminal SYNCHRONOUSLY before any await — iOS Safari only honors
// programmatic focus() within the user-gesture call stack (e.g. tab click).
// After the first await the gesture context is lost and focus() is silently
Expand Down Expand Up @@ -4359,9 +4410,9 @@ class CodemanApp {
bufferWasEmpty = true;
}

// Buffer load complete — unblock live SSE writes. chunkedTerminalWrite calls
// _finishBufferLoad internally (discarding queued events to prevent duplicate
// content); if we skipped the write (cache hit or empty), call it here.
// Buffer load complete — unblock live SSE writes. selectSession owns this
// gate across every cache/fetch/replay stage, so its chunked writes leave
// the transaction open and we finish it exactly once here.
// COD-144: when the load painted nothing, FLUSH the queued events instead of
// discarding — a new session's prompt arrives only as a queued SSE event.
if (this._isLoadingBuffer) {
Expand Down
3 changes: 3 additions & 0 deletions src/web/public/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,12 @@ const AUTO_CLOSE_NOTIFICATION_MS = 8000; // Auto-close browser notifications
const THROTTLE_DELAY_MS = 100; // General UI throttle delay
const TERMINAL_CHUNK_SIZE = 32 * 1024; // 32KB chunks for terminal buffer loading
const TERMINAL_TAIL_SIZE = 1024 * 1024; // 1MB tail for initial load (more scrollback on tab switch)
const TERMINAL_LATEST_FRAME_SIZE = 128 * 1024; // Bounded current-pane request shown before history replay
const TERMINAL_FRAME_FETCH_TIMEOUT_MS = 5000; // Bound a stale pane capture so later transitions can recover
const SYNC_WAIT_TIMEOUT_MS = 50; // Wait timeout for terminal sync
const STATS_POLLING_INTERVAL_MS = 2000; // System stats polling
const TUI_REDRAW_SETTLE_MS = 400; // Grace for a TUI to redraw after a real resize, before fetching its buffer
const TUI_ACTION_REDRAW_SETTLE_MS = 180; // Grace before capturing the pane after a decision is submitted

// Z-index base values for layered floating windows
const ZINDEX_SUBAGENT_BASE = 1000;
Expand Down
Loading