Fix dashboard repaint after partial viewport shrink
- commit
- 532d7f6d487b9d7b99cf297985defde54e225d9b
- author
- patriceckhart · · 2026-09-03 05:46 UTC
- parent
- 169b025
- signature
- unsigned
- refs
- tag: v0.3.58
2 files · +53 -1
M packages/tui/render.go +12 -1
| @@ -583,7 +583,18 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int | |||
| 583 | 583 | // real previous bg-colored row. | |
| 584 | 584 | ||
| 585 | 585 | wasInitialized := r.logInit | |
| 586 | - | full := !wasInitialized || len(r.logLines) == 0 | |
| 586 | + | bottomShrunkAboveViewport := false | |
| 587 | + | if len(bottomFrame) < len(r.logBottom) { | |
| 588 | + | newViewportTop := len(lines) - r.rows | |
| 589 | + | if newViewportTop < 0 { | |
| 590 | + | newViewportTop = 0 | |
| 591 | + | } | |
| 592 | + | // If the shorter bottom frame starts above the currently addressable | |
| 593 | + | // viewport, relative cursor movement cannot repaint its prefix. This | |
| 594 | + | // happens when returning from a moderately long dialog to a short one. | |
| 595 | + | bottomShrunkAboveViewport = newViewportTop < r.logViewportTop | |
| 596 | + | } | |
| 597 | + | full := !wasInitialized || len(r.logLines) == 0 || bottomShrunkAboveViewport | |
| 587 | 598 | if full { | |
| 588 | 599 | writeFull(true, !wasInitialized) | |
| 589 | 600 | r.logInit = true | |
| @@ -583,7 +583,18 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int | |||||
| 583 | // real previous bg-colored row. | 583 | // real previous bg-colored row. | ||
| 584 | 584 | ||||
| 585 | wasInitialized := r.logInit | 585 | wasInitialized := r.logInit | ||
| 586 | - | full := !wasInitialized || len(r.logLines) == 0 | 586 | + | bottomShrunkAboveViewport := false |
| 587 | + | if len(bottomFrame) < len(r.logBottom) { | |||
| 588 | + | newViewportTop := len(lines) - r.rows | |||
| 589 | + | if newViewportTop < 0 { | |||
| 590 | + | newViewportTop = 0 | |||
| 591 | + | } | |||
| 592 | + | // If the shorter bottom frame starts above the currently addressable | |||
| 593 | + | // viewport, relative cursor movement cannot repaint its prefix. This | |||
| 594 | + | // happens when returning from a moderately long dialog to a short one. | |||
| 595 | + | bottomShrunkAboveViewport = newViewportTop < r.logViewportTop | |||
| 596 | + | } | |||
| 597 | + | full := !wasInitialized || len(r.logLines) == 0 || bottomShrunkAboveViewport | |||
| 587 | if full { | 598 | if full { | ||
| 588 | writeFull(true, !wasInitialized) | 599 | writeFull(true, !wasInitialized) | ||
| 589 | r.logInit = true | 600 | r.logInit = true | ||
M packages/tui/render_test.go +41 -0
| @@ -83,6 +83,47 @@ func TestDrawLogLargeBottomShrinkKeepsVisibleRowsAddressable(t *testing.T) { | |||
| 83 | 83 | } | |
| 84 | 84 | } | |
| 85 | 85 | ||
| 86 | + | func TestDrawLogPartialViewportShrinkRepaintsFromDashboardStart(t *testing.T) { | |
| 87 | + | t.Setenv("TERM_PROGRAM", "ghostty") | |
| 88 | + | var buf bytes.Buffer | |
| 89 | + | r := NewRenderer(&buf) | |
| 90 | + | r.Resize(80, 4) | |
| 91 | + | ||
| 92 | + | // This transcript overflows the viewport, but returning to the dashboard | |
| 93 | + | // removes fewer than one viewport of rows. | |
| 94 | + | r.DrawLog(nil, []string{"transcript 1", "transcript 2", "transcript 3", "transcript 4", "transcript 5"}, -1, 0) | |
| 95 | + | buf.Reset() | |
| 96 | + | ||
| 97 | + | r.DrawLog(nil, []string{"dashboard", "selected agent 1"}, -1, 0) | |
| 98 | + | got := buf.String() | |
| 99 | + | if !strings.Contains(got, SeqClearScreenNoHome) { | |
| 100 | + | t.Fatalf("partial viewport shrink did not repaint the screen: %q", got) | |
| 101 | + | } | |
| 102 | + | for _, want := range []string{"dashboard", "selected agent 1"} { | |
| 103 | + | if !strings.Contains(got, want) { | |
| 104 | + | t.Fatalf("partial viewport repaint missing %q: %q", want, got) | |
| 105 | + | } | |
| 106 | + | } | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | func TestDrawLogChatShrinkDoesNotForceDialogRepaint(t *testing.T) { | |
| 110 | + | t.Setenv("TERM_PROGRAM", "ghostty") | |
| 111 | + | var buf bytes.Buffer | |
| 112 | + | r := NewRenderer(&buf) | |
| 113 | + | r.Resize(80, 4) | |
| 114 | + | ||
| 115 | + | r.DrawLog([]string{"chat 1", "chat 2", "chat 3", "chat 4", "chat 5", "chat 6"}, []string{"input"}, 0, 0) | |
| 116 | + | buf.Reset() | |
| 117 | + | ||
| 118 | + | // Chat reflow has its own coordinate-rebasing path, which preserves native | |
| 119 | + | // terminal selections. Only a shrinking bottom frame should force the | |
| 120 | + | // repaint used when returning from a transcript to the dashboard. | |
| 121 | + | r.DrawLog([]string{"chat 1", "chat 2", "chat 3", "chat 4", "chat 5"}, []string{"input"}, 0, 0) | |
| 122 | + | if got := buf.String(); strings.Contains(got, SeqClearScreenNoHome) { | |
| 123 | + | t.Fatalf("chat shrink unnecessarily repainted the screen: %q", got) | |
| 124 | + | } | |
| 125 | + | } | |
| 126 | + | ||
| 86 | 127 | // TestDrawLogCursorMoveBreaksFastPath proves a cursor-only change | |
| 87 | 128 | // (no buffer change) still produces output. Without this, typing in | |
| 88 | 129 | // the editor would visually move the caret but the terminal would | |
| @@ -83,6 +83,47 @@ func TestDrawLogLargeBottomShrinkKeepsVisibleRowsAddressable(t *testing.T) { | |||||
| 83 | } | 83 | } | ||
| 84 | } | 84 | } | ||
| 85 | 85 | ||||
| 86 | + | func TestDrawLogPartialViewportShrinkRepaintsFromDashboardStart(t *testing.T) { | |||
| 87 | + | t.Setenv("TERM_PROGRAM", "ghostty") | |||
| 88 | + | var buf bytes.Buffer | |||
| 89 | + | r := NewRenderer(&buf) | |||
| 90 | + | r.Resize(80, 4) | |||
| 91 | + | ||||
| 92 | + | // This transcript overflows the viewport, but returning to the dashboard | |||
| 93 | + | // removes fewer than one viewport of rows. | |||
| 94 | + | r.DrawLog(nil, []string{"transcript 1", "transcript 2", "transcript 3", "transcript 4", "transcript 5"}, -1, 0) | |||
| 95 | + | buf.Reset() | |||
| 96 | + | ||||
| 97 | + | r.DrawLog(nil, []string{"dashboard", "selected agent 1"}, -1, 0) | |||
| 98 | + | got := buf.String() | |||
| 99 | + | if !strings.Contains(got, SeqClearScreenNoHome) { | |||
| 100 | + | t.Fatalf("partial viewport shrink did not repaint the screen: %q", got) | |||
| 101 | + | } | |||
| 102 | + | for _, want := range []string{"dashboard", "selected agent 1"} { | |||
| 103 | + | if !strings.Contains(got, want) { | |||
| 104 | + | t.Fatalf("partial viewport repaint missing %q: %q", want, got) | |||
| 105 | + | } | |||
| 106 | + | } | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | func TestDrawLogChatShrinkDoesNotForceDialogRepaint(t *testing.T) { | |||
| 110 | + | t.Setenv("TERM_PROGRAM", "ghostty") | |||
| 111 | + | var buf bytes.Buffer | |||
| 112 | + | r := NewRenderer(&buf) | |||
| 113 | + | r.Resize(80, 4) | |||
| 114 | + | ||||
| 115 | + | r.DrawLog([]string{"chat 1", "chat 2", "chat 3", "chat 4", "chat 5", "chat 6"}, []string{"input"}, 0, 0) | |||
| 116 | + | buf.Reset() | |||
| 117 | + | ||||
| 118 | + | // Chat reflow has its own coordinate-rebasing path, which preserves native | |||
| 119 | + | // terminal selections. Only a shrinking bottom frame should force the | |||
| 120 | + | // repaint used when returning from a transcript to the dashboard. | |||
| 121 | + | r.DrawLog([]string{"chat 1", "chat 2", "chat 3", "chat 4", "chat 5"}, []string{"input"}, 0, 0) | |||
| 122 | + | if got := buf.String(); strings.Contains(got, SeqClearScreenNoHome) { | |||
| 123 | + | t.Fatalf("chat shrink unnecessarily repainted the screen: %q", got) | |||
| 124 | + | } | |||
| 125 | + | } | |||
| 126 | + | ||||
| 86 | // TestDrawLogCursorMoveBreaksFastPath proves a cursor-only change | 127 | // TestDrawLogCursorMoveBreaksFastPath proves a cursor-only change | ||
| 87 | // (no buffer change) still produces output. Without this, typing in | 128 | // (no buffer change) still produces output. Without this, typing in | ||
| 88 | // the editor would visually move the caret but the terminal would | 129 | // the editor would visually move the caret but the terminal would | ||