code-yeongyu / code-yeongyu/senpi
Windows: returning to the terminal window wipes scrollback and repaints from the top row
- Dominant language
- TypeScript
- Stars
- 429
- Forks
- 98
- Avg merge
- 5h 3m
- Merged PRs (30d)
- 526
Description
# Windows: returning to the terminal window wipes scrollback and repaints from the top row
## Environment
| | |
|---|---|
| senpi | `@code-yeongyu/senpi@2026.8.25` (installed via `omo-ai`) |
| TUI | `@earendil-works/pi-tui@2026.8.25` (npm alias of `@code-yeongyu/senpi-tui`) |
| OS | Windows 10 (kernel `Windows_NT 10.0.26200`), x64 |
| Terminal | Windows Terminal / ConPTY, **no tmux** |
| Mode | interactive TUI (`senpi` / `omo`) |
## Summary
On Windows, switching to another application and then coming back to the terminal
occasionally makes the whole session jump to the top: everything that was above the
current view is gone and the TUI is repainted starting at row 1. It looks like "the
terminal scrolled to the very top", but the scrollback is actually erased.
This is a resize-driven full redraw. The full-redraw path emits `ESC[2J ESC[H` plus
`ESC[3J`, and `ESC[3J` deletes the scrollback buffer. tmux users never see it because the
multiplexer path deliberately skips `ESC[3J`; a bare Windows Terminal has no such guard.
## Reproduction
1. Run the interactive TUI in Windows Terminal (no tmux).
2. Produce enough output that the session has real scrollback.
3. Switch to another window, wait, switch back to the terminal.
4. Intermittently: the view is at the top, prior scrollback is unrecoverable.
Not reliably reproducible on every focus change, which matches a transient console size
report rather than a deterministic code path.
## Root cause
Line numbers are from the published dist of `2026.8.25`
(`node_modules/@earendil-works/pi-tui/dist/...`); the source lives in `packages/tui/src/`.
### 1. The full redraw destroys scrollback
`dist/tui.js` `doRender()` -> `fullRender()` (1591-1600):
```js
const fullRender = (clear, clearScrollback = clear) => {
this.fullRedrawCount += 1;
let buffer = TUI.FRAME_BEGIN;
if (clear) {
buffer += this.deleteKittyImages(this.previousKittyImageIds);
buffer += "\x1b[2J\x1b[H";
if (clearScrollback && !preserveMuxScrollback) {
buffer += "\x1b[3J"; // <- erases the scrollback buffer
}
}
```
### 2. Any size change takes that path when not in a multiplexer
`dist/tui.js` 1656-1677:
```js
if (widthChanged) {
logRedraw(`terminal width changed (${this.previousWidth} -> ${width})`);
fullRender(true, !preserveMuxScrollback); // non-mux => clearScrollback = true
return;
}
if (heightChanged && !isTermuxSession()) {
logRedraw(`terminal height changed (${this.previousHeight} -> ${height})`);
if (preserveMuxScrollback) {
if (!this.renderMuxViewportRepaint(newLines, rawLines, cursorPos, width, height)) {
fullRender(true, false); // mux: no ESC[3J
}
}
else {
fullRender(true); // non-mux: ESC[3J
}
return;
}
```
`shouldPreserveMuxScrollback()` (1351) is `this.#muxDetector() && !useLegacyMuxRender()`,
so on plain Windows Terminal `preserveMuxScrollback === false` and both branches erase the
scrollback. The mux path already demonstrates that a resize can be handled with a viewport
repaint and without `ESC[3J`.
### 3. A transient size read is enough to trigger it
`dist/terminal.js` 460-465:
```js
get columns() {
return process.stdout.columns || Number(process.env.COLUMNS) || 80;
}
get rows() {
return process.stdout.rows || Number(process.env.LINES) || 24;
}
```
A single frame where `process.stdout.columns` reads as `0`/`undefined` silently becomes
`80` (or `24` rows), and `doRender()` sees that as a genuine
`widthChanged`/`heightChanged`, so the destructive branch runs even though the user never
resized the window.
Windows also has no size-refresh fallback: `dist/terminal.js` 168-174 re-reads dimensions
by re-raising `SIGWINCH`, guarded by `process.platform !== "win32"`. The only resize signal
on Windows is `process.stdout.on("resize", ...)` (167), wired to
`this.terminal.start(..., () => this.requestRender())` (`dist/tui.js` 656), and ConPTY does
emit buffer-size changes around window restore/focus transitions.
Note: `clearOnShrink` (1681) is **not** involved by default. Despite the comment mentioning
`PI_CLEAR_ON_SHRINK=0`, the flag is initialized as
`this.clearOnShrink = process.env.PI_CLEAR_ON_SHRINK === "1"` (326), so it is off unless
explicitly enabled.
## Diagnostic to confirm the exact branch
Run with `PI_DEBUG_REDRAW=1`, reproduce the window switch, then read
`/pi-debug.log` (default `~/.senpi/agent`, `dist/tui.js` 345 and 1644). Each
full redraw logs its reason:
```
fullRender: terminal width changed (120 -> 80) (prev=..., new=..., height=...)
fullRender: terminal height changed (30 -> 24) ...
```
A `-> 80` or `-> 24` value is direct proof of the fallback-driven false resize; any other
pair still identifies a resize-driven `ESC[3J`.
## Proposed fix
Two independent changes; either one alone removes the data loss, both together also remove
the spurious repaint.
**A. Never erase scrollback on a resize-driven repaint (all platforms).** Use the shape the
mux path already uses:
```diff
if (widthChanged) {
logRedraw(`terminal width changed (${this.previousWidth} -> ${width})`);
- fullRender(true, !preserveMuxScrollback);
+ if (!this.renderMuxViewportRepaint(newLines, rawLines, cursorPos, width, height)) {
+ fullRender(true, false);
+ }
return;
}
if (heightChanged && !isTermuxSession()) {
logRedraw(`terminal height changed (${this.previousHeight} -> ${height})`);
- if (preserveMuxScrollback) {
- if (!this.renderMuxViewportRepaint(newLines, rawLines, cursorPos, width, height)) {
- fullRender(true, false);
- }
- }
- else {
- fullRender(true);
- }
+ if (!this.renderMuxViewportRepaint(newLines, rawLines, cursorPos, width, height)) {
+ fullRender(true, false);
+ }
return;
}
```
A width change does need a reflowed repaint, but it never needs the history deleted;
`ESC[2J ESC[H` alone repaints the viewport and leaves prior output recoverable by scrolling.
**B. Do not let a falsy console size register as a resize.** Remember the last non-zero
value instead of falling back to 80x24 mid-session:
```diff
get columns() {
- return process.stdout.columns || Number(process.env.COLUMNS) || 80;
+ const reported = process.stdout.columns;
+ if (reported) this.lastKnownColumns = reported;
+ return this.lastKnownColumns || Number(process.env.COLUMNS) || 80;
}
get rows() {
- return process.stdout.rows || Number(process.env.LINES) || 24;
+ const reported = process.stdout.rows;
+ if (reported) this.lastKnownRows = reported;
+ return this.lastKnownRows || Number(process.env.LINES) || 24;
}
```
## Impact
Every Windows user running the TUI outside a multiplexer can lose the entire visible
history of a session by alt-tabbing. The loss is unrecoverable: `ESC[3J` drops the
terminal's own scrollback, so the transcript is only in the session file, not on screen.
## Workaround for users today
Run inside tmux (`shouldPreserveMuxScrollback()` then suppresses `ESC[3J`), or keep the
terminal window size and focus stable during a session.
## Source locations (verified against current `main`)
The analysis above quotes the published dist. The same code in this repository:
| What | Source |
|---|---|
| `fullRender()` / `clearScrollback && !preserveMuxScrollback` -> `ESC[3J` | `packages/tui/src/tui.ts` 2026-2046 |
| `widthChanged` branch -> `fullRender(true, !preserveMuxScrollback)` | `packages/tui/src/tui.ts` 1995, 2094-2097 |
| `heightChanged` branch (mux vs non-mux) | `packages/tui/src/tui.ts` 2104-2117 |
| `shouldPreserveMuxScrollback()` | `packages/tui/src/tui.ts` 1716 |
| `renderMuxViewportRepaint()` | `packages/tui/src/tui.ts` 1854 |
| `renderScrollbackReplay()` -> unconditional `ESC[3J` when not in a mux | `packages/tui/src/tui.ts` 1826-1827 |
| `clearOnShrink = process.env.PI_CLEAR_ON_SHRINK === "1"` (off by default) | `packages/tui/src/tui.ts` 579 |
| `get columns()` / `get rows()` 80x24 fallback | `packages/tui/src/terminal.ts` 676-682 |
Contributor guide
Research direction
Start with packages/tui/src/tui.ts, especially fullRender(), the widthChanged and heightChanged branches, renderMuxViewportRepaint(), and renderScrollbackReplay(); then inspect packages/tui/src/terminal.ts for the columns and rows getters. Run the Windows reproduction with PI_DEBUG_REDRAW=1 and compare the logged resize values. Done means resize-driven repainting no longer emits ESC[3J and transient falsy dimensions do not cause a false resize.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- cli
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 70/100