fix(tui): preserve scrollback when hiding right bar
- commit
- 251604c1a63c3c4f783fc95685e2e2301b5fb6aa
- author
- brice · · 2026-08-05 14:50 UTC
- parent
- 2114cb7
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +40 -8
M packages/tui/render.go +12 -7
| @@ -37,12 +37,13 @@ type Renderer struct { | |||
| 37 | 37 | // in the terminal's visible viewport so we can diff safely, and bail | |
| 38 | 38 | // out to clear+replay when the diff would touch rows that are no | |
| 39 | 39 | // longer addressable. | |
| 40 | - | logChat []string | |
| 41 | - | logBottom []string | |
| 42 | - | logLines []string | |
| 43 | - | logViewportTop int | |
| 44 | - | logHardwareRow int | |
| 45 | - | logInit bool | |
| 40 | + | logChat []string | |
| 41 | + | logBottom []string | |
| 42 | + | logLines []string | |
| 43 | + | logViewportTop int | |
| 44 | + | logHardwareRow int | |
| 45 | + | logInit bool | |
| 46 | + | logNeedsFullRepaint bool | |
| 46 | 47 | ||
| 47 | 48 | // keepScrollback is true when we must NOT emit \x1b[3J | |
| 48 | 49 | // (erase-in-display 3, "clear scrollback"). | |
| @@ -588,8 +589,12 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int | |||
| 588 | 589 | wasInitialized := r.logInit | |
| 589 | 590 | full := !wasInitialized || len(r.logLines) == 0 | |
| 590 | 591 | if full { | |
| 591 | - | writeFull(true, !wasInitialized) | |
| 592 | + | // Returning from the fixed right-bar frame needs a full viewport | |
| 593 | + | // repaint, but it must not discard terminal scrollback merely because | |
| 594 | + | // the renderer's flow cache is intentionally empty. | |
| 595 | + | writeFull(true, !wasInitialized && !r.logNeedsFullRepaint) | |
| 592 | 596 | r.logInit = true | |
| 597 | + | r.logNeedsFullRepaint = false | |
| 593 | 598 | } else { | |
| 594 | 599 | firstChanged := -1 | |
| 595 | 600 | lastChanged := -1 | |
| @@ -37,12 +37,13 @@ type Renderer struct { | |||||
| 37 | // in the terminal's visible viewport so we can diff safely, and bail | 37 | // in the terminal's visible viewport so we can diff safely, and bail | ||
| 38 | // out to clear+replay when the diff would touch rows that are no | 38 | // out to clear+replay when the diff would touch rows that are no | ||
| 39 | // longer addressable. | 39 | // longer addressable. | ||
| 40 | - | logChat []string | 40 | + | logChat []string |
| 41 | - | logBottom []string | 41 | + | logBottom []string |
| 42 | - | logLines []string | 42 | + | logLines []string |
| 43 | - | logViewportTop int | 43 | + | logViewportTop int |
| 44 | - | logHardwareRow int | 44 | + | logHardwareRow int |
| 45 | - | logInit bool | 45 | + | logInit bool |
| 46 | + | logNeedsFullRepaint bool | |||
| 46 | 47 | ||||
| 47 | // keepScrollback is true when we must NOT emit \x1b[3J | 48 | // keepScrollback is true when we must NOT emit \x1b[3J | ||
| 48 | // (erase-in-display 3, "clear scrollback"). | 49 | // (erase-in-display 3, "clear scrollback"). | ||
| @@ -588,8 +589,12 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int | |||||
| 588 | wasInitialized := r.logInit | 589 | wasInitialized := r.logInit | ||
| 589 | full := !wasInitialized || len(r.logLines) == 0 | 590 | full := !wasInitialized || len(r.logLines) == 0 | ||
| 590 | if full { | 591 | if full { | ||
| 591 | - | writeFull(true, !wasInitialized) | 592 | + | // Returning from the fixed right-bar frame needs a full viewport |
| 593 | + | // repaint, but it must not discard terminal scrollback merely because | |||
| 594 | + | // the renderer's flow cache is intentionally empty. | |||
| 595 | + | writeFull(true, !wasInitialized && !r.logNeedsFullRepaint) | |||
| 592 | r.logInit = true | 596 | r.logInit = true | ||
| 597 | + | r.logNeedsFullRepaint = false | |||
| 593 | } else { | 598 | } else { | ||
| 594 | firstChanged := -1 | 599 | firstChanged := -1 | ||
| 595 | lastChanged := -1 | 600 | lastChanged := -1 | ||
M packages/tui/rightbar.go +3 -1
| @@ -401,12 +401,14 @@ func (r *Renderer) DrawRightBar(chat, bottom, rightBar []string, cursorBottomRow | |||
| 401 | 401 | ||
| 402 | 402 | // Draw() owns the fixed-frame cache. Reset the flow-mode cache so a | |
| 403 | 403 | // later fallback to DrawLog starts with a full repaint instead of trying | |
| 404 | - | // to diff against logical rows from before the rail was enabled. | |
| 404 | + | // to diff against logical rows from before the rail was enabled. Mark the | |
| 405 | + | // transition separately so that repaint does not clear scrollback. | |
| 405 | 406 | r.logChat = nil | |
| 406 | 407 | r.logBottom = nil | |
| 407 | 408 | r.logLines = nil | |
| 408 | 409 | r.logViewportTop = 0 | |
| 409 | 410 | r.logHardwareRow = 0 | |
| 410 | 411 | r.logInit = false | |
| 412 | + | r.logNeedsFullRepaint = true | |
| 411 | 413 | r.Draw(frame, cursorRow, cursorCol) | |
| 412 | 414 | } | |
| @@ -401,12 +401,14 @@ func (r *Renderer) DrawRightBar(chat, bottom, rightBar []string, cursorBottomRow | |||||
| 401 | 401 | ||||
| 402 | // Draw() owns the fixed-frame cache. Reset the flow-mode cache so a | 402 | // Draw() owns the fixed-frame cache. Reset the flow-mode cache so a | ||
| 403 | // later fallback to DrawLog starts with a full repaint instead of trying | 403 | // later fallback to DrawLog starts with a full repaint instead of trying | ||
| 404 | - | // to diff against logical rows from before the rail was enabled. | 404 | + | // to diff against logical rows from before the rail was enabled. Mark the |
| 405 | + | // transition separately so that repaint does not clear scrollback. | |||
| 405 | r.logChat = nil | 406 | r.logChat = nil | ||
| 406 | r.logBottom = nil | 407 | r.logBottom = nil | ||
| 407 | r.logLines = nil | 408 | r.logLines = nil | ||
| 408 | r.logViewportTop = 0 | 409 | r.logViewportTop = 0 | ||
| 409 | r.logHardwareRow = 0 | 410 | r.logHardwareRow = 0 | ||
| 410 | r.logInit = false | 411 | r.logInit = false | ||
| 412 | + | r.logNeedsFullRepaint = true | |||
| 411 | r.Draw(frame, cursorRow, cursorCol) | 413 | r.Draw(frame, cursorRow, cursorCol) | ||
| 412 | } | 414 | } | ||
M packages/tui/rightbar_test.go +25 -0
| @@ -184,6 +184,31 @@ func TestJoinRightBarPreservesCellBudgetForUnicode(t *testing.T) { | |||
| 184 | 184 | } | |
| 185 | 185 | } | |
| 186 | 186 | ||
| 187 | + | func TestRendererDrawRightBarToDrawLogKeepsScrollback(t *testing.T) { | |
| 188 | + | t.Setenv("TERM_PROGRAM", "") | |
| 189 | + | ||
| 190 | + | var out bytes.Buffer | |
| 191 | + | r := NewRenderer(&out) | |
| 192 | + | r.SetTheme(Dark) | |
| 193 | + | r.Resize(80, 5) | |
| 194 | + | r.DrawRightBar( | |
| 195 | + | []string{"chat"}, | |
| 196 | + | []string{"input"}, | |
| 197 | + | RenderRightBar(Dark, []RightBarWidget{{Extension: "plan", Title: "Plan", Lines: []string{"task"}}}, 26, 5), | |
| 198 | + | 0, | |
| 199 | + | 0, | |
| 200 | + | ) | |
| 201 | + | ||
| 202 | + | out.Reset() | |
| 203 | + | r.DrawLog([]string{"chat"}, []string{"input"}, 0, 0) | |
| 204 | + | if !strings.Contains(stripANSI(out.String()), "input") { | |
| 205 | + | t.Fatalf("right-bar fallback did not repaint flow content: %q", out.String()) | |
| 206 | + | } | |
| 207 | + | if strings.Contains(out.String(), SeqClearScrollback) { | |
| 208 | + | t.Fatalf("right-bar fallback cleared terminal scrollback: %q", out.String()) | |
| 209 | + | } | |
| 210 | + | } | |
| 211 | + | ||
| 187 | 212 | func TestRendererDrawRightBarUpdatesClearsAndResizes(t *testing.T) { | |
| 188 | 213 | var out bytes.Buffer | |
| 189 | 214 | r := NewRenderer(&out) | |
| @@ -184,6 +184,31 @@ func TestJoinRightBarPreservesCellBudgetForUnicode(t *testing.T) { | |||||
| 184 | } | 184 | } | ||
| 185 | } | 185 | } | ||
| 186 | 186 | ||||
| 187 | + | func TestRendererDrawRightBarToDrawLogKeepsScrollback(t *testing.T) { | |||
| 188 | + | t.Setenv("TERM_PROGRAM", "") | |||
| 189 | + | ||||
| 190 | + | var out bytes.Buffer | |||
| 191 | + | r := NewRenderer(&out) | |||
| 192 | + | r.SetTheme(Dark) | |||
| 193 | + | r.Resize(80, 5) | |||
| 194 | + | r.DrawRightBar( | |||
| 195 | + | []string{"chat"}, | |||
| 196 | + | []string{"input"}, | |||
| 197 | + | RenderRightBar(Dark, []RightBarWidget{{Extension: "plan", Title: "Plan", Lines: []string{"task"}}}, 26, 5), | |||
| 198 | + | 0, | |||
| 199 | + | 0, | |||
| 200 | + | ) | |||
| 201 | + | ||||
| 202 | + | out.Reset() | |||
| 203 | + | r.DrawLog([]string{"chat"}, []string{"input"}, 0, 0) | |||
| 204 | + | if !strings.Contains(stripANSI(out.String()), "input") { | |||
| 205 | + | t.Fatalf("right-bar fallback did not repaint flow content: %q", out.String()) | |||
| 206 | + | } | |||
| 207 | + | if strings.Contains(out.String(), SeqClearScrollback) { | |||
| 208 | + | t.Fatalf("right-bar fallback cleared terminal scrollback: %q", out.String()) | |||
| 209 | + | } | |||
| 210 | + | } | |||
| 211 | + | ||||
| 187 | func TestRendererDrawRightBarUpdatesClearsAndResizes(t *testing.T) { | 212 | func TestRendererDrawRightBarUpdatesClearsAndResizes(t *testing.T) { | ||
| 188 | var out bytes.Buffer | 213 | var out bytes.Buffer | ||
| 189 | r := NewRenderer(&out) | 214 | r := NewRenderer(&out) | ||