detached 0a849d0

Merge pull request #2 from bnema/fix/tui-viewport-reflow

fix(tui): keep viewport updates visible after reflow
commit
0a849d0f9ccdf594d97714c6c4f13f996999f39f
author
bnema · · 2026-08-04 08:00 UTC
committer
GitHub · · 2026-08-04 08:00 UTC
merge of
9eef239 + a33efe0 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
2 files · +115 -105
M packages/tui/render.go +55 -72
@@ -215,6 +215,12 @@ func containsImageEscape(s string) bool {
215215 return strings.Contains(s, "\x1b]1337;File=") || strings.Contains(s, "\x1b_G")
216216 }
217217
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+
218224 // paintBackgroundRow applies the optional theme background to a single
219225 // already-truncated terminal row. It pads with spaces to cols so the
220226 // background reaches the right edge, and re-applies the background
@@ -634,10 +640,7 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
634640 delta := len(chatFrame) - len(r.logChat)
635641 reflowAbove := false
636642 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))
641644 for idx := r.logViewportTop; idx < overlapEnd; idx++ {
642645 if r.logChat[idx] != chatFrame[idx] {
643646 reflowAbove = true
@@ -646,81 +649,61 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
646649 }
647650 }
648651 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
679664 }
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
686667 }
687668 }
688669 }
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])
711677 }
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
715699 }
716- lastChanged = idx
717700 }
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+ }
724707 }
725708 }
726709
@@ -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 single224 // paintBackgroundRow applies the optional theme background to a single
219 // already-truncated terminal row. It pads with spaces to cols so the225 // already-truncated terminal row. It pads with spaces to cols so the
220 // background reaches the right edge, and re-applies the background226 // 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 := false641 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 = true646 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 following652+ // Reflow has made the cached logical coordinates unreliable.
650- // logical row, but it does not move the rows already painted by653+ // Repaint only the visible tail: replaying the full transcript
651- // the terminal. Rebase our logical coordinates by the same delta654+ // duplicates scrollback, while rebasing by the total height delta
652- // instead of clearing and replaying the transcript. The inaccessible655+ // can drift when some new rows landed inside the viewport.
653- // prefix remains the historical snapshot the terminal actually656+ w.WriteString(SeqDeleteKittyImages + SeqCursorHome + SeqClearToEnd)
654- // holds; addressable chat and bottom rows stay aligned with their657+ viewportTop := max(0, len(lines)-r.rows)
655- // existing physical rows.658+ start := viewportTop
656- oldLines := r.logLines659+ if start > 0 && imageFootprintBlank(lines[start]) {
657- oldChatLen := len(r.logChat)660+ for idx := start - 1; idx >= 0; idx-- {
658- newViewportTop := r.logViewportTop + delta661+ if containsImageEscape(lines[idx]) {
659- if newViewportTop < 0 {662+ start = idx
660- newViewportTop = 0663+ 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 = rebased670+ for idx := start; idx < len(lines); idx++ {
690- r.logViewportTop = newViewportTop671+ if idx > start {
691- r.logHardwareRow += delta672+ w.WriteString("\r\n")
692- if r.logHardwareRow < 0 {673+ }
693- r.logHardwareRow = 0674+ 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 = idx680+ 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 empty702+ // 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) - 1706+ }
724 }707 }
725 }708 }
726 709
M packages/tui/render_test.go +60 -33
@@ -126,51 +126,78 @@ func TestDrawLogInaccessibleChangePreservesScrollbackSelection(t *testing.T) {
126126 }
127127 }
128128
129-func TestDrawLogStructuralReflowAboveViewportRebasesWithoutReplay(t *testing.T) {
129+func TestDrawLogStructuralReflowResetsVisibleViewport(t *testing.T) {
130130 t.Setenv("TERM_PROGRAM", "ghostty")
131131 var buf bytes.Buffer
132132 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)
140135 buf.Reset()
141136
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)
153142 got := buf.String()
143+ if !strings.Contains(got, SeqCursorHome+SeqClearToEnd) {
144+ t.Fatalf("structural reflow did not reset the visible viewport: %q", got)
145+ }
154146 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)
156151 }
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)
160155 }
161156 }
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+ }
162163
163164 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)
174201 }
175202 }
176203
@@ -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.Buffer131 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 transcript138+ // 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 rows140+ 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

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