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:
- a tmux focus event (
\x1b[I / \x1b[O) — both directions, even for a pane that is no longer visible;
- 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):
:1327 — focus reporting is enabled when inside tmux: if (process.env.TMUX) this.terminal.write(ENABLE_FOCUS_REPORTING);
: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);
:1479-1489 — resetForcedRenderState() sets previousWidth = -1 with the comment // -1 triggers widthChanged, forcing a full clear.
: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.
:2423-2436 — fullRender(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.
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:
\x1b[I/\x1b[O) — both directions, even for a pane that is no longer visible;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-limithistory.Environment
@code-yeongyu/senpi@2026.9.13(launched viaomo-ai@5.0.0-0.beta.62); same code present in2026.9.13-2@code-yeongyu/senpi-tui@2026.9.13(npm alias@earendil-works/pi-tui)xterm-ghostty, 220x47)focus-events on,allow-passthrough on,extended-keys on,window-size latest,aggressive-resize off,history-limit 100000TuiMainScreen,mode = "regular")Root cause
packages/tui/src/tui.ts(HEAD; identical in the publisheddist/tui.js)::1327— focus reporting is enabled when inside tmux:if (process.env.TMUX) this.terminal.write(ENABLE_FOCUS_REPORTING);:1536-1545(handleTerminalInput) — focus in and focus out are handled identically::1479-1489—resetForcedRenderState()setspreviousWidth = -1with the comment// -1 triggers widthChanged, forcing a full clear.:2487-2493— the forced render therefore takes the width-change branch:fullRenderloops over every line ofnewLines(:2435,for (let i = 0; i < newLines.length; i++)), i.e. the whole buffer, not the viewport.:2423-2436—fullRender(clear = true)writesdeleteKittyImages(previousKittyImageIds)then\x1b[2J\x1b[H, and (correctly) suppresses\x1b[3Junder 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:
Resize path:
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:\x1b[2J\r\n#{history_size}resize-pane -x 100then-x 109Control: on a scratch tmux server,
resize-windowalone withseq 1 5000in a pane lefthistory_sizeat 4,981 — so the churn comes from the app's re-emission, not from tmux resizing by itself.Rapid
tmux capture-panesampling (~31 ms) caught the pane mid-replay in 32-33 ms windows: no editor line, no status line, mid-transcript content on screen.Impact
history-limit 100000, ~1.7k lines replayed per event). A handful of pane zooms/session hops reshaped and shortened history during this investigation.deleteKittyImages()on both events.Proposed fix
resetCapabilitiesCache()plus a viewport repaint through the existingrenderMuxViewportRepaint(),:2246— neverrequestRender(true); and skip focus-out entirely, since the pane is not visible.PI_TUI_*) to disable focus-driven repaints would help users who hit this today.Related
\x1b[3J+ full redraw wiping scrollback on Windows Terminal). This report is the multiplexer path, where3Jis suppressed but the full re-emission still floods tmux history.Raw
pipe-paneescape captures and the frame captures are available on request.