Repaint sidebar after child scrolls the host scroll region

Codex (Ratatui) emits an 8x RI burst on startup right after setting
DECSTBM. RI at the top of the scroll region scrolls the region down,
and DECSTBM only constrains rows -- so the scroll spans every column
and drags the right-rail session-tree entries down with the main pane.
The chrome cache then hid the clobber because the computed sidebar
frame was unchanged.

The viewport renderer now flags any chunk containing RI / IND / NEL /
SU / SD / IL / DL and OnPTYOut drops the sidebar cache when the flag
is set, so the next drawSidebar repaints over the drift.

Adds unit tests for the new flag and a harness regression scenario
(sidebar_survives_ri_scroll) that fails without the fix.
This commit is contained in:
2026-05-14 20:01:14 +01:00
parent 3622c41fd0
commit 58dbb56937
5 changed files with 151 additions and 0 deletions

View File

@@ -102,3 +102,55 @@ func TestViewportRendererTracksPrintableCursor(t *testing.T) {
t.Fatalf("clear-line after five chars should erase 15 cells: %q", got)
}
}
func TestViewportRendererFlagsRIAsScrolling(t *testing.T) {
// Reproduces the sidebar-gap bug: codex emits `\x1b[1;1H` followed
// by 8× `\x1bM` (RI) on startup. RI at the top of the host scroll
// region scrolls the region down — across all columns — pushing
// sidebar content out of place. The renderer must flag the chunk
// so the sidebar cache gets invalidated and repainted afterwards.
vr := newViewportRenderer(newTerminalLayout(120, 40))
if vr.TookScrollAction() {
t.Fatalf("scroll flag set before any input")
}
_ = vr.Render([]byte("\x1b[1;1H"))
if vr.TookScrollAction() {
t.Fatalf("plain CUP should not flag scroll")
}
_ = vr.Render([]byte("\x1bM"))
if !vr.TookScrollAction() {
t.Fatalf("RI (ESC M) should flag scroll")
}
if vr.TookScrollAction() {
t.Fatalf("flag should reset after read")
}
}
func TestViewportRendererFlagsScrollVerbs(t *testing.T) {
cases := map[string][]byte{
"IND": []byte("\x1bD"),
"NEL": []byte("\x1bE"),
"SU": []byte("\x1b[3S"),
"SD": []byte("\x1b[2T"),
}
for name, in := range cases {
t.Run(name, func(t *testing.T) {
vr := newViewportRenderer(newTerminalLayout(120, 40))
_ = vr.Render(in)
if !vr.TookScrollAction() {
t.Fatalf("%s should flag scroll", name)
}
})
}
}
func TestViewportRendererForwardsRIVerbatim(t *testing.T) {
// We rely on the host terminal performing the scroll inside the
// DECSTBM region; the renderer must not eat or transform RI. If a
// future change ever rewrites RI, this test catches the regression.
vr := newViewportRenderer(newTerminalLayout(120, 40))
got := string(vr.Render([]byte("\x1bM")))
if got != "\x1bM" {
t.Fatalf("RI should pass through unchanged: got %q", got)
}
}