fix(tui): stabilize streaming viewport reflows
- commit
- cf8265ca63bf1d65266c338724aeb9a667c9dcef
- author
- brice · · 2026-08-09 09:12 UTC
- committer
- brice · · 2026-08-09 09:32 UTC
- parent
- 1adbe2f
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +156 -33
M packages/tui/render.go +98 -25
| @@ -243,6 +243,35 @@ func imageFootprintBlank(s string) bool { | |||
| 243 | 243 | return strings.TrimSpace(s) == "" | |
| 244 | 244 | } | |
| 245 | 245 | ||
| 246 | + | // visibleTailHasImage reports whether an inline image or its blank terminal | |
| 247 | + | // footprint intersects the visible tail. Images earlier in retained scrollback | |
| 248 | + | // do not prevent ordinary text reflows from using the stable in-place path. | |
| 249 | + | func visibleTailHasImage(lines []string, viewportTop int) bool { | |
| 250 | + | if viewportTop < 0 { | |
| 251 | + | viewportTop = 0 | |
| 252 | + | } | |
| 253 | + | if viewportTop >= len(lines) { | |
| 254 | + | return false | |
| 255 | + | } | |
| 256 | + | for _, line := range lines[viewportTop:] { | |
| 257 | + | if containsImageEscape(line) { | |
| 258 | + | return true | |
| 259 | + | } | |
| 260 | + | } | |
| 261 | + | if viewportTop == 0 || !imageFootprintBlank(lines[viewportTop]) { | |
| 262 | + | return false | |
| 263 | + | } | |
| 264 | + | for idx := viewportTop - 1; idx >= 0; idx-- { | |
| 265 | + | if containsImageEscape(lines[idx]) { | |
| 266 | + | return true | |
| 267 | + | } | |
| 268 | + | if !imageFootprintBlank(lines[idx]) { | |
| 269 | + | return false | |
| 270 | + | } | |
| 271 | + | } | |
| 272 | + | return false | |
| 273 | + | } | |
| 274 | + | ||
| 246 | 275 | // paintBackgroundRow applies the optional theme background to a single | |
| 247 | 276 | // already-truncated terminal row. It pads with spaces to cols so the | |
| 248 | 277 | // background reaches the right edge, and re-applies the background | |
| @@ -746,42 +775,86 @@ func (r *Renderer) drawLog(chat, bottom []string, cursorBottomRow, cursorCol, co | |||
| 746 | 775 | reflowAbove := false | |
| 747 | 776 | if delta != 0 { | |
| 748 | 777 | overlapEnd := min(len(chatFrame), len(r.logChat)) | |
| 749 | - | for idx := r.logViewportTop; idx < overlapEnd; idx++ { | |
| 778 | + | // When the viewport starts in the bottom band there are no chat | |
| 779 | + | // rows to compare at the old logical coordinates, but changing | |
| 780 | + | // chat height still shifts the entire visible band. | |
| 781 | + | reflowAbove = r.logViewportTop >= overlapEnd | |
| 782 | + | for idx := r.logViewportTop; !reflowAbove && idx < overlapEnd; idx++ { | |
| 750 | 783 | if r.logChat[idx] != chatFrame[idx] { | |
| 751 | 784 | reflowAbove = true | |
| 752 | - | break | |
| 753 | 785 | } | |
| 754 | 786 | } | |
| 755 | 787 | } | |
| 756 | 788 | if reflowAbove { | |
| 757 | - | // Reflow has made the cached logical coordinates unreliable. | |
| 758 | - | // Repaint only the visible tail: replaying the full transcript | |
| 759 | - | // duplicates scrollback, while rebasing by the total height delta | |
| 760 | - | // can drift when some new rows landed inside the viewport. | |
| 761 | - | w.WriteString(SeqDeleteKittyImages + SeqCursorHome + SeqClearToEnd) | |
| 762 | - | viewportTop := max(0, len(lines)-r.rows) | |
| 763 | - | start := viewportTop | |
| 764 | - | if start > 0 && imageFootprintBlank(lines[start]) { | |
| 765 | - | for idx := start - 1; idx >= 0; idx-- { | |
| 766 | - | if containsImageEscape(lines[idx]) { | |
| 767 | - | start = idx | |
| 768 | - | break | |
| 789 | + | // A text-only logical buffer can be rebased without resetting the | |
| 790 | + | // terminal viewport. Advance physical rows when it grows, then | |
| 791 | + | // repaint the visible tail in place for both growth and shrinkage. | |
| 792 | + | // Cursor-home plus erase-to-end makes several terminals visibly | |
| 793 | + | // jump on streaming-markdown reflows. | |
| 794 | + | growth := len(lines) - len(r.logLines) | |
| 795 | + | newViewportTop := max(0, len(lines)-r.rows) | |
| 796 | + | hasVisibleImages := visibleTailHasImage(r.logLines, r.logViewportTop) || | |
| 797 | + | visibleTailHasImage(lines, newViewportTop) | |
| 798 | + | if !hasVisibleImages && len(lines) >= r.rows { | |
| 799 | + | currentScreenRow := r.logHardwareRow - r.logViewportTop | |
| 800 | + | if currentScreenRow < 0 { | |
| 801 | + | currentScreenRow = 0 | |
| 802 | + | } | |
| 803 | + | if currentScreenRow >= r.rows { | |
| 804 | + | currentScreenRow = r.rows - 1 | |
| 805 | + | } | |
| 806 | + | if growth > 0 { | |
| 807 | + | if down := r.rows - 1 - currentScreenRow; down > 0 { | |
| 808 | + | w.WriteString("\x1b[" + itoa(down) + "B") | |
| 769 | 809 | } | |
| 770 | - | if !imageFootprintBlank(lines[idx]) { | |
| 771 | - | break | |
| 810 | + | for range growth { | |
| 811 | + | w.WriteString("\r\n") | |
| 772 | 812 | } | |
| 813 | + | currentScreenRow = r.rows - 1 | |
| 773 | 814 | } | |
| 774 | - | } | |
| 775 | - | for idx := start; idx < len(lines); idx++ { | |
| 776 | - | if idx > start { | |
| 777 | - | w.WriteString("\r\n") | |
| 815 | + | if currentScreenRow > 0 { | |
| 816 | + | w.WriteString("\x1b[" + itoa(currentScreenRow) + "A") | |
| 817 | + | } | |
| 818 | + | w.WriteString("\r") | |
| 819 | + | for idx := newViewportTop; idx < len(lines); idx++ { | |
| 820 | + | if idx > newViewportTop { | |
| 821 | + | w.WriteString("\r\n") | |
| 822 | + | } | |
| 823 | + | w.WriteString("\x1b[0m") | |
| 824 | + | w.WriteString(SeqClearLine) | |
| 825 | + | w.WriteString(lines[idx]) | |
| 826 | + | } | |
| 827 | + | r.logViewportTop = newViewportTop | |
| 828 | + | r.logHardwareRow = len(lines) - 1 | |
| 829 | + | } else { | |
| 830 | + | // Inline-image reflows and buffers shorter than the viewport need | |
| 831 | + | // the conservative repaint path. Replaying the full transcript | |
| 832 | + | // would duplicate retained scrollback. | |
| 833 | + | w.WriteString(SeqDeleteKittyImages + SeqCursorHome + SeqClearToEnd) | |
| 834 | + | viewportTop := max(0, len(lines)-r.rows) | |
| 835 | + | start := viewportTop | |
| 836 | + | if start > 0 && imageFootprintBlank(lines[start]) { | |
| 837 | + | for idx := start - 1; idx >= 0; idx-- { | |
| 838 | + | if containsImageEscape(lines[idx]) { | |
| 839 | + | start = idx | |
| 840 | + | break | |
| 841 | + | } | |
| 842 | + | if !imageFootprintBlank(lines[idx]) { | |
| 843 | + | break | |
| 844 | + | } | |
| 845 | + | } | |
| 846 | + | } | |
| 847 | + | for idx := start; idx < len(lines); idx++ { | |
| 848 | + | if idx > start { | |
| 849 | + | w.WriteString("\r\n") | |
| 850 | + | } | |
| 851 | + | w.WriteString("\x1b[0m") | |
| 852 | + | w.WriteString(SeqClearLine) | |
| 853 | + | w.WriteString(lines[idx]) | |
| 778 | 854 | } | |
| 779 | - | w.WriteString("\x1b[0m") | |
| 780 | - | w.WriteString(SeqClearLine) | |
| 781 | - | w.WriteString(lines[idx]) | |
| 855 | + | r.logViewportTop = viewportTop | |
| 856 | + | r.logHardwareRow = len(lines) - 1 | |
| 782 | 857 | } | |
| 783 | - | r.logViewportTop = viewportTop | |
| 784 | - | r.logHardwareRow = len(lines) - 1 | |
| 785 | 858 | firstChanged = -1 | |
| 786 | 859 | lastChanged = -1 | |
| 787 | 860 | } else { | |
| @@ -243,6 +243,35 @@ func imageFootprintBlank(s string) bool { | |||||
| 243 | return strings.TrimSpace(s) == "" | 243 | return strings.TrimSpace(s) == "" | ||
| 244 | } | 244 | } | ||
| 245 | 245 | ||||
| 246 | + | // visibleTailHasImage reports whether an inline image or its blank terminal | |||
| 247 | + | // footprint intersects the visible tail. Images earlier in retained scrollback | |||
| 248 | + | // do not prevent ordinary text reflows from using the stable in-place path. | |||
| 249 | + | func visibleTailHasImage(lines []string, viewportTop int) bool { | |||
| 250 | + | if viewportTop < 0 { | |||
| 251 | + | viewportTop = 0 | |||
| 252 | + | } | |||
| 253 | + | if viewportTop >= len(lines) { | |||
| 254 | + | return false | |||
| 255 | + | } | |||
| 256 | + | for _, line := range lines[viewportTop:] { | |||
| 257 | + | if containsImageEscape(line) { | |||
| 258 | + | return true | |||
| 259 | + | } | |||
| 260 | + | } | |||
| 261 | + | if viewportTop == 0 || !imageFootprintBlank(lines[viewportTop]) { | |||
| 262 | + | return false | |||
| 263 | + | } | |||
| 264 | + | for idx := viewportTop - 1; idx >= 0; idx-- { | |||
| 265 | + | if containsImageEscape(lines[idx]) { | |||
| 266 | + | return true | |||
| 267 | + | } | |||
| 268 | + | if !imageFootprintBlank(lines[idx]) { | |||
| 269 | + | return false | |||
| 270 | + | } | |||
| 271 | + | } | |||
| 272 | + | return false | |||
| 273 | + | } | |||
| 274 | + | ||||
| 246 | // paintBackgroundRow applies the optional theme background to a single | 275 | // paintBackgroundRow applies the optional theme background to a single | ||
| 247 | // already-truncated terminal row. It pads with spaces to cols so the | 276 | // already-truncated terminal row. It pads with spaces to cols so the | ||
| 248 | // background reaches the right edge, and re-applies the background | 277 | // background reaches the right edge, and re-applies the background | ||
| @@ -746,42 +775,86 @@ func (r *Renderer) drawLog(chat, bottom []string, cursorBottomRow, cursorCol, co | |||||
| 746 | reflowAbove := false | 775 | reflowAbove := false | ||
| 747 | if delta != 0 { | 776 | if delta != 0 { | ||
| 748 | overlapEnd := min(len(chatFrame), len(r.logChat)) | 777 | overlapEnd := min(len(chatFrame), len(r.logChat)) | ||
| 749 | - | for idx := r.logViewportTop; idx < overlapEnd; idx++ { | 778 | + | // When the viewport starts in the bottom band there are no chat |
| 779 | + | // rows to compare at the old logical coordinates, but changing | |||
| 780 | + | // chat height still shifts the entire visible band. | |||
| 781 | + | reflowAbove = r.logViewportTop >= overlapEnd | |||
| 782 | + | for idx := r.logViewportTop; !reflowAbove && idx < overlapEnd; idx++ { | |||
| 750 | if r.logChat[idx] != chatFrame[idx] { | 783 | if r.logChat[idx] != chatFrame[idx] { | ||
| 751 | reflowAbove = true | 784 | reflowAbove = true | ||
| 752 | - | break | |||
| 753 | } | 785 | } | ||
| 754 | } | 786 | } | ||
| 755 | } | 787 | } | ||
| 756 | if reflowAbove { | 788 | if reflowAbove { | ||
| 757 | - | // Reflow has made the cached logical coordinates unreliable. | 789 | + | // A text-only logical buffer can be rebased without resetting the |
| 758 | - | // Repaint only the visible tail: replaying the full transcript | 790 | + | // terminal viewport. Advance physical rows when it grows, then |
| 759 | - | // duplicates scrollback, while rebasing by the total height delta | 791 | + | // repaint the visible tail in place for both growth and shrinkage. |
| 760 | - | // can drift when some new rows landed inside the viewport. | 792 | + | // Cursor-home plus erase-to-end makes several terminals visibly |
| 761 | - | w.WriteString(SeqDeleteKittyImages + SeqCursorHome + SeqClearToEnd) | 793 | + | // jump on streaming-markdown reflows. |
| 762 | - | viewportTop := max(0, len(lines)-r.rows) | 794 | + | growth := len(lines) - len(r.logLines) |
| 763 | - | start := viewportTop | 795 | + | newViewportTop := max(0, len(lines)-r.rows) |
| 764 | - | if start > 0 && imageFootprintBlank(lines[start]) { | 796 | + | hasVisibleImages := visibleTailHasImage(r.logLines, r.logViewportTop) || |
| 765 | - | for idx := start - 1; idx >= 0; idx-- { | 797 | + | visibleTailHasImage(lines, newViewportTop) |
| 766 | - | if containsImageEscape(lines[idx]) { | 798 | + | if !hasVisibleImages && len(lines) >= r.rows { |
| 767 | - | start = idx | 799 | + | currentScreenRow := r.logHardwareRow - r.logViewportTop |
| 768 | - | break | 800 | + | if currentScreenRow < 0 { |
| 801 | + | currentScreenRow = 0 | |||
| 802 | + | } | |||
| 803 | + | if currentScreenRow >= r.rows { | |||
| 804 | + | currentScreenRow = r.rows - 1 | |||
| 805 | + | } | |||
| 806 | + | if growth > 0 { | |||
| 807 | + | if down := r.rows - 1 - currentScreenRow; down > 0 { | |||
| 808 | + | w.WriteString("\x1b[" + itoa(down) + "B") | |||
| 769 | } | 809 | } | ||
| 770 | - | if !imageFootprintBlank(lines[idx]) { | 810 | + | for range growth { |
| 771 | - | break | 811 | + | w.WriteString("\r\n") |
| 772 | } | 812 | } | ||
| 813 | + | currentScreenRow = r.rows - 1 | |||
| 773 | } | 814 | } | ||
| 774 | - | } | 815 | + | if currentScreenRow > 0 { |
| 775 | - | for idx := start; idx < len(lines); idx++ { | 816 | + | w.WriteString("\x1b[" + itoa(currentScreenRow) + "A") |
| 776 | - | if idx > start { | 817 | + | } |
| 777 | - | w.WriteString("\r\n") | 818 | + | w.WriteString("\r") |
| 819 | + | for idx := newViewportTop; idx < len(lines); idx++ { | |||
| 820 | + | if idx > newViewportTop { | |||
| 821 | + | w.WriteString("\r\n") | |||
| 822 | + | } | |||
| 823 | + | w.WriteString("\x1b[0m") | |||
| 824 | + | w.WriteString(SeqClearLine) | |||
| 825 | + | w.WriteString(lines[idx]) | |||
| 826 | + | } | |||
| 827 | + | r.logViewportTop = newViewportTop | |||
| 828 | + | r.logHardwareRow = len(lines) - 1 | |||
| 829 | + | } else { | |||
| 830 | + | // Inline-image reflows and buffers shorter than the viewport need | |||
| 831 | + | // the conservative repaint path. Replaying the full transcript | |||
| 832 | + | // would duplicate retained scrollback. | |||
| 833 | + | w.WriteString(SeqDeleteKittyImages + SeqCursorHome + SeqClearToEnd) | |||
| 834 | + | viewportTop := max(0, len(lines)-r.rows) | |||
| 835 | + | start := viewportTop | |||
| 836 | + | if start > 0 && imageFootprintBlank(lines[start]) { | |||
| 837 | + | for idx := start - 1; idx >= 0; idx-- { | |||
| 838 | + | if containsImageEscape(lines[idx]) { | |||
| 839 | + | start = idx | |||
| 840 | + | break | |||
| 841 | + | } | |||
| 842 | + | if !imageFootprintBlank(lines[idx]) { | |||
| 843 | + | break | |||
| 844 | + | } | |||
| 845 | + | } | |||
| 846 | + | } | |||
| 847 | + | for idx := start; idx < len(lines); idx++ { | |||
| 848 | + | if idx > start { | |||
| 849 | + | w.WriteString("\r\n") | |||
| 850 | + | } | |||
| 851 | + | w.WriteString("\x1b[0m") | |||
| 852 | + | w.WriteString(SeqClearLine) | |||
| 853 | + | w.WriteString(lines[idx]) | |||
| 778 | } | 854 | } | ||
| 779 | - | w.WriteString("\x1b[0m") | 855 | + | r.logViewportTop = viewportTop |
| 780 | - | w.WriteString(SeqClearLine) | 856 | + | r.logHardwareRow = len(lines) - 1 |
| 781 | - | w.WriteString(lines[idx]) | |||
| 782 | } | 857 | } | ||
| 783 | - | r.logViewportTop = viewportTop | |||
| 784 | - | r.logHardwareRow = len(lines) - 1 | |||
| 785 | firstChanged = -1 | 858 | firstChanged = -1 | ||
| 786 | lastChanged = -1 | 859 | lastChanged = -1 | ||
| 787 | } else { | 860 | } else { | ||
M packages/tui/render_test.go +58 -8
| @@ -126,7 +126,7 @@ func TestDrawLogInaccessibleChangePreservesScrollbackSelection(t *testing.T) { | |||
| 126 | 126 | } | |
| 127 | 127 | } | |
| 128 | 128 | ||
| 129 | - | func TestDrawLogStructuralReflowResetsVisibleViewport(t *testing.T) { | |
| 129 | + | func TestDrawLogStructuralGrowthScrollsBeforeRepaintingVisibleTail(t *testing.T) { | |
| 130 | 130 | t.Setenv("TERM_PROGRAM", "ghostty") | |
| 131 | 131 | var buf bytes.Buffer | |
| 132 | 132 | r := NewRenderer(&buf) | |
| @@ -135,16 +135,20 @@ func TestDrawLogStructuralReflowResetsVisibleViewport(t *testing.T) { | |||
| 135 | 135 | buf.Reset() | |
| 136 | 136 | ||
| 137 | 137 | // Chat and the bottom band both grow while an old row is above the | |
| 138 | - | // viewport. Rebasing by chat growth alone leaves both tracked terminal | |
| 139 | - | // coordinates one row behind, so future status updates paint off-screen. | |
| 138 | + | // viewport. Scroll by the logical growth before repainting the visible | |
| 139 | + | // tail. Homing and clearing the viewport here makes terminals visibly | |
| 140 | + | // jump during every streaming markdown reflow. | |
| 140 | 141 | chat := []string{"new row", "old 0", "old 1"} | |
| 141 | 142 | r.DrawLog(chat, []string{"working 0s", "editor"}, 1, 3) | |
| 142 | 143 | got := buf.String() | |
| 143 | - | if !strings.Contains(got, SeqCursorHome+SeqClearToEnd) { | |
| 144 | - | t.Fatalf("structural reflow did not reset the visible viewport: %q", got) | |
| 144 | + | if strings.Contains(got, SeqCursorHome+SeqClearToEnd) { | |
| 145 | + | t.Fatalf("structural growth reset the visible viewport: %q", got) | |
| 145 | 146 | } | |
| 146 | 147 | if strings.Contains(got, SeqClearScreenNoHome) || strings.Contains(got, SeqClearScrollback) { | |
| 147 | - | t.Fatalf("structural reflow cleared retained scrollback: %q", got) | |
| 148 | + | t.Fatalf("structural growth cleared retained scrollback: %q", got) | |
| 149 | + | } | |
| 150 | + | if !strings.Contains(got, "\r\n\r\n") { | |
| 151 | + | t.Fatalf("structural growth did not advance the viewport by two consecutive rows: %q", got) | |
| 148 | 152 | } | |
| 149 | 153 | if strings.Contains(got, "old 1") { | |
| 150 | 154 | t.Fatalf("viewport repaint included a row above the visible tail: %q", got) | |
| @@ -164,13 +168,59 @@ func TestDrawLogStructuralReflowResetsVisibleViewport(t *testing.T) { | |||
| 164 | 168 | buf.Reset() | |
| 165 | 169 | r.DrawLog(chat, []string{"working 1s", "editor"}, 1, 3) | |
| 166 | 170 | if got := buf.String(); !strings.Contains(got, "working 1s") { | |
| 167 | - | t.Fatalf("status update after viewport reset was lost: %q", got) | |
| 171 | + | t.Fatalf("status update after viewport repaint was lost: %q", got) | |
| 168 | 172 | } | |
| 169 | 173 | ||
| 170 | 174 | buf.Reset() | |
| 171 | 175 | r.DrawLog(append(chat, "next tool"), []string{"working 2s", "editor"}, 1, 3) | |
| 172 | 176 | if got := buf.String(); !strings.Contains(got, "next tool") { | |
| 173 | - | t.Fatalf("append after viewport reset was lost: %q", got) | |
| 177 | + | t.Fatalf("append after viewport repaint was lost: %q", got) | |
| 178 | + | } | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | func TestDrawLogStructuralShrinkRepaintsVisibleTailInPlace(t *testing.T) { | |
| 182 | + | t.Setenv("TERM_PROGRAM", "ghostty") | |
| 183 | + | var buf bytes.Buffer | |
| 184 | + | r := NewRenderer(&buf) | |
| 185 | + | r.Resize(80, 4) | |
| 186 | + | r.DrawLog([]string{"partial", "old 0", "old 1", "old 2"}, []string{"working", "editor"}, 1, 2) | |
| 187 | + | buf.Reset() | |
| 188 | + | ||
| 189 | + | chat := []string{"old 0", "old 1", "old 2"} | |
| 190 | + | r.DrawLog(chat, []string{"working", "editor"}, 1, 2) | |
| 191 | + | got := buf.String() | |
| 192 | + | if strings.Contains(got, SeqCursorHome+SeqClearToEnd) { | |
| 193 | + | t.Fatalf("structural shrink reset the visible viewport: %q", got) | |
| 194 | + | } | |
| 195 | + | for _, visible := range []string{"old 2", "working", "editor"} { | |
| 196 | + | if !strings.Contains(got, visible) { | |
| 197 | + | t.Fatalf("viewport repaint missing %q after shrink: %q", visible, got) | |
| 198 | + | } | |
| 199 | + | } | |
| 200 | + | if !strings.Contains(got, "\r\x1b[0m"+SeqClearLine+"old 2") { | |
| 201 | + | t.Fatalf("structural shrink did not return to column zero before repaint: %q", got) | |
| 202 | + | } | |
| 203 | + | if want := len(r.logLines) - r.rows; r.logViewportTop != want { | |
| 204 | + | t.Fatalf("viewport top = %d, want %d", r.logViewportTop, want) | |
| 205 | + | } | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | func TestDrawLogStructuralGrowthIgnoresImagesOutsideVisibleTail(t *testing.T) { | |
| 209 | + | t.Setenv("TERM_PROGRAM", "ghostty") | |
| 210 | + | var buf bytes.Buffer | |
| 211 | + | r := NewRenderer(&buf) | |
| 212 | + | r.Resize(80, 3) | |
| 213 | + | image := "\x1b_Ga=T;payload\x1b\\" | |
| 214 | + | r.DrawLog([]string{image, "line 1", "line 2", "line 3"}, []string{"input"}, -1, 0) | |
| 215 | + | buf.Reset() | |
| 216 | + | ||
| 217 | + | r.DrawLog([]string{"new row", image, "line 1", "line 2", "line 3"}, []string{"input"}, -1, 0) | |
| 218 | + | got := buf.String() | |
| 219 | + | if strings.Contains(got, SeqCursorHome+SeqClearToEnd) { | |
| 220 | + | t.Fatalf("image in retained scrollback forced a viewport reset: %q", got) | |
| 221 | + | } | |
| 222 | + | if !strings.Contains(got, "line 3") || !strings.Contains(got, "input") { | |
| 223 | + | t.Fatalf("visible tail was not repainted after growth: %q", got) | |
| 174 | 224 | } | |
| 175 | 225 | } | |
| 176 | 226 | ||
| @@ -126,7 +126,7 @@ func TestDrawLogInaccessibleChangePreservesScrollbackSelection(t *testing.T) { | |||||
| 126 | } | 126 | } | ||
| 127 | } | 127 | } | ||
| 128 | 128 | ||||
| 129 | - | func TestDrawLogStructuralReflowResetsVisibleViewport(t *testing.T) { | 129 | + | func TestDrawLogStructuralGrowthScrollsBeforeRepaintingVisibleTail(t *testing.T) { |
| 130 | t.Setenv("TERM_PROGRAM", "ghostty") | 130 | t.Setenv("TERM_PROGRAM", "ghostty") | ||
| 131 | var buf bytes.Buffer | 131 | var buf bytes.Buffer | ||
| 132 | r := NewRenderer(&buf) | 132 | r := NewRenderer(&buf) | ||
| @@ -135,16 +135,20 @@ func TestDrawLogStructuralReflowResetsVisibleViewport(t *testing.T) { | |||||
| 135 | buf.Reset() | 135 | buf.Reset() | ||
| 136 | 136 | ||||
| 137 | // Chat and the bottom band both grow while an old row is above the | 137 | // Chat and the bottom band both grow while an old row is above the | ||
| 138 | - | // viewport. Rebasing by chat growth alone leaves both tracked terminal | 138 | + | // viewport. Scroll by the logical growth before repainting the visible |
| 139 | - | // coordinates one row behind, so future status updates paint off-screen. | 139 | + | // tail. Homing and clearing the viewport here makes terminals visibly |
| 140 | + | // jump during every streaming markdown reflow. | |||
| 140 | chat := []string{"new row", "old 0", "old 1"} | 141 | chat := []string{"new row", "old 0", "old 1"} | ||
| 141 | r.DrawLog(chat, []string{"working 0s", "editor"}, 1, 3) | 142 | r.DrawLog(chat, []string{"working 0s", "editor"}, 1, 3) | ||
| 142 | got := buf.String() | 143 | got := buf.String() | ||
| 143 | - | if !strings.Contains(got, SeqCursorHome+SeqClearToEnd) { | 144 | + | if strings.Contains(got, SeqCursorHome+SeqClearToEnd) { |
| 144 | - | t.Fatalf("structural reflow did not reset the visible viewport: %q", got) | 145 | + | t.Fatalf("structural growth reset the visible viewport: %q", got) |
| 145 | } | 146 | } | ||
| 146 | if strings.Contains(got, SeqClearScreenNoHome) || strings.Contains(got, SeqClearScrollback) { | 147 | if strings.Contains(got, SeqClearScreenNoHome) || strings.Contains(got, SeqClearScrollback) { | ||
| 147 | - | t.Fatalf("structural reflow cleared retained scrollback: %q", got) | 148 | + | t.Fatalf("structural growth cleared retained scrollback: %q", got) |
| 149 | + | } | |||
| 150 | + | if !strings.Contains(got, "\r\n\r\n") { | |||
| 151 | + | t.Fatalf("structural growth did not advance the viewport by two consecutive rows: %q", got) | |||
| 148 | } | 152 | } | ||
| 149 | if strings.Contains(got, "old 1") { | 153 | if strings.Contains(got, "old 1") { | ||
| 150 | t.Fatalf("viewport repaint included a row above the visible tail: %q", got) | 154 | t.Fatalf("viewport repaint included a row above the visible tail: %q", got) | ||
| @@ -164,13 +168,59 @@ func TestDrawLogStructuralReflowResetsVisibleViewport(t *testing.T) { | |||||
| 164 | buf.Reset() | 168 | buf.Reset() | ||
| 165 | r.DrawLog(chat, []string{"working 1s", "editor"}, 1, 3) | 169 | r.DrawLog(chat, []string{"working 1s", "editor"}, 1, 3) | ||
| 166 | if got := buf.String(); !strings.Contains(got, "working 1s") { | 170 | if got := buf.String(); !strings.Contains(got, "working 1s") { | ||
| 167 | - | t.Fatalf("status update after viewport reset was lost: %q", got) | 171 | + | t.Fatalf("status update after viewport repaint was lost: %q", got) |
| 168 | } | 172 | } | ||
| 169 | 173 | ||||
| 170 | buf.Reset() | 174 | buf.Reset() | ||
| 171 | r.DrawLog(append(chat, "next tool"), []string{"working 2s", "editor"}, 1, 3) | 175 | r.DrawLog(append(chat, "next tool"), []string{"working 2s", "editor"}, 1, 3) | ||
| 172 | if got := buf.String(); !strings.Contains(got, "next tool") { | 176 | if got := buf.String(); !strings.Contains(got, "next tool") { | ||
| 173 | - | t.Fatalf("append after viewport reset was lost: %q", got) | 177 | + | t.Fatalf("append after viewport repaint was lost: %q", got) |
| 178 | + | } | |||
| 179 | + | } | |||
| 180 | + | ||||
| 181 | + | func TestDrawLogStructuralShrinkRepaintsVisibleTailInPlace(t *testing.T) { | |||
| 182 | + | t.Setenv("TERM_PROGRAM", "ghostty") | |||
| 183 | + | var buf bytes.Buffer | |||
| 184 | + | r := NewRenderer(&buf) | |||
| 185 | + | r.Resize(80, 4) | |||
| 186 | + | r.DrawLog([]string{"partial", "old 0", "old 1", "old 2"}, []string{"working", "editor"}, 1, 2) | |||
| 187 | + | buf.Reset() | |||
| 188 | + | ||||
| 189 | + | chat := []string{"old 0", "old 1", "old 2"} | |||
| 190 | + | r.DrawLog(chat, []string{"working", "editor"}, 1, 2) | |||
| 191 | + | got := buf.String() | |||
| 192 | + | if strings.Contains(got, SeqCursorHome+SeqClearToEnd) { | |||
| 193 | + | t.Fatalf("structural shrink reset the visible viewport: %q", got) | |||
| 194 | + | } | |||
| 195 | + | for _, visible := range []string{"old 2", "working", "editor"} { | |||
| 196 | + | if !strings.Contains(got, visible) { | |||
| 197 | + | t.Fatalf("viewport repaint missing %q after shrink: %q", visible, got) | |||
| 198 | + | } | |||
| 199 | + | } | |||
| 200 | + | if !strings.Contains(got, "\r\x1b[0m"+SeqClearLine+"old 2") { | |||
| 201 | + | t.Fatalf("structural shrink did not return to column zero before repaint: %q", got) | |||
| 202 | + | } | |||
| 203 | + | if want := len(r.logLines) - r.rows; r.logViewportTop != want { | |||
| 204 | + | t.Fatalf("viewport top = %d, want %d", r.logViewportTop, want) | |||
| 205 | + | } | |||
| 206 | + | } | |||
| 207 | + | ||||
| 208 | + | func TestDrawLogStructuralGrowthIgnoresImagesOutsideVisibleTail(t *testing.T) { | |||
| 209 | + | t.Setenv("TERM_PROGRAM", "ghostty") | |||
| 210 | + | var buf bytes.Buffer | |||
| 211 | + | r := NewRenderer(&buf) | |||
| 212 | + | r.Resize(80, 3) | |||
| 213 | + | image := "\x1b_Ga=T;payload\x1b\\" | |||
| 214 | + | r.DrawLog([]string{image, "line 1", "line 2", "line 3"}, []string{"input"}, -1, 0) | |||
| 215 | + | buf.Reset() | |||
| 216 | + | ||||
| 217 | + | r.DrawLog([]string{"new row", image, "line 1", "line 2", "line 3"}, []string{"input"}, -1, 0) | |||
| 218 | + | got := buf.String() | |||
| 219 | + | if strings.Contains(got, SeqCursorHome+SeqClearToEnd) { | |||
| 220 | + | t.Fatalf("image in retained scrollback forced a viewport reset: %q", got) | |||
| 221 | + | } | |||
| 222 | + | if !strings.Contains(got, "line 3") || !strings.Contains(got, "input") { | |||
| 223 | + | t.Fatalf("visible tail was not repainted after growth: %q", got) | |||
| 174 | } | 224 | } | ||
| 175 | } | 225 | } | ||
| 176 | 226 | ||||