detached 251604c

fix(tui): preserve scrollback when hiding right bar

commit
251604c1a63c3c4f783fc95685e2e2301b5fb6aa
author
brice · · 2026-08-05 14:50 UTC
parent
2114cb7
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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 {
3737 // in the terminal's visible viewport so we can diff safely, and bail
3838 // out to clear+replay when the diff would touch rows that are no
3939 // 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
4647
4748 // keepScrollback is true when we must NOT emit \x1b[3J
4849 // (erase-in-display 3, "clear scrollback").
@@ -588,8 +589,12 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
588589 wasInitialized := r.logInit
589590 full := !wasInitialized || len(r.logLines) == 0
590591 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)
592596 r.logInit = true
597+ r.logNeedsFullRepaint = false
593598 } else {
594599 firstChanged := -1
595600 lastChanged := -1
@@ -37,12 +37,13 @@ type Renderer struct {
37 // in the terminal's visible viewport so we can diff safely, and bail37 // 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 no38 // out to clear+replay when the diff would touch rows that are no
39 // longer addressable.39 // longer addressable.
40- logChat []string40+ logChat []string
41- logBottom []string41+ logBottom []string
42- logLines []string42+ logLines []string
43- logViewportTop int43+ logViewportTop int
44- logHardwareRow int44+ logHardwareRow int
45- logInit bool45+ logInit bool
46+ logNeedsFullRepaint bool
46 47
47 // keepScrollback is true when we must NOT emit \x1b[3J48 // 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.logInit589 wasInitialized := r.logInit
589 full := !wasInitialized || len(r.logLines) == 0590 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 = true596 r.logInit = true
597+ r.logNeedsFullRepaint = false
593 } else {598 } else {
594 firstChanged := -1599 firstChanged := -1
595 lastChanged := -1600 lastChanged := -1
M packages/tui/rightbar.go +3 -1
@@ -401,12 +401,14 @@ func (r *Renderer) DrawRightBar(chat, bottom, rightBar []string, cursorBottomRow
401401
402402 // Draw() owns the fixed-frame cache. Reset the flow-mode cache so a
403403 // 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.
405406 r.logChat = nil
406407 r.logBottom = nil
407408 r.logLines = nil
408409 r.logViewportTop = 0
409410 r.logHardwareRow = 0
410411 r.logInit = false
412+ r.logNeedsFullRepaint = true
411413 r.Draw(frame, cursorRow, cursorCol)
412414 }
@@ -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 a402 // 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 trying403 // 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 = nil406 r.logChat = nil
406 r.logBottom = nil407 r.logBottom = nil
407 r.logLines = nil408 r.logLines = nil
408 r.logViewportTop = 0409 r.logViewportTop = 0
409 r.logHardwareRow = 0410 r.logHardwareRow = 0
410 r.logInit = false411 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) {
184184 }
185185 }
186186
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+
187212 func TestRendererDrawRightBarUpdatesClearsAndResizes(t *testing.T) {
188213 var out bytes.Buffer
189214 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.Buffer213 var out bytes.Buffer
189 r := NewRenderer(&out)214 r := NewRenderer(&out)

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close