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 | |||
| 786 | 786 | w.WriteString(lines[idx]) | |
| 787 | 787 | } | |
| 788 | 788 | finalRow := renderEnd | |
| 789 | + | fullRepaint := false | |
| 789 | 790 | if len(r.logLines) > len(lines) { | |
| 790 | 791 | extra := len(r.logLines) - len(lines) | |
| 791 | - | if extra > r.rows { | |
| 792 | + | if extra >= r.rows { | |
| 792 | 793 | writeFull(true, false) | |
| 794 | + | fullRepaint = true | |
| 793 | 795 | } else { | |
| 794 | 796 | for e := 0; e < extra; e++ { | |
| 795 | 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 = 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 | + | } | |
| 814 | 818 | } | |
| 815 | 819 | } | |
| 816 | 820 | } | |
| @@ -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 := renderEnd | 788 | 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 = finalRow | 809 | + | if !fullRepaint { |
| 808 | - | r.logViewportTop = viewportTop | 810 | + | r.logHardwareRow = finalRow |
| 809 | - | if minTop := r.logHardwareRow - r.rows + 1; minTop > r.logViewportTop { | 811 | + | r.logViewportTop = viewportTop |
| 810 | - | r.logViewportTop = minTop | 812 | + | if minTop := r.logHardwareRow - r.rows + 1; minTop > r.logViewportTop { |
| 811 | - | } | 813 | + | r.logViewportTop = minTop |
| 812 | - | if r.logViewportTop < 0 { | 814 | + | } |
| 813 | - | r.logViewportTop = 0 | 815 | + | 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) { | |||
| 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 | 86 | // TestDrawLogCursorMoveBreaksFastPath proves a cursor-only change | |
| 65 | 87 | // (no buffer change) still produces output. Without this, typing in | |
| 66 | 88 | // 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 change | 86 | // TestDrawLogCursorMoveBreaksFastPath proves a cursor-only change | ||
| 65 | // (no buffer change) still produces output. Without this, typing in | 87 | // (no buffer change) still produces output. Without this, typing in | ||
| 66 | // the editor would visually move the caret but the terminal would | 88 | // the editor would visually move the caret but the terminal would | ||