Fix tool frame repaint after viewport reflow
- commit
- eddf12c605fdc78c6e63f04ba0d449364254e7e9
- author
- patriceckhart · · 2026-07-17 16:06 UTC
- parent
- 6bf1bb1
- signature
- unsigned
- refs
- tag: v0.2.89
2 files · +74 -16
M packages/tui/render.go +40 -16
| @@ -633,25 +633,49 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int | |||
| 633 | 633 | // updated below, so ignored rows remain historical snapshots while new | |
| 634 | 634 | // output naturally pushes them farther into scrollback. | |
| 635 | 635 | if firstChanged >= 0 && firstChanged < r.logViewportTop { | |
| 636 | - | firstChanged = -1 | |
| 637 | - | for idx := r.logViewportTop; idx < maxLines; idx++ { | |
| 638 | - | oldLine := "" | |
| 639 | - | if idx < len(r.logLines) { | |
| 640 | - | oldLine = r.logLines[idx] | |
| 636 | + | // A height change above the viewport shifts the logical row numbers | |
| 637 | + | // of everything below it. If any already-emitted visible row also | |
| 638 | + | // changed, an index-based patch would target the wrong physical row | |
| 639 | + | // and splice the next tool frame into the previous one's border. | |
| 640 | + | // Recover with a clean repaint. Pure suffix appends remain safe and | |
| 641 | + | // keep terminal selections intact. | |
| 642 | + | structuralReflow := len(chatFrame) < len(r.logChat) | |
| 643 | + | if len(chatFrame) != len(r.logChat) { | |
| 644 | + | overlapEnd := len(chatFrame) | |
| 645 | + | if len(r.logChat) < overlapEnd { | |
| 646 | + | overlapEnd = len(r.logChat) | |
| 641 | 647 | } | |
| 642 | - | newLine := "" | |
| 643 | - | if idx < len(lines) { | |
| 644 | - | newLine = lines[idx] | |
| 645 | - | } | |
| 646 | - | if oldLine != newLine { | |
| 647 | - | firstChanged = idx | |
| 648 | - | break | |
| 648 | + | for idx := r.logViewportTop; idx < overlapEnd; idx++ { | |
| 649 | + | if r.logChat[idx] != chatFrame[idx] { | |
| 650 | + | structuralReflow = true | |
| 651 | + | break | |
| 652 | + | } | |
| 649 | 653 | } | |
| 650 | 654 | } | |
| 651 | - | // A newly appended blank row compares equal to the implicit empty | |
| 652 | - | // row past the old slice, but it still has to advance the terminal. | |
| 653 | - | if firstChanged == -1 && len(lines) > len(r.logLines) && len(r.logLines) >= r.logViewportTop { | |
| 654 | - | firstChanged = len(r.logLines) | |
| 655 | + | if structuralReflow { | |
| 656 | + | writeFull(true, false) | |
| 657 | + | firstChanged = -1 | |
| 658 | + | } else { | |
| 659 | + | firstChanged = -1 | |
| 660 | + | for idx := r.logViewportTop; idx < maxLines; idx++ { | |
| 661 | + | oldLine := "" | |
| 662 | + | if idx < len(r.logLines) { | |
| 663 | + | oldLine = r.logLines[idx] | |
| 664 | + | } | |
| 665 | + | newLine := "" | |
| 666 | + | if idx < len(lines) { | |
| 667 | + | newLine = lines[idx] | |
| 668 | + | } | |
| 669 | + | if oldLine != newLine { | |
| 670 | + | firstChanged = idx | |
| 671 | + | break | |
| 672 | + | } | |
| 673 | + | } | |
| 674 | + | // A newly appended blank row compares equal to the implicit empty | |
| 675 | + | // row past the old slice, but it still has to advance the terminal. | |
| 676 | + | if firstChanged == -1 && len(lines) > len(r.logLines) && len(r.logLines) >= r.logViewportTop { | |
| 677 | + | firstChanged = len(r.logLines) | |
| 678 | + | } | |
| 655 | 679 | } | |
| 656 | 680 | } | |
| 657 | 681 | ||
| @@ -633,25 +633,49 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int | |||||
| 633 | // updated below, so ignored rows remain historical snapshots while new | 633 | // updated below, so ignored rows remain historical snapshots while new | ||
| 634 | // output naturally pushes them farther into scrollback. | 634 | // output naturally pushes them farther into scrollback. | ||
| 635 | if firstChanged >= 0 && firstChanged < r.logViewportTop { | 635 | if firstChanged >= 0 && firstChanged < r.logViewportTop { | ||
| 636 | - | firstChanged = -1 | 636 | + | // A height change above the viewport shifts the logical row numbers |
| 637 | - | for idx := r.logViewportTop; idx < maxLines; idx++ { | 637 | + | // of everything below it. If any already-emitted visible row also |
| 638 | - | oldLine := "" | 638 | + | // changed, an index-based patch would target the wrong physical row |
| 639 | - | if idx < len(r.logLines) { | 639 | + | // and splice the next tool frame into the previous one's border. |
| 640 | - | oldLine = r.logLines[idx] | 640 | + | // Recover with a clean repaint. Pure suffix appends remain safe and |
| 641 | + | // keep terminal selections intact. | |||
| 642 | + | structuralReflow := len(chatFrame) < len(r.logChat) | |||
| 643 | + | if len(chatFrame) != len(r.logChat) { | |||
| 644 | + | overlapEnd := len(chatFrame) | |||
| 645 | + | if len(r.logChat) < overlapEnd { | |||
| 646 | + | overlapEnd = len(r.logChat) | |||
| 641 | } | 647 | } | ||
| 642 | - | newLine := "" | 648 | + | for idx := r.logViewportTop; idx < overlapEnd; idx++ { |
| 643 | - | if idx < len(lines) { | 649 | + | if r.logChat[idx] != chatFrame[idx] { |
| 644 | - | newLine = lines[idx] | 650 | + | structuralReflow = true |
| 645 | - | } | 651 | + | break |
| 646 | - | if oldLine != newLine { | 652 | + | } |
| 647 | - | firstChanged = idx | |||
| 648 | - | break | |||
| 649 | } | 653 | } | ||
| 650 | } | 654 | } | ||
| 651 | - | // A newly appended blank row compares equal to the implicit empty | 655 | + | if structuralReflow { |
| 652 | - | // row past the old slice, but it still has to advance the terminal. | 656 | + | writeFull(true, false) |
| 653 | - | if firstChanged == -1 && len(lines) > len(r.logLines) && len(r.logLines) >= r.logViewportTop { | 657 | + | firstChanged = -1 |
| 654 | - | firstChanged = len(r.logLines) | 658 | + | } else { |
| 659 | + | firstChanged = -1 | |||
| 660 | + | for idx := r.logViewportTop; idx < maxLines; idx++ { | |||
| 661 | + | oldLine := "" | |||
| 662 | + | if idx < len(r.logLines) { | |||
| 663 | + | oldLine = r.logLines[idx] | |||
| 664 | + | } | |||
| 665 | + | newLine := "" | |||
| 666 | + | if idx < len(lines) { | |||
| 667 | + | newLine = lines[idx] | |||
| 668 | + | } | |||
| 669 | + | if oldLine != newLine { | |||
| 670 | + | firstChanged = idx | |||
| 671 | + | break | |||
| 672 | + | } | |||
| 673 | + | } | |||
| 674 | + | // A newly appended blank row compares equal to the implicit empty | |||
| 675 | + | // row past the old slice, but it still has to advance the terminal. | |||
| 676 | + | if firstChanged == -1 && len(lines) > len(r.logLines) && len(r.logLines) >= r.logViewportTop { | |||
| 677 | + | firstChanged = len(r.logLines) | |||
| 678 | + | } | |||
| 655 | } | 679 | } | ||
| 656 | } | 680 | } | ||
| 657 | 681 | ||||
M packages/tui/render_test.go +34 -0
| @@ -126,6 +126,40 @@ func TestDrawLogInaccessibleChangePreservesScrollbackSelection(t *testing.T) { | |||
| 126 | 126 | } | |
| 127 | 127 | } | |
| 128 | 128 | ||
| 129 | + | func TestDrawLogStructuralReflowAboveViewportRepaintsToolFrames(t *testing.T) { | |
| 130 | + | t.Setenv("TERM_PROGRAM", "") | |
| 131 | + | var buf bytes.Buffer | |
| 132 | + | r := NewRenderer(&buf) | |
| 133 | + | r.Resize(80, 4) | |
| 134 | + | oldChat := []string{ | |
| 135 | + | "┌ bash first ─────", "│", "│ old output", "└──────────────────", | |
| 136 | + | "┌ bash second ────", "│ second output", "└──────────────────", | |
| 137 | + | } | |
| 138 | + | r.DrawLog(oldChat, []string{"input"}, 0, 0) | |
| 139 | + | buf.Reset() | |
| 140 | + | ||
| 141 | + | // The first tool gains wrapped rows after its top has scrolled away. | |
| 142 | + | // Every following logical row shifts, so patching by the old indexes | |
| 143 | + | // would draw the second header inside the first tool's border. | |
| 144 | + | newChat := []string{ | |
| 145 | + | "┌ bash first ─────", "│", "│ old output", "│ wrapped line 1", "│ wrapped line 2", "└──────────────────", | |
| 146 | + | "┌ bash second ────", "│ second output", "└──────────────────", | |
| 147 | + | } | |
| 148 | + | r.DrawLog(newChat, []string{"input"}, 0, 0) | |
| 149 | + | got := buf.String() | |
| 150 | + | if !strings.Contains(got, SeqClearScreenNoHome) { | |
| 151 | + | t.Fatalf("structural reflow was patched in place instead of repainted: %q", got) | |
| 152 | + | } | |
| 153 | + | for _, want := range []string{"│ wrapped line 2", "┌ bash second", "└──────────────────"} { | |
| 154 | + | if !strings.Contains(got, want) { | |
| 155 | + | t.Fatalf("repaint missing %q: %q", want, got) | |
| 156 | + | } | |
| 157 | + | } | |
| 158 | + | if strings.Contains(got, SeqClearScrollback) { | |
| 159 | + | t.Fatalf("automatic recovery erased terminal scrollback: %q", got) | |
| 160 | + | } | |
| 161 | + | } | |
| 162 | + | ||
| 129 | 163 | func TestDrawLogInaccessibleChangeStillAppendsNewOutput(t *testing.T) { | |
| 130 | 164 | t.Setenv("TERM_PROGRAM", "") | |
| 131 | 165 | var buf bytes.Buffer | |
| @@ -126,6 +126,40 @@ func TestDrawLogInaccessibleChangePreservesScrollbackSelection(t *testing.T) { | |||||
| 126 | } | 126 | } | ||
| 127 | } | 127 | } | ||
| 128 | 128 | ||||
| 129 | + | func TestDrawLogStructuralReflowAboveViewportRepaintsToolFrames(t *testing.T) { | |||
| 130 | + | t.Setenv("TERM_PROGRAM", "") | |||
| 131 | + | var buf bytes.Buffer | |||
| 132 | + | r := NewRenderer(&buf) | |||
| 133 | + | r.Resize(80, 4) | |||
| 134 | + | oldChat := []string{ | |||
| 135 | + | "┌ bash first ─────", "│", "│ old output", "└──────────────────", | |||
| 136 | + | "┌ bash second ────", "│ second output", "└──────────────────", | |||
| 137 | + | } | |||
| 138 | + | r.DrawLog(oldChat, []string{"input"}, 0, 0) | |||
| 139 | + | buf.Reset() | |||
| 140 | + | ||||
| 141 | + | // The first tool gains wrapped rows after its top has scrolled away. | |||
| 142 | + | // Every following logical row shifts, so patching by the old indexes | |||
| 143 | + | // would draw the second header inside the first tool's border. | |||
| 144 | + | newChat := []string{ | |||
| 145 | + | "┌ bash first ─────", "│", "│ old output", "│ wrapped line 1", "│ wrapped line 2", "└──────────────────", | |||
| 146 | + | "┌ bash second ────", "│ second output", "└──────────────────", | |||
| 147 | + | } | |||
| 148 | + | r.DrawLog(newChat, []string{"input"}, 0, 0) | |||
| 149 | + | got := buf.String() | |||
| 150 | + | if !strings.Contains(got, SeqClearScreenNoHome) { | |||
| 151 | + | t.Fatalf("structural reflow was patched in place instead of repainted: %q", got) | |||
| 152 | + | } | |||
| 153 | + | for _, want := range []string{"│ wrapped line 2", "┌ bash second", "└──────────────────"} { | |||
| 154 | + | if !strings.Contains(got, want) { | |||
| 155 | + | t.Fatalf("repaint missing %q: %q", want, got) | |||
| 156 | + | } | |||
| 157 | + | } | |||
| 158 | + | if strings.Contains(got, SeqClearScrollback) { | |||
| 159 | + | t.Fatalf("automatic recovery erased terminal scrollback: %q", got) | |||
| 160 | + | } | |||
| 161 | + | } | |||
| 162 | + | ||||
| 129 | func TestDrawLogInaccessibleChangeStillAppendsNewOutput(t *testing.T) { | 163 | func TestDrawLogInaccessibleChangeStillAppendsNewOutput(t *testing.T) { | ||
| 130 | t.Setenv("TERM_PROGRAM", "") | 164 | t.Setenv("TERM_PROGRAM", "") | ||
| 131 | var buf bytes.Buffer | 165 | var buf bytes.Buffer | ||