Skip to content

tmux: focus events and pane width changes replay the entire transcript (visible flash + scrollback churn) #1704

Description

@itokun99

Two ambient events make the main-screen TUI do a full clear plus a re-emission of the entire transcript buffer instead of just the viewport:

  1. a tmux focus event (\x1b[I / \x1b[O) — both directions, even for a pane that is no longer visible;
  2. any pane width change (a plain 109 → 100 → 109 round trip is enough).

In a tmux pane this is visible as a short flash where the editor/status region is missing while the pane scrolls through transcript content, and it pushes the whole transcript into the pane's scrollback on every event (≈1.7k lines per event for a ≈1.9k-line transcript), which churns and reshapes history-limit history.

Environment

senpi @code-yeongyu/senpi@2026.9.13 (launched via omo-ai@5.0.0-0.beta.62); same code present in 2026.9.13-2
TUI @code-yeongyu/senpi-tui@2026.9.13 (npm alias @earendil-works/pi-tui)
OS macOS, Darwin 25.6.0, arm64
Terminal Ghostty (tmux client xterm-ghostty, 220x47)
Multiplexer tmux 3.7c — focus-events on, allow-passthrough on, extended-keys on, window-size latest, aggressive-resize off, history-limit 100000
Layout window with two panes, 110x46 and 109x46
Mode interactive TUI, main screen (TuiMainScreen, mode = "regular")

Root cause

packages/tui/src/tui.ts (HEAD; identical in the published dist/tui.js):

  1. :1327 — focus reporting is enabled when inside tmux: if (process.env.TMUX) this.terminal.write(ENABLE_FOCUS_REPORTING);
  2. :1536-1545 (handleTerminalInput) — focus in and focus out are handled identically:
    const focus = consumeTmuxFocusEvent(data);
    if (focus.event !== null) {
        resetCapabilitiesCache();
        this.invalidate();
        this.requestRender(true);
  3. :1479-1489resetForcedRenderState() sets previousWidth = -1 with the comment // -1 triggers widthChanged, forcing a full clear.
  4. :2487-2493 — the forced render therefore takes the width-change branch:
    // In multiplexers, re-emit the viewport without 3J so pane history survives; an older copy may remain above.
    fullRender(true, !preserveMuxScrollback);
    The comment says "re-emit the viewport", but fullRender loops over every line of newLines (:2435, for (let i = 0; i < newLines.length; i++)), i.e. the whole buffer, not the viewport.
  5. :2423-2436fullRender(clear = true) writes deleteKittyImages(previousKittyImageIds) then \x1b[2J\x1b[H, and (correctly) suppresses \x1b[3J under a multiplexer.

So the multiplexer guard protects tmux history from being erased, but not from being flooded: re-emitting the whole buffer scrolls N lines into the pane's history on every event, and the scrollback churn grows with the transcript.

Reproduction

Deterministic, no key presses — inject exactly the bytes tmux sends:

tmux pipe-pane -t %9 -o 'cat >> /tmp/probe.log'
tmux send-keys -t %9 -H 1b 5b 4f    # focus-out
tmux send-keys -t %9 -H 1b 5b 49    # focus-in
tmux pipe-pane -t %9
grep -c $'\x1b\[2J' /tmp/probe.log  # => 2, one full clear per event

Resize path:

tmux resize-pane -t %9 -x 100; sleep 1; tmux resize-pane -t %9 -x 109

To see the visible artifact, capture the pane in a loop while the event runs; frames appear without the editor/status region while the pane is mid-replay.

Measured impact

Transcript ≈1.9k lines, pane 109x46, raw pane output captured with tmux pipe-pane:

event bytes written \x1b[2J \r\n #{history_size}
synthetic focus-out + focus-in 634,398 2 3,464 94,504 → 96,237 → 97,970 (+1,733 per event)
resize-pane -x 100 then -x 109 841,453 2 4,871 63,123 → 91,191 → 57,989 (net −5,134, transcript duplicated inside)
real pane switch, same geometry (109x46 ↔ 110x46) 240 0 0 unchanged

Control: on a scratch tmux server, resize-window alone with seq 1 5000 in a pane left history_size at 4,981 — so the churn comes from the app's re-emission, not from tmux resizing by itself.

Rapid tmux capture-pane sampling (~31 ms) caught the pane mid-replay in 32-33 ms windows: no editor line, no status line, mid-transcript content on screen.

Impact

  • Scrollback becomes a repeated copy of the transcript, and older real history is evicted (history-limit 100000, ~1.7k lines replayed per event). A handful of pane zooms/session hops reshaped and shortened history during this investigation.
  • Visible flash/scroll on every pane zoom, split resize, or session/window hop that changes pane geometry — and the cost scales with transcript length, so it is most noticeable right after a long session/execution.
  • Half the work is invisible: the focus-out event replays a pane nobody can see.
  • If the transcript contains kitty images, the full clear also emits deleteKittyImages() on both events.

Proposed fix

  • Main-screen mode inside a multiplexer: on a focus event, only resetCapabilitiesCache() plus a viewport repaint through the existing renderMuxViewportRepaint(), :2246 — never requestRender(true); and skip focus-out entirely, since the pane is not visible.
  • On a width change inside a multiplexer, repaint only the re-wrapped viewport (already-scrolled-off lines keeping their old wrapping is what the existing comment assumes) instead of re-emitting the whole buffer; or at least gate the full-buffer replay behind an env flag.
  • An escape hatch (e.g. PI_TUI_*) to disable focus-driven repaints would help users who hit this today.

Related

Raw pipe-pane escape captures and the frame captures are available on request.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions