code-yeongyu / code-yeongyu/senpi

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

Open
#1,704 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
TypeScript
Stars
429
Forks
98
Avg merge
5h 3m
Merged PRs (30d)
526

Description

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:
```ts
const focus = consumeTmuxFocusEvent(data);
if (focus.event !== null) {
resetCapabilitiesCache();
this.invalidate();
this.requestRender(true);
```
3. `:1479-1489` — `resetForcedRenderState()` 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:
```ts
// 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-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:

```sh
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:

```sh
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

- #1145 is the non-multiplexer sibling of the same code path (`\x1b[3J` + full redraw wiping scrollback on Windows Terminal). This report is the multiplexer path, where `3J` is suppressed but the full re-emission still floods tmux history.
- #1076 covers a different vertical-motion path (progressive transcript hydration).

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

Contributor guide

Open the contributing guide

Research direction

Start in packages/tui/src/tui.ts at handleTerminalInput, resetForcedRenderState(), fullRender(), and the existing renderMuxViewportRepaint() path. Reproduce the focus and 109→100→109 resize cases with the tmux commands in the report, then trace which render path emits the full transcript. Done means multiplexer focus and width events repaint the visible viewport without clearing or replaying the entire transcript into tmux scrollback.

Written by the indexing model from the issue text.

Assessment

Tech stack
typescript
Domain
cli
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
68/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.