fix(tui): keep viewport updates visible after reflow
- commit
- a33efe03fe491a30cbe1a4bb083af95d8e924ee8
- author
- brice · · 2026-08-04 07:53 UTC
- parent
- 9eef239
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +115 -105
M packages/tui/render.go +55 -72
| @@ -215,6 +215,12 @@ func containsImageEscape(s string) bool { | |||
| 215 | 215 | return strings.Contains(s, "\x1b]1337;File=") || strings.Contains(s, "\x1b_G") | |
| 216 | 216 | } | |
| 217 | 217 | ||
| 218 | + | func imageFootprintBlank(s string) bool { | |
| 219 | + | s = strings.ReplaceAll(stripANSI(s), imageFootprintSentinel, "") | |
| 220 | + | s = strings.Trim(strings.TrimSpace(s), "│") | |
| 221 | + | return strings.TrimSpace(s) == "" | |
| 222 | + | } | |
| 223 | + | ||
| 218 | 224 | // paintBackgroundRow applies the optional theme background to a single | |
| 219 | 225 | // already-truncated terminal row. It pads with spaces to cols so the | |
| 220 | 226 | // background reaches the right edge, and re-applies the background | |
| @@ -634,10 +640,7 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int | |||
| 634 | 640 | delta := len(chatFrame) - len(r.logChat) | |
| 635 | 641 | reflowAbove := false | |
| 636 | 642 | if delta != 0 { | |
| 637 | - | overlapEnd := len(chatFrame) | |
| 638 | - | if len(r.logChat) < overlapEnd { | |
| 639 | - | overlapEnd = len(r.logChat) | |
| 640 | - | } | |
| 643 | + | overlapEnd := min(len(chatFrame), len(r.logChat)) | |
| 641 | 644 | for idx := r.logViewportTop; idx < overlapEnd; idx++ { | |
| 642 | 645 | if r.logChat[idx] != chatFrame[idx] { | |
| 643 | 646 | reflowAbove = true | |
| @@ -646,81 +649,61 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int | |||
| 646 | 649 | } | |
| 647 | 650 | } | |
| 648 | 651 | if reflowAbove { | |
| 649 | - | // A height change above the viewport shifts every following | |
| 650 | - | // logical row, but it does not move the rows already painted by | |
| 651 | - | // the terminal. Rebase our logical coordinates by the same delta | |
| 652 | - | // instead of clearing and replaying the transcript. The inaccessible | |
| 653 | - | // prefix remains the historical snapshot the terminal actually | |
| 654 | - | // holds; addressable chat and bottom rows stay aligned with their | |
| 655 | - | // existing physical rows. | |
| 656 | - | oldLines := r.logLines | |
| 657 | - | oldChatLen := len(r.logChat) | |
| 658 | - | newViewportTop := r.logViewportTop + delta | |
| 659 | - | if newViewportTop < 0 { | |
| 660 | - | newViewportTop = 0 | |
| 661 | - | } | |
| 662 | - | rebasedLen := len(oldLines) + delta | |
| 663 | - | if rebasedLen < 0 { | |
| 664 | - | rebasedLen = 0 | |
| 665 | - | } | |
| 666 | - | rebased := make([]string, rebasedLen) | |
| 667 | - | for idx := range rebased { | |
| 668 | - | switch { | |
| 669 | - | case idx < newViewportTop: | |
| 670 | - | // These rows cannot be repainted. Mark the new logical | |
| 671 | - | // contents as accepted so they do not trigger a replay. | |
| 672 | - | if idx < len(lines) { | |
| 673 | - | rebased[idx] = lines[idx] | |
| 674 | - | } | |
| 675 | - | case idx < len(chatFrame): | |
| 676 | - | oldIdx := idx - delta | |
| 677 | - | if oldIdx >= 0 && oldIdx < oldChatLen { | |
| 678 | - | rebased[idx] = oldLines[oldIdx] | |
| 652 | + | // Reflow has made the cached logical coordinates unreliable. | |
| 653 | + | // Repaint only the visible tail: replaying the full transcript | |
| 654 | + | // duplicates scrollback, while rebasing by the total height delta | |
| 655 | + | // can drift when some new rows landed inside the viewport. | |
| 656 | + | w.WriteString(SeqDeleteKittyImages + SeqCursorHome + SeqClearToEnd) | |
| 657 | + | viewportTop := max(0, len(lines)-r.rows) | |
| 658 | + | start := viewportTop | |
| 659 | + | if start > 0 && imageFootprintBlank(lines[start]) { | |
| 660 | + | for idx := start - 1; idx >= 0; idx-- { | |
| 661 | + | if containsImageEscape(lines[idx]) { | |
| 662 | + | start = idx | |
| 663 | + | break | |
| 679 | 664 | } | |
| 680 | - | default: | |
| 681 | - | // Bottom rows are indexed relative to the end of chat, | |
| 682 | - | // so preserve that relative position across the reflow. | |
| 683 | - | oldIdx := oldChatLen + idx - len(chatFrame) | |
| 684 | - | if oldIdx >= 0 && oldIdx < len(oldLines) { | |
| 685 | - | rebased[idx] = oldLines[oldIdx] | |
| 665 | + | if !imageFootprintBlank(lines[idx]) { | |
| 666 | + | break | |
| 686 | 667 | } | |
| 687 | 668 | } | |
| 688 | 669 | } | |
| 689 | - | r.logLines = rebased | |
| 690 | - | r.logViewportTop = newViewportTop | |
| 691 | - | r.logHardwareRow += delta | |
| 692 | - | if r.logHardwareRow < 0 { | |
| 693 | - | r.logHardwareRow = 0 | |
| 694 | - | } | |
| 695 | - | maxLines = len(lines) | |
| 696 | - | if len(r.logLines) > maxLines { | |
| 697 | - | maxLines = len(r.logLines) | |
| 698 | - | } | |
| 699 | - | } | |
| 700 | - | ||
| 701 | - | firstChanged = -1 | |
| 702 | - | lastChanged = -1 | |
| 703 | - | for idx := r.logViewportTop; idx < maxLines; idx++ { | |
| 704 | - | oldLine := "" | |
| 705 | - | if idx < len(r.logLines) { | |
| 706 | - | oldLine = r.logLines[idx] | |
| 707 | - | } | |
| 708 | - | newLine := "" | |
| 709 | - | if idx < len(lines) { | |
| 710 | - | newLine = lines[idx] | |
| 670 | + | for idx := start; idx < len(lines); idx++ { | |
| 671 | + | if idx > start { | |
| 672 | + | w.WriteString("\r\n") | |
| 673 | + | } | |
| 674 | + | w.WriteString("\x1b[0m") | |
| 675 | + | w.WriteString(SeqClearLine) | |
| 676 | + | w.WriteString(lines[idx]) | |
| 711 | 677 | } | |
| 712 | - | if oldLine != newLine { | |
| 713 | - | if firstChanged == -1 { | |
| 714 | - | firstChanged = idx | |
| 678 | + | r.logViewportTop = viewportTop | |
| 679 | + | r.logHardwareRow = len(lines) - 1 | |
| 680 | + | firstChanged = -1 | |
| 681 | + | lastChanged = -1 | |
| 682 | + | } else { | |
| 683 | + | firstChanged = -1 | |
| 684 | + | lastChanged = -1 | |
| 685 | + | for idx := r.logViewportTop; idx < maxLines; idx++ { | |
| 686 | + | oldLine := "" | |
| 687 | + | if idx < len(r.logLines) { | |
| 688 | + | oldLine = r.logLines[idx] | |
| 689 | + | } | |
| 690 | + | newLine := "" | |
| 691 | + | if idx < len(lines) { | |
| 692 | + | newLine = lines[idx] | |
| 693 | + | } | |
| 694 | + | if oldLine != newLine { | |
| 695 | + | if firstChanged == -1 { | |
| 696 | + | firstChanged = idx | |
| 697 | + | } | |
| 698 | + | lastChanged = idx | |
| 715 | 699 | } | |
| 716 | - | lastChanged = idx | |
| 717 | 700 | } | |
| 718 | - | } | |
| 719 | - | // A newly appended blank row compares equal to the implicit empty | |
| 720 | - | // row past the old slice, but it still has to advance the terminal. | |
| 721 | - | if firstChanged == -1 && len(lines) > len(r.logLines) && len(r.logLines) >= r.logViewportTop { | |
| 722 | - | firstChanged = len(r.logLines) | |
| 723 | - | lastChanged = len(lines) - 1 | |
| 701 | + | // A newly appended blank row compares equal to the implicit empty | |
| 702 | + | // row past the old slice, but it still has to advance the terminal. | |
| 703 | + | if firstChanged == -1 && len(lines) > len(r.logLines) && len(r.logLines) >= r.logViewportTop { | |
| 704 | + | firstChanged = len(r.logLines) | |
| 705 | + | lastChanged = len(lines) - 1 | |
| 706 | + | } | |
| 724 | 707 | } | |
| 725 | 708 | } | |
| 726 | 709 | ||
| @@ -215,6 +215,12 @@ func containsImageEscape(s string) bool { | |||||
| 215 | return strings.Contains(s, "\x1b]1337;File=") || strings.Contains(s, "\x1b_G") | 215 | return strings.Contains(s, "\x1b]1337;File=") || strings.Contains(s, "\x1b_G") | ||
| 216 | } | 216 | } | ||
| 217 | 217 | ||||
| 218 | + | func imageFootprintBlank(s string) bool { | |||
| 219 | + | s = strings.ReplaceAll(stripANSI(s), imageFootprintSentinel, "") | |||
| 220 | + | s = strings.Trim(strings.TrimSpace(s), "│") | |||
| 221 | + | return strings.TrimSpace(s) == "" | |||
| 222 | + | } | |||
| 223 | + | ||||
| 218 | // paintBackgroundRow applies the optional theme background to a single | 224 | // paintBackgroundRow applies the optional theme background to a single | ||
| 219 | // already-truncated terminal row. It pads with spaces to cols so the | 225 | // already-truncated terminal row. It pads with spaces to cols so the | ||
| 220 | // background reaches the right edge, and re-applies the background | 226 | // background reaches the right edge, and re-applies the background | ||
| @@ -634,10 +640,7 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int | |||||
| 634 | delta := len(chatFrame) - len(r.logChat) | 640 | delta := len(chatFrame) - len(r.logChat) | ||
| 635 | reflowAbove := false | 641 | reflowAbove := false | ||
| 636 | if delta != 0 { | 642 | if delta != 0 { | ||
| 637 | - | overlapEnd := len(chatFrame) | 643 | + | overlapEnd := min(len(chatFrame), len(r.logChat)) |
| 638 | - | if len(r.logChat) < overlapEnd { | |||
| 639 | - | overlapEnd = len(r.logChat) | |||
| 640 | - | } | |||
| 641 | for idx := r.logViewportTop; idx < overlapEnd; idx++ { | 644 | for idx := r.logViewportTop; idx < overlapEnd; idx++ { | ||
| 642 | if r.logChat[idx] != chatFrame[idx] { | 645 | if r.logChat[idx] != chatFrame[idx] { | ||
| 643 | reflowAbove = true | 646 | reflowAbove = true | ||
| @@ -646,81 +649,61 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int | |||||
| 646 | } | 649 | } | ||
| 647 | } | 650 | } | ||
| 648 | if reflowAbove { | 651 | if reflowAbove { | ||
| 649 | - | // A height change above the viewport shifts every following | 652 | + | // Reflow has made the cached logical coordinates unreliable. |
| 650 | - | // logical row, but it does not move the rows already painted by | 653 | + | // Repaint only the visible tail: replaying the full transcript |
| 651 | - | // the terminal. Rebase our logical coordinates by the same delta | 654 | + | // duplicates scrollback, while rebasing by the total height delta |
| 652 | - | // instead of clearing and replaying the transcript. The inaccessible | 655 | + | // can drift when some new rows landed inside the viewport. |
| 653 | - | // prefix remains the historical snapshot the terminal actually | 656 | + | w.WriteString(SeqDeleteKittyImages + SeqCursorHome + SeqClearToEnd) |
| 654 | - | // holds; addressable chat and bottom rows stay aligned with their | 657 | + | viewportTop := max(0, len(lines)-r.rows) |
| 655 | - | // existing physical rows. | 658 | + | start := viewportTop |
| 656 | - | oldLines := r.logLines | 659 | + | if start > 0 && imageFootprintBlank(lines[start]) { |
| 657 | - | oldChatLen := len(r.logChat) | 660 | + | for idx := start - 1; idx >= 0; idx-- { |
| 658 | - | newViewportTop := r.logViewportTop + delta | 661 | + | if containsImageEscape(lines[idx]) { |
| 659 | - | if newViewportTop < 0 { | 662 | + | start = idx |
| 660 | - | newViewportTop = 0 | 663 | + | break |
| 661 | - | } | |||
| 662 | - | rebasedLen := len(oldLines) + delta | |||
| 663 | - | if rebasedLen < 0 { | |||
| 664 | - | rebasedLen = 0 | |||
| 665 | - | } | |||
| 666 | - | rebased := make([]string, rebasedLen) | |||
| 667 | - | for idx := range rebased { | |||
| 668 | - | switch { | |||
| 669 | - | case idx < newViewportTop: | |||
| 670 | - | // These rows cannot be repainted. Mark the new logical | |||
| 671 | - | // contents as accepted so they do not trigger a replay. | |||
| 672 | - | if idx < len(lines) { | |||
| 673 | - | rebased[idx] = lines[idx] | |||
| 674 | - | } | |||
| 675 | - | case idx < len(chatFrame): | |||
| 676 | - | oldIdx := idx - delta | |||
| 677 | - | if oldIdx >= 0 && oldIdx < oldChatLen { | |||
| 678 | - | rebased[idx] = oldLines[oldIdx] | |||
| 679 | } | 664 | } | ||
| 680 | - | default: | 665 | + | if !imageFootprintBlank(lines[idx]) { |
| 681 | - | // Bottom rows are indexed relative to the end of chat, | 666 | + | break |
| 682 | - | // so preserve that relative position across the reflow. | |||
| 683 | - | oldIdx := oldChatLen + idx - len(chatFrame) | |||
| 684 | - | if oldIdx >= 0 && oldIdx < len(oldLines) { | |||
| 685 | - | rebased[idx] = oldLines[oldIdx] | |||
| 686 | } | 667 | } | ||
| 687 | } | 668 | } | ||
| 688 | } | 669 | } | ||
| 689 | - | r.logLines = rebased | 670 | + | for idx := start; idx < len(lines); idx++ { |
| 690 | - | r.logViewportTop = newViewportTop | 671 | + | if idx > start { |
| 691 | - | r.logHardwareRow += delta | 672 | + | w.WriteString("\r\n") |
| 692 | - | if r.logHardwareRow < 0 { | 673 | + | } |
| 693 | - | r.logHardwareRow = 0 | 674 | + | w.WriteString("\x1b[0m") |
| 694 | - | } | 675 | + | w.WriteString(SeqClearLine) |
| 695 | - | maxLines = len(lines) | 676 | + | w.WriteString(lines[idx]) |
| 696 | - | if len(r.logLines) > maxLines { | |||
| 697 | - | maxLines = len(r.logLines) | |||
| 698 | - | } | |||
| 699 | - | } | |||
| 700 | - | ||||
| 701 | - | firstChanged = -1 | |||
| 702 | - | lastChanged = -1 | |||
| 703 | - | for idx := r.logViewportTop; idx < maxLines; idx++ { | |||
| 704 | - | oldLine := "" | |||
| 705 | - | if idx < len(r.logLines) { | |||
| 706 | - | oldLine = r.logLines[idx] | |||
| 707 | - | } | |||
| 708 | - | newLine := "" | |||
| 709 | - | if idx < len(lines) { | |||
| 710 | - | newLine = lines[idx] | |||
| 711 | } | 677 | } | ||
| 712 | - | if oldLine != newLine { | 678 | + | r.logViewportTop = viewportTop |
| 713 | - | if firstChanged == -1 { | 679 | + | r.logHardwareRow = len(lines) - 1 |
| 714 | - | firstChanged = idx | 680 | + | firstChanged = -1 |
| 681 | + | lastChanged = -1 | |||
| 682 | + | } else { | |||
| 683 | + | firstChanged = -1 | |||
| 684 | + | lastChanged = -1 | |||
| 685 | + | for idx := r.logViewportTop; idx < maxLines; idx++ { | |||
| 686 | + | oldLine := "" | |||
| 687 | + | if idx < len(r.logLines) { | |||
| 688 | + | oldLine = r.logLines[idx] | |||
| 689 | + | } | |||
| 690 | + | newLine := "" | |||
| 691 | + | if idx < len(lines) { | |||
| 692 | + | newLine = lines[idx] | |||
| 693 | + | } | |||
| 694 | + | if oldLine != newLine { | |||
| 695 | + | if firstChanged == -1 { | |||
| 696 | + | firstChanged = idx | |||
| 697 | + | } | |||
| 698 | + | lastChanged = idx | |||
| 715 | } | 699 | } | ||
| 716 | - | lastChanged = idx | |||
| 717 | } | 700 | } | ||
| 718 | - | } | 701 | + | // A newly appended blank row compares equal to the implicit empty |
| 719 | - | // A newly appended blank row compares equal to the implicit empty | 702 | + | // row past the old slice, but it still has to advance the terminal. |
| 720 | - | // row past the old slice, but it still has to advance the terminal. | 703 | + | if firstChanged == -1 && len(lines) > len(r.logLines) && len(r.logLines) >= r.logViewportTop { |
| 721 | - | if firstChanged == -1 && len(lines) > len(r.logLines) && len(r.logLines) >= r.logViewportTop { | 704 | + | firstChanged = len(r.logLines) |
| 722 | - | firstChanged = len(r.logLines) | 705 | + | lastChanged = len(lines) - 1 |
| 723 | - | lastChanged = len(lines) - 1 | 706 | + | } |
| 724 | } | 707 | } | ||
| 725 | } | 708 | } | ||
| 726 | 709 | ||||
M packages/tui/render_test.go +60 -33
| @@ -126,51 +126,78 @@ func TestDrawLogInaccessibleChangePreservesScrollbackSelection(t *testing.T) { | |||
| 126 | 126 | } | |
| 127 | 127 | } | |
| 128 | 128 | ||
| 129 | - | func TestDrawLogStructuralReflowAboveViewportRebasesWithoutReplay(t *testing.T) { | |
| 129 | + | func TestDrawLogStructuralReflowResetsVisibleViewport(t *testing.T) { | |
| 130 | 130 | t.Setenv("TERM_PROGRAM", "ghostty") | |
| 131 | 131 | var buf bytes.Buffer | |
| 132 | 132 | r := NewRenderer(&buf) | |
| 133 | - | r.Resize(80, 4) | |
| 134 | - | oldChat := []string{ | |
| 135 | - | "user prompt", | |
| 136 | - | "┌ bash first ─────", "│ old output", "└──────────────────", | |
| 137 | - | "┌ bash second ────", "│ second output", "└──────────────────", | |
| 138 | - | } | |
| 139 | - | r.DrawLog(oldChat, []string{"input"}, 0, 0) | |
| 133 | + | r.Resize(80, 3) | |
| 134 | + | r.DrawLog([]string{"old 0", "old 1"}, []string{"working 0s"}, -1, 0) | |
| 140 | 135 | buf.Reset() | |
| 141 | 136 | ||
| 142 | - | // The first tool gains wrapped rows after its top has scrolled away. | |
| 143 | - | // Clearing the viewport and replaying the complete logical transcript | |
| 144 | - | // leaves the old prompt in native scrollback and prints a second copy. | |
| 145 | - | // Rebase the logical row coordinates instead, leaving inaccessible rows | |
| 146 | - | // as historical snapshots and keeping the visible tail aligned. | |
| 147 | - | newChat := []string{ | |
| 148 | - | "user prompt", | |
| 149 | - | "┌ bash first ─────", "│ old output", "│ wrapped line 1", "│ wrapped line 2", "└──────────────────", | |
| 150 | - | "┌ bash second ────", "│ second output", "└──────────────────", | |
| 151 | - | } | |
| 152 | - | r.DrawLog(newChat, []string{"input"}, 0, 0) | |
| 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. | |
| 140 | + | chat := []string{"new row", "old 0", "old 1"} | |
| 141 | + | r.DrawLog(chat, []string{"working 0s", "editor"}, 1, 3) | |
| 153 | 142 | got := buf.String() | |
| 143 | + | if !strings.Contains(got, SeqCursorHome+SeqClearToEnd) { | |
| 144 | + | t.Fatalf("structural reflow did not reset the visible viewport: %q", got) | |
| 145 | + | } | |
| 154 | 146 | if strings.Contains(got, SeqClearScreenNoHome) || strings.Contains(got, SeqClearScrollback) { | |
| 155 | - | t.Fatalf("structural reflow cleared the terminal: %q", got) | |
| 147 | + | t.Fatalf("structural reflow cleared retained scrollback: %q", got) | |
| 148 | + | } | |
| 149 | + | if strings.Contains(got, "old 1") { | |
| 150 | + | t.Fatalf("viewport repaint included a row above the visible tail: %q", got) | |
| 156 | 151 | } | |
| 157 | - | for _, replayed := range []string{"user prompt", "┌ bash first", "┌ bash second"} { | |
| 158 | - | if strings.Contains(got, replayed) { | |
| 159 | - | t.Fatalf("structural reflow replayed %q into scrollback: %q", replayed, got) | |
| 152 | + | for _, visible := range []string{"working 0s", "editor", SeqShowCursor, "\x1b[3C"} { | |
| 153 | + | if !strings.Contains(got, visible) { | |
| 154 | + | t.Fatalf("viewport repaint missing %q: %q", visible, got) | |
| 160 | 155 | } | |
| 161 | 156 | } | |
| 157 | + | if want := len(r.logLines) - r.rows; r.logViewportTop != want { | |
| 158 | + | t.Fatalf("viewport top = %d, want %d", r.logViewportTop, want) | |
| 159 | + | } | |
| 160 | + | if want := len(chat) + 1; r.logHardwareRow != want { | |
| 161 | + | t.Fatalf("hardware row = %d, want cursor row %d", r.logHardwareRow, want) | |
| 162 | + | } | |
| 162 | 163 | ||
| 163 | 164 | buf.Reset() | |
| 164 | - | appended := append(append([]string(nil), newChat...), "┌ bash third ─────", "│ third output", "└──────────────────") | |
| 165 | - | r.DrawLog(appended, []string{"input"}, 0, 0) | |
| 166 | - | got = buf.String() | |
| 167 | - | if !strings.Contains(got, "┌ bash third") || !strings.Contains(got, "│ third output") { | |
| 168 | - | t.Fatalf("new output was not appended after coordinate rebase: %q", got) | |
| 169 | - | } | |
| 170 | - | for _, replayed := range []string{"user prompt", "┌ bash first", "┌ bash second"} { | |
| 171 | - | if strings.Contains(got, replayed) { | |
| 172 | - | t.Fatalf("append after rebase replayed %q: %q", replayed, got) | |
| 173 | - | } | |
| 165 | + | r.DrawLog(chat, []string{"working 1s", "editor"}, 1, 3) | |
| 166 | + | if got := buf.String(); !strings.Contains(got, "working 1s") { | |
| 167 | + | t.Fatalf("status update after viewport reset was lost: %q", got) | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | buf.Reset() | |
| 171 | + | r.DrawLog(append(chat, "next tool"), []string{"working 2s", "editor"}, 1, 3) | |
| 172 | + | if got := buf.String(); !strings.Contains(got, "next tool") { | |
| 173 | + | t.Fatalf("append after viewport reset was lost: %q", got) | |
| 174 | + | } | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | func TestDrawLogStructuralReflowRepaintsVisibleImageFootprint(t *testing.T) { | |
| 178 | + | t.Setenv("TERM_PROGRAM", "ghostty") | |
| 179 | + | var buf bytes.Buffer | |
| 180 | + | r := NewRenderer(&buf) | |
| 181 | + | r.Resize(80, 4) | |
| 182 | + | image := "\x1b_Ga=T;payload\x1b\\" | |
| 183 | + | boxedBlank := " │ │ " | |
| 184 | + | r.DrawLog([]string{image, boxedBlank, "caption"}, []string{"working 0s"}, -1, 0) | |
| 185 | + | buf.Reset() | |
| 186 | + | ||
| 187 | + | // The visible tail begins deep inside an image footprint spanning more | |
| 188 | + | // than one screen. Real footprint rows may retain their sentinel or be | |
| 189 | + | // wrapped in empty tool-box edges. A viewport reset deletes Kitty images, | |
| 190 | + | // so it must scan across both forms and replay the escape row above them. | |
| 191 | + | r.DrawLog([]string{ | |
| 192 | + | "new row", image, boxedBlank, imageFootprintSentinel, boxedBlank, | |
| 193 | + | boxedBlank, boxedBlank, "caption", | |
| 194 | + | }, []string{"working 1s"}, -1, 0) | |
| 195 | + | got := buf.String() | |
| 196 | + | if !strings.Contains(got, SeqDeleteKittyImages) { | |
| 197 | + | t.Fatalf("viewport reset did not clear stale Kitty images: %q", got) | |
| 198 | + | } | |
| 199 | + | if !strings.Contains(got, image) { | |
| 200 | + | t.Fatalf("viewport reset did not repaint the visible image: %q", got) | |
| 174 | 201 | } | |
| 175 | 202 | } | |
| 176 | 203 | ||
| @@ -126,51 +126,78 @@ func TestDrawLogInaccessibleChangePreservesScrollbackSelection(t *testing.T) { | |||||
| 126 | } | 126 | } | ||
| 127 | } | 127 | } | ||
| 128 | 128 | ||||
| 129 | - | func TestDrawLogStructuralReflowAboveViewportRebasesWithoutReplay(t *testing.T) { | 129 | + | func TestDrawLogStructuralReflowResetsVisibleViewport(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) | ||
| 133 | - | r.Resize(80, 4) | 133 | + | r.Resize(80, 3) |
| 134 | - | oldChat := []string{ | 134 | + | r.DrawLog([]string{"old 0", "old 1"}, []string{"working 0s"}, -1, 0) |
| 135 | - | "user prompt", | |||
| 136 | - | "┌ bash first ─────", "│ old output", "└──────────────────", | |||
| 137 | - | "┌ bash second ────", "│ second output", "└──────────────────", | |||
| 138 | - | } | |||
| 139 | - | r.DrawLog(oldChat, []string{"input"}, 0, 0) | |||
| 140 | buf.Reset() | 135 | buf.Reset() | ||
| 141 | 136 | ||||
| 142 | - | // The first tool gains wrapped rows after its top has scrolled away. | 137 | + | // Chat and the bottom band both grow while an old row is above the |
| 143 | - | // Clearing the viewport and replaying the complete logical transcript | 138 | + | // viewport. Rebasing by chat growth alone leaves both tracked terminal |
| 144 | - | // leaves the old prompt in native scrollback and prints a second copy. | 139 | + | // coordinates one row behind, so future status updates paint off-screen. |
| 145 | - | // Rebase the logical row coordinates instead, leaving inaccessible rows | 140 | + | chat := []string{"new row", "old 0", "old 1"} |
| 146 | - | // as historical snapshots and keeping the visible tail aligned. | 141 | + | r.DrawLog(chat, []string{"working 0s", "editor"}, 1, 3) |
| 147 | - | newChat := []string{ | |||
| 148 | - | "user prompt", | |||
| 149 | - | "┌ bash first ─────", "│ old output", "│ wrapped line 1", "│ wrapped line 2", "└──────────────────", | |||
| 150 | - | "┌ bash second ────", "│ second output", "└──────────────────", | |||
| 151 | - | } | |||
| 152 | - | r.DrawLog(newChat, []string{"input"}, 0, 0) | |||
| 153 | got := buf.String() | 142 | got := buf.String() | ||
| 143 | + | if !strings.Contains(got, SeqCursorHome+SeqClearToEnd) { | |||
| 144 | + | t.Fatalf("structural reflow did not reset the visible viewport: %q", got) | |||
| 145 | + | } | |||
| 154 | if strings.Contains(got, SeqClearScreenNoHome) || strings.Contains(got, SeqClearScrollback) { | 146 | if strings.Contains(got, SeqClearScreenNoHome) || strings.Contains(got, SeqClearScrollback) { | ||
| 155 | - | t.Fatalf("structural reflow cleared the terminal: %q", got) | 147 | + | t.Fatalf("structural reflow cleared retained scrollback: %q", got) |
| 148 | + | } | |||
| 149 | + | if strings.Contains(got, "old 1") { | |||
| 150 | + | t.Fatalf("viewport repaint included a row above the visible tail: %q", got) | |||
| 156 | } | 151 | } | ||
| 157 | - | for _, replayed := range []string{"user prompt", "┌ bash first", "┌ bash second"} { | 152 | + | for _, visible := range []string{"working 0s", "editor", SeqShowCursor, "\x1b[3C"} { |
| 158 | - | if strings.Contains(got, replayed) { | 153 | + | if !strings.Contains(got, visible) { |
| 159 | - | t.Fatalf("structural reflow replayed %q into scrollback: %q", replayed, got) | 154 | + | t.Fatalf("viewport repaint missing %q: %q", visible, got) |
| 160 | } | 155 | } | ||
| 161 | } | 156 | } | ||
| 157 | + | if want := len(r.logLines) - r.rows; r.logViewportTop != want { | |||
| 158 | + | t.Fatalf("viewport top = %d, want %d", r.logViewportTop, want) | |||
| 159 | + | } | |||
| 160 | + | if want := len(chat) + 1; r.logHardwareRow != want { | |||
| 161 | + | t.Fatalf("hardware row = %d, want cursor row %d", r.logHardwareRow, want) | |||
| 162 | + | } | |||
| 162 | 163 | ||||
| 163 | buf.Reset() | 164 | buf.Reset() | ||
| 164 | - | appended := append(append([]string(nil), newChat...), "┌ bash third ─────", "│ third output", "└──────────────────") | 165 | + | r.DrawLog(chat, []string{"working 1s", "editor"}, 1, 3) |
| 165 | - | r.DrawLog(appended, []string{"input"}, 0, 0) | 166 | + | if got := buf.String(); !strings.Contains(got, "working 1s") { |
| 166 | - | got = buf.String() | 167 | + | t.Fatalf("status update after viewport reset was lost: %q", got) |
| 167 | - | if !strings.Contains(got, "┌ bash third") || !strings.Contains(got, "│ third output") { | 168 | + | } |
| 168 | - | t.Fatalf("new output was not appended after coordinate rebase: %q", got) | 169 | + | |
| 169 | - | } | 170 | + | buf.Reset() |
| 170 | - | for _, replayed := range []string{"user prompt", "┌ bash first", "┌ bash second"} { | 171 | + | r.DrawLog(append(chat, "next tool"), []string{"working 2s", "editor"}, 1, 3) |
| 171 | - | if strings.Contains(got, replayed) { | 172 | + | if got := buf.String(); !strings.Contains(got, "next tool") { |
| 172 | - | t.Fatalf("append after rebase replayed %q: %q", replayed, got) | 173 | + | t.Fatalf("append after viewport reset was lost: %q", got) |
| 173 | - | } | 174 | + | } |
| 175 | + | } | |||
| 176 | + | ||||
| 177 | + | func TestDrawLogStructuralReflowRepaintsVisibleImageFootprint(t *testing.T) { | |||
| 178 | + | t.Setenv("TERM_PROGRAM", "ghostty") | |||
| 179 | + | var buf bytes.Buffer | |||
| 180 | + | r := NewRenderer(&buf) | |||
| 181 | + | r.Resize(80, 4) | |||
| 182 | + | image := "\x1b_Ga=T;payload\x1b\\" | |||
| 183 | + | boxedBlank := " │ │ " | |||
| 184 | + | r.DrawLog([]string{image, boxedBlank, "caption"}, []string{"working 0s"}, -1, 0) | |||
| 185 | + | buf.Reset() | |||
| 186 | + | ||||
| 187 | + | // The visible tail begins deep inside an image footprint spanning more | |||
| 188 | + | // than one screen. Real footprint rows may retain their sentinel or be | |||
| 189 | + | // wrapped in empty tool-box edges. A viewport reset deletes Kitty images, | |||
| 190 | + | // so it must scan across both forms and replay the escape row above them. | |||
| 191 | + | r.DrawLog([]string{ | |||
| 192 | + | "new row", image, boxedBlank, imageFootprintSentinel, boxedBlank, | |||
| 193 | + | boxedBlank, boxedBlank, "caption", | |||
| 194 | + | }, []string{"working 1s"}, -1, 0) | |||
| 195 | + | got := buf.String() | |||
| 196 | + | if !strings.Contains(got, SeqDeleteKittyImages) { | |||
| 197 | + | t.Fatalf("viewport reset did not clear stale Kitty images: %q", got) | |||
| 198 | + | } | |||
| 199 | + | if !strings.Contains(got, image) { | |||
| 200 | + | t.Fatalf("viewport reset did not repaint the visible image: %q", got) | |||
| 174 | } | 201 | } | ||
| 175 | } | 202 | } | ||
| 176 | 203 | ||||