detached b289bf6

Fix transcript duplication after viewport reflow

commit
b289bf69d5e11390b1590db644cfcceb9a29e588
author
patriceckhart · · 2026-07-30 06:04 UTC
parent
1c2b2f0
signature
unsigned
refs
tag: v0.3.26
2 files · +104 -45
M packages/tui/render.go +77 -32
@@ -629,53 +629,98 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
629629 // history. Replaying the full logical buffer to update one of them
630630 // duplicates that history, and erasing it first destroys native text
631631 // selections. Ignore inaccessible changes and patch only the first
632- // changed row that is still addressable. The cached logical buffer is
633- // updated below, so ignored rows remain historical snapshots while new
634- // output naturally pushes them farther into scrollback.
632+ // changed row that is still addressable.
635633 if firstChanged >= 0 && firstChanged < r.logViewportTop {
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) {
634+ delta := len(chatFrame) - len(r.logChat)
635+ reflowAbove := false
636+ if delta != 0 {
644637 overlapEnd := len(chatFrame)
645638 if len(r.logChat) < overlapEnd {
646639 overlapEnd = len(r.logChat)
647640 }
648641 for idx := r.logViewportTop; idx < overlapEnd; idx++ {
649642 if r.logChat[idx] != chatFrame[idx] {
650- structuralReflow = true
643+ reflowAbove = true
651644 break
652645 }
653646 }
654647 }
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]
648+ 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]
679+ }
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]
686+ }
668687 }
669- if oldLine != newLine {
688+ }
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]
711+ }
712+ if oldLine != newLine {
713+ if firstChanged == -1 {
670714 firstChanged = idx
671- break
672715 }
716+ lastChanged = idx
673717 }
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- }
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
679724 }
680725 }
681726
@@ -629,53 +629,98 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
629 // history. Replaying the full logical buffer to update one of them629 // history. Replaying the full logical buffer to update one of them
630 // duplicates that history, and erasing it first destroys native text630 // duplicates that history, and erasing it first destroys native text
631 // selections. Ignore inaccessible changes and patch only the first631 // selections. Ignore inaccessible changes and patch only the first
632- // changed row that is still addressable. The cached logical buffer is632+ // changed row that is still addressable.
633- // updated below, so ignored rows remain historical snapshots while new
634- // output naturally pushes them farther into scrollback.
635 if firstChanged >= 0 && firstChanged < r.logViewportTop {633 if firstChanged >= 0 && firstChanged < r.logViewportTop {
636- // A height change above the viewport shifts the logical row numbers634+ delta := len(chatFrame) - len(r.logChat)
637- // of everything below it. If any already-emitted visible row also635+ reflowAbove := false
638- // changed, an index-based patch would target the wrong physical row636+ if delta != 0 {
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)637 overlapEnd := len(chatFrame)
645 if len(r.logChat) < overlapEnd {638 if len(r.logChat) < overlapEnd {
646 overlapEnd = len(r.logChat)639 overlapEnd = len(r.logChat)
647 }640 }
648 for idx := r.logViewportTop; idx < overlapEnd; idx++ {641 for idx := r.logViewportTop; idx < overlapEnd; idx++ {
649 if r.logChat[idx] != chatFrame[idx] {642 if r.logChat[idx] != chatFrame[idx] {
650- structuralReflow = true643+ reflowAbove = true
651 break644 break
652 }645 }
653 }646 }
654 }647 }
655- if structuralReflow {648+ if reflowAbove {
656- writeFull(true, false)649+ // A height change above the viewport shifts every following
657- firstChanged = -1650+ // logical row, but it does not move the rows already painted by
658- } else {651+ // the terminal. Rebase our logical coordinates by the same delta
659- firstChanged = -1652+ // instead of clearing and replaying the transcript. The inaccessible
660- for idx := r.logViewportTop; idx < maxLines; idx++ {653+ // prefix remains the historical snapshot the terminal actually
661- oldLine := ""654+ // holds; addressable chat and bottom rows stay aligned with their
662- if idx < len(r.logLines) {655+ // existing physical rows.
663- oldLine = r.logLines[idx]656+ oldLines := r.logLines
664- }657+ oldChatLen := len(r.logChat)
665- newLine := ""658+ newViewportTop := r.logViewportTop + delta
666- if idx < len(lines) {659+ if newViewportTop < 0 {
667- newLine = lines[idx]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]
679+ }
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]
686+ }
668 }687 }
669- if oldLine != newLine {688+ }
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]
711+ }
712+ if oldLine != newLine {
713+ if firstChanged == -1 {
670 firstChanged = idx714 firstChanged = idx
671- break
672 }715 }
716+ lastChanged = idx
673 }717 }
674- // A newly appended blank row compares equal to the implicit empty718+ }
675- // row past the old slice, but it still has to advance the terminal.719+ // A newly appended blank row compares equal to the implicit empty
676- if firstChanged == -1 && len(lines) > len(r.logLines) && len(r.logLines) >= r.logViewportTop {720+ // row past the old slice, but it still has to advance the terminal.
677- firstChanged = len(r.logLines)721+ if firstChanged == -1 && len(lines) > len(r.logLines) && len(r.logLines) >= r.logViewportTop {
678- }722+ firstChanged = len(r.logLines)
723+ lastChanged = len(lines) - 1
679 }724 }
680 }725 }
681 726
M packages/tui/render_test.go +27 -13
@@ -126,37 +126,51 @@ func TestDrawLogInaccessibleChangePreservesScrollbackSelection(t *testing.T) {
126126 }
127127 }
128128
129-func TestDrawLogStructuralReflowAboveViewportRepaintsToolFrames(t *testing.T) {
130- t.Setenv("TERM_PROGRAM", "")
129+func TestDrawLogStructuralReflowAboveViewportRebasesWithoutReplay(t *testing.T) {
130+ t.Setenv("TERM_PROGRAM", "ghostty")
131131 var buf bytes.Buffer
132132 r := NewRenderer(&buf)
133133 r.Resize(80, 4)
134134 oldChat := []string{
135- "┌ bash first ─────", "│", "│ old output", "└──────────────────",
135+ "user prompt",
136+ "┌ bash first ─────", "│ old output", "└──────────────────",
136137 "┌ bash second ────", "│ second output", "└──────────────────",
137138 }
138139 r.DrawLog(oldChat, []string{"input"}, 0, 0)
139140 buf.Reset()
140141
141142 // 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.
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.
144147 newChat := []string{
145- "┌ bash first ─────", "│", "│ old output", "│ wrapped line 1", "│ wrapped line 2", "└──────────────────",
148+ "user prompt",
149+ "┌ bash first ─────", "│ old output", "│ wrapped line 1", "│ wrapped line 2", "└──────────────────",
146150 "┌ bash second ────", "│ second output", "└──────────────────",
147151 }
148152 r.DrawLog(newChat, []string{"input"}, 0, 0)
149153 got := buf.String()
150- if !strings.Contains(got, SeqClearScreenNoHome) {
151- t.Fatalf("structural reflow was patched in place instead of repainted: %q", got)
154+ if strings.Contains(got, SeqClearScreenNoHome) || strings.Contains(got, SeqClearScrollback) {
155+ t.Fatalf("structural reflow cleared the terminal: %q", got)
152156 }
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)
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)
156160 }
157161 }
158- if strings.Contains(got, SeqClearScrollback) {
159- t.Fatalf("automatic recovery erased terminal scrollback: %q", got)
162+
163+ 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+ }
160174 }
161175 }
162176
@@ -126,37 +126,51 @@ func TestDrawLogInaccessibleChangePreservesScrollbackSelection(t *testing.T) {
126 }126 }
127 }127 }
128 128
129-func TestDrawLogStructuralReflowAboveViewportRepaintsToolFrames(t *testing.T) {129+func TestDrawLogStructuralReflowAboveViewportRebasesWithoutReplay(t *testing.T) {
130- t.Setenv("TERM_PROGRAM", "")130+ t.Setenv("TERM_PROGRAM", "ghostty")
131 var buf bytes.Buffer131 var buf bytes.Buffer
132 r := NewRenderer(&buf)132 r := NewRenderer(&buf)
133 r.Resize(80, 4)133 r.Resize(80, 4)
134 oldChat := []string{134 oldChat := []string{
135- "┌ bash first ─────", "│", "│ old output", "└──────────────────",135+ "user prompt",
136+ "┌ bash first ─────", "│ old output", "└──────────────────",
136 "┌ bash second ────", "│ second output", "└──────────────────",137 "┌ bash second ────", "│ second output", "└──────────────────",
137 }138 }
138 r.DrawLog(oldChat, []string{"input"}, 0, 0)139 r.DrawLog(oldChat, []string{"input"}, 0, 0)
139 buf.Reset()140 buf.Reset()
140 141
141 // The first tool gains wrapped rows after its top has scrolled away.142 // The first tool gains wrapped rows after its top has scrolled away.
142- // Every following logical row shifts, so patching by the old indexes143+ // Clearing the viewport and replaying the complete logical transcript
143- // would draw the second header inside the first tool's border.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.
144 newChat := []string{147 newChat := []string{
145- "┌ bash first ─────", "│", "│ old output", "│ wrapped line 1", "│ wrapped line 2", "└──────────────────",148+ "user prompt",
149+ "┌ bash first ─────", "│ old output", "│ wrapped line 1", "│ wrapped line 2", "└──────────────────",
146 "┌ bash second ────", "│ second output", "└──────────────────",150 "┌ bash second ────", "│ second output", "└──────────────────",
147 }151 }
148 r.DrawLog(newChat, []string{"input"}, 0, 0)152 r.DrawLog(newChat, []string{"input"}, 0, 0)
149 got := buf.String()153 got := buf.String()
150- if !strings.Contains(got, SeqClearScreenNoHome) {154+ if strings.Contains(got, SeqClearScreenNoHome) || strings.Contains(got, SeqClearScrollback) {
151- t.Fatalf("structural reflow was patched in place instead of repainted: %q", got)155+ t.Fatalf("structural reflow cleared the terminal: %q", got)
152 }156 }
153- for _, want := range []string{"│ wrapped line 2", "┌ bash second", "└──────────────────"} {157+ for _, replayed := range []string{"user prompt", "┌ bash first", "┌ bash second"} {
154- if !strings.Contains(got, want) {158+ if strings.Contains(got, replayed) {
155- t.Fatalf("repaint missing %q: %q", want, got)159+ t.Fatalf("structural reflow replayed %q into scrollback: %q", replayed, got)
156 }160 }
157 }161 }
158- if strings.Contains(got, SeqClearScrollback) {162+
159- t.Fatalf("automatic recovery erased terminal scrollback: %q", got)163+ 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+ }
160 }174 }
161 }175 }
162 176

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