detached eddf12c

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
633633 // updated below, so ignored rows remain historical snapshots while new
634634 // output naturally pushes them farther into scrollback.
635635 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)
641647 }
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+ }
649653 }
650654 }
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+ }
655679 }
656680 }
657681
@@ -633,25 +633,49 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
633 // updated below, so ignored rows remain historical snapshots while new633 // 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 = -1636+ // 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 empty655+ 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) {
126126 }
127127 }
128128
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+
129163 func TestDrawLogInaccessibleChangeStillAppendsNewOutput(t *testing.T) {
130164 t.Setenv("TERM_PROGRAM", "")
131165 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.Buffer165 var buf bytes.Buffer

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close