detached 5d7a424

Fix dashboard repaint after long transcript

commit
5d7a424a45b6b55d14464db9a4db5ef8298a970a
author
patriceckhart · · 2026-09-02 15:21 UTC
parent
1856ebe
signature
unsigned
2 files · +34 -8
M packages/tui/render.go +12 -8
@@ -786,10 +786,12 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
786786 w.WriteString(lines[idx])
787787 }
788788 finalRow := renderEnd
789+ fullRepaint := false
789790 if len(r.logLines) > len(lines) {
790791 extra := len(r.logLines) - len(lines)
791- if extra > r.rows {
792+ if extra >= r.rows {
792793 writeFull(true, false)
794+ fullRepaint = true
793795 } else {
794796 for e := 0; e < extra; e++ {
795797 w.WriteString("\x1b[1B")
@@ -804,13 +806,15 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
804806 }
805807 }
806808 }
807- r.logHardwareRow = finalRow
808- r.logViewportTop = viewportTop
809- if minTop := r.logHardwareRow - r.rows + 1; minTop > r.logViewportTop {
810- r.logViewportTop = minTop
811- }
812- if r.logViewportTop < 0 {
813- r.logViewportTop = 0
809+ if !fullRepaint {
810+ r.logHardwareRow = finalRow
811+ r.logViewportTop = viewportTop
812+ if minTop := r.logHardwareRow - r.rows + 1; minTop > r.logViewportTop {
813+ r.logViewportTop = minTop
814+ }
815+ if r.logViewportTop < 0 {
816+ r.logViewportTop = 0
817+ }
814818 }
815819 }
816820 }
@@ -786,10 +786,12 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
786 w.WriteString(lines[idx])786 w.WriteString(lines[idx])
787 }787 }
788 finalRow := renderEnd788 finalRow := renderEnd
789+ fullRepaint := false
789 if len(r.logLines) > len(lines) {790 if len(r.logLines) > len(lines) {
790 extra := len(r.logLines) - len(lines)791 extra := len(r.logLines) - len(lines)
791- if extra > r.rows {792+ if extra >= r.rows {
792 writeFull(true, false)793 writeFull(true, false)
794+ fullRepaint = true
793 } else {795 } else {
794 for e := 0; e < extra; e++ {796 for e := 0; e < extra; e++ {
795 w.WriteString("\x1b[1B")797 w.WriteString("\x1b[1B")
@@ -804,13 +806,15 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
804 }806 }
805 }807 }
806 }808 }
807- r.logHardwareRow = finalRow809+ if !fullRepaint {
808- r.logViewportTop = viewportTop810+ r.logHardwareRow = finalRow
809- if minTop := r.logHardwareRow - r.rows + 1; minTop > r.logViewportTop {811+ r.logViewportTop = viewportTop
810- r.logViewportTop = minTop812+ if minTop := r.logHardwareRow - r.rows + 1; minTop > r.logViewportTop {
811- }813+ r.logViewportTop = minTop
812- if r.logViewportTop < 0 {814+ }
813- r.logViewportTop = 0815+ if r.logViewportTop < 0 {
816+ r.logViewportTop = 0
817+ }
814 }818 }
815 }819 }
816 }820 }
M packages/tui/render_test.go +22 -0
@@ -61,6 +61,28 @@ func TestDrawLogContentChangeBreaksFastPath(t *testing.T) {
6161 }
6262 }
6363
64+func TestDrawLogLargeBottomShrinkKeepsVisibleRowsAddressable(t *testing.T) {
65+ var buf bytes.Buffer
66+ r := NewRenderer(&buf)
67+ r.Resize(80, 4)
68+
69+ // A long dialog, such as a swarm transcript, scrolls beyond the viewport.
70+ r.DrawLog(nil, []string{"transcript 1", "transcript 2", "transcript 3", "transcript 4", "transcript 5", "transcript 6"}, -1, 0)
71+ buf.Reset()
72+
73+ // Returning to the short dashboard requires a full repaint because one
74+ // viewport of logical rows disappeared.
75+ r.DrawLog(nil, []string{"dashboard", "selected agent 1"}, -1, 0)
76+ buf.Reset()
77+
78+ // The dashboard remains interactive after that repaint. A cursor move must
79+ // update its visible selection instead of being discarded as inaccessible.
80+ r.DrawLog(nil, []string{"dashboard", "selected agent 2"}, -1, 0)
81+ if got := buf.String(); !strings.Contains(got, "selected agent 2") {
82+ t.Fatalf("visible update after large dialog shrink was suppressed: %q", got)
83+ }
84+}
85+
6486 // TestDrawLogCursorMoveBreaksFastPath proves a cursor-only change
6587 // (no buffer change) still produces output. Without this, typing in
6688 // the editor would visually move the caret but the terminal would
@@ -61,6 +61,28 @@ func TestDrawLogContentChangeBreaksFastPath(t *testing.T) {
61 }61 }
62 }62 }
63 63
64+func TestDrawLogLargeBottomShrinkKeepsVisibleRowsAddressable(t *testing.T) {
65+ var buf bytes.Buffer
66+ r := NewRenderer(&buf)
67+ r.Resize(80, 4)
68+
69+ // A long dialog, such as a swarm transcript, scrolls beyond the viewport.
70+ r.DrawLog(nil, []string{"transcript 1", "transcript 2", "transcript 3", "transcript 4", "transcript 5", "transcript 6"}, -1, 0)
71+ buf.Reset()
72+
73+ // Returning to the short dashboard requires a full repaint because one
74+ // viewport of logical rows disappeared.
75+ r.DrawLog(nil, []string{"dashboard", "selected agent 1"}, -1, 0)
76+ buf.Reset()
77+
78+ // The dashboard remains interactive after that repaint. A cursor move must
79+ // update its visible selection instead of being discarded as inaccessible.
80+ r.DrawLog(nil, []string{"dashboard", "selected agent 2"}, -1, 0)
81+ if got := buf.String(); !strings.Contains(got, "selected agent 2") {
82+ t.Fatalf("visible update after large dialog shrink was suppressed: %q", got)
83+ }
84+}
85+
64 // TestDrawLogCursorMoveBreaksFastPath proves a cursor-only change86 // TestDrawLogCursorMoveBreaksFastPath proves a cursor-only change
65 // (no buffer change) still produces output. Without this, typing in87 // (no buffer change) still produces output. Without this, typing in
66 // the editor would visually move the caret but the terminal would88 // the editor would visually move the caret but the terminal would

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 477

↑↓ moveenter openesc close