detached cf8265c

fix(tui): stabilize streaming viewport reflows

commit
cf8265ca63bf1d65266c338724aeb9a667c9dcef
author
brice · · 2026-08-09 09:12 UTC
committer
brice · · 2026-08-09 09:32 UTC
parent
1adbe2f
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +156 -33
M packages/tui/render.go +98 -25
@@ -243,6 +243,35 @@ func imageFootprintBlank(s string) bool {
243243 return strings.TrimSpace(s) == ""
244244 }
245245
246+// visibleTailHasImage reports whether an inline image or its blank terminal
247+// footprint intersects the visible tail. Images earlier in retained scrollback
248+// do not prevent ordinary text reflows from using the stable in-place path.
249+func visibleTailHasImage(lines []string, viewportTop int) bool {
250+ if viewportTop < 0 {
251+ viewportTop = 0
252+ }
253+ if viewportTop >= len(lines) {
254+ return false
255+ }
256+ for _, line := range lines[viewportTop:] {
257+ if containsImageEscape(line) {
258+ return true
259+ }
260+ }
261+ if viewportTop == 0 || !imageFootprintBlank(lines[viewportTop]) {
262+ return false
263+ }
264+ for idx := viewportTop - 1; idx >= 0; idx-- {
265+ if containsImageEscape(lines[idx]) {
266+ return true
267+ }
268+ if !imageFootprintBlank(lines[idx]) {
269+ return false
270+ }
271+ }
272+ return false
273+}
274+
246275 // paintBackgroundRow applies the optional theme background to a single
247276 // already-truncated terminal row. It pads with spaces to cols so the
248277 // background reaches the right edge, and re-applies the background
@@ -746,42 +775,86 @@ func (r *Renderer) drawLog(chat, bottom []string, cursorBottomRow, cursorCol, co
746775 reflowAbove := false
747776 if delta != 0 {
748777 overlapEnd := min(len(chatFrame), len(r.logChat))
749- for idx := r.logViewportTop; idx < overlapEnd; idx++ {
778+ // When the viewport starts in the bottom band there are no chat
779+ // rows to compare at the old logical coordinates, but changing
780+ // chat height still shifts the entire visible band.
781+ reflowAbove = r.logViewportTop >= overlapEnd
782+ for idx := r.logViewportTop; !reflowAbove && idx < overlapEnd; idx++ {
750783 if r.logChat[idx] != chatFrame[idx] {
751784 reflowAbove = true
752- break
753785 }
754786 }
755787 }
756788 if reflowAbove {
757- // Reflow has made the cached logical coordinates unreliable.
758- // Repaint only the visible tail: replaying the full transcript
759- // duplicates scrollback, while rebasing by the total height delta
760- // can drift when some new rows landed inside the viewport.
761- w.WriteString(SeqDeleteKittyImages + SeqCursorHome + SeqClearToEnd)
762- viewportTop := max(0, len(lines)-r.rows)
763- start := viewportTop
764- if start > 0 && imageFootprintBlank(lines[start]) {
765- for idx := start - 1; idx >= 0; idx-- {
766- if containsImageEscape(lines[idx]) {
767- start = idx
768- break
789+ // A text-only logical buffer can be rebased without resetting the
790+ // terminal viewport. Advance physical rows when it grows, then
791+ // repaint the visible tail in place for both growth and shrinkage.
792+ // Cursor-home plus erase-to-end makes several terminals visibly
793+ // jump on streaming-markdown reflows.
794+ growth := len(lines) - len(r.logLines)
795+ newViewportTop := max(0, len(lines)-r.rows)
796+ hasVisibleImages := visibleTailHasImage(r.logLines, r.logViewportTop) ||
797+ visibleTailHasImage(lines, newViewportTop)
798+ if !hasVisibleImages && len(lines) >= r.rows {
799+ currentScreenRow := r.logHardwareRow - r.logViewportTop
800+ if currentScreenRow < 0 {
801+ currentScreenRow = 0
802+ }
803+ if currentScreenRow >= r.rows {
804+ currentScreenRow = r.rows - 1
805+ }
806+ if growth > 0 {
807+ if down := r.rows - 1 - currentScreenRow; down > 0 {
808+ w.WriteString("\x1b[" + itoa(down) + "B")
769809 }
770- if !imageFootprintBlank(lines[idx]) {
771- break
810+ for range growth {
811+ w.WriteString("\r\n")
772812 }
813+ currentScreenRow = r.rows - 1
773814 }
774- }
775- for idx := start; idx < len(lines); idx++ {
776- if idx > start {
777- w.WriteString("\r\n")
815+ if currentScreenRow > 0 {
816+ w.WriteString("\x1b[" + itoa(currentScreenRow) + "A")
817+ }
818+ w.WriteString("\r")
819+ for idx := newViewportTop; idx < len(lines); idx++ {
820+ if idx > newViewportTop {
821+ w.WriteString("\r\n")
822+ }
823+ w.WriteString("\x1b[0m")
824+ w.WriteString(SeqClearLine)
825+ w.WriteString(lines[idx])
826+ }
827+ r.logViewportTop = newViewportTop
828+ r.logHardwareRow = len(lines) - 1
829+ } else {
830+ // Inline-image reflows and buffers shorter than the viewport need
831+ // the conservative repaint path. Replaying the full transcript
832+ // would duplicate retained scrollback.
833+ w.WriteString(SeqDeleteKittyImages + SeqCursorHome + SeqClearToEnd)
834+ viewportTop := max(0, len(lines)-r.rows)
835+ start := viewportTop
836+ if start > 0 && imageFootprintBlank(lines[start]) {
837+ for idx := start - 1; idx >= 0; idx-- {
838+ if containsImageEscape(lines[idx]) {
839+ start = idx
840+ break
841+ }
842+ if !imageFootprintBlank(lines[idx]) {
843+ break
844+ }
845+ }
846+ }
847+ for idx := start; idx < len(lines); idx++ {
848+ if idx > start {
849+ w.WriteString("\r\n")
850+ }
851+ w.WriteString("\x1b[0m")
852+ w.WriteString(SeqClearLine)
853+ w.WriteString(lines[idx])
778854 }
779- w.WriteString("\x1b[0m")
780- w.WriteString(SeqClearLine)
781- w.WriteString(lines[idx])
855+ r.logViewportTop = viewportTop
856+ r.logHardwareRow = len(lines) - 1
782857 }
783- r.logViewportTop = viewportTop
784- r.logHardwareRow = len(lines) - 1
785858 firstChanged = -1
786859 lastChanged = -1
787860 } else {
@@ -243,6 +243,35 @@ func imageFootprintBlank(s string) bool {
243 return strings.TrimSpace(s) == ""243 return strings.TrimSpace(s) == ""
244 }244 }
245 245
246+// visibleTailHasImage reports whether an inline image or its blank terminal
247+// footprint intersects the visible tail. Images earlier in retained scrollback
248+// do not prevent ordinary text reflows from using the stable in-place path.
249+func visibleTailHasImage(lines []string, viewportTop int) bool {
250+ if viewportTop < 0 {
251+ viewportTop = 0
252+ }
253+ if viewportTop >= len(lines) {
254+ return false
255+ }
256+ for _, line := range lines[viewportTop:] {
257+ if containsImageEscape(line) {
258+ return true
259+ }
260+ }
261+ if viewportTop == 0 || !imageFootprintBlank(lines[viewportTop]) {
262+ return false
263+ }
264+ for idx := viewportTop - 1; idx >= 0; idx-- {
265+ if containsImageEscape(lines[idx]) {
266+ return true
267+ }
268+ if !imageFootprintBlank(lines[idx]) {
269+ return false
270+ }
271+ }
272+ return false
273+}
274+
246 // paintBackgroundRow applies the optional theme background to a single275 // paintBackgroundRow applies the optional theme background to a single
247 // already-truncated terminal row. It pads with spaces to cols so the276 // already-truncated terminal row. It pads with spaces to cols so the
248 // background reaches the right edge, and re-applies the background277 // background reaches the right edge, and re-applies the background
@@ -746,42 +775,86 @@ func (r *Renderer) drawLog(chat, bottom []string, cursorBottomRow, cursorCol, co
746 reflowAbove := false775 reflowAbove := false
747 if delta != 0 {776 if delta != 0 {
748 overlapEnd := min(len(chatFrame), len(r.logChat))777 overlapEnd := min(len(chatFrame), len(r.logChat))
749- for idx := r.logViewportTop; idx < overlapEnd; idx++ {778+ // When the viewport starts in the bottom band there are no chat
779+ // rows to compare at the old logical coordinates, but changing
780+ // chat height still shifts the entire visible band.
781+ reflowAbove = r.logViewportTop >= overlapEnd
782+ for idx := r.logViewportTop; !reflowAbove && idx < overlapEnd; idx++ {
750 if r.logChat[idx] != chatFrame[idx] {783 if r.logChat[idx] != chatFrame[idx] {
751 reflowAbove = true784 reflowAbove = true
752- break
753 }785 }
754 }786 }
755 }787 }
756 if reflowAbove {788 if reflowAbove {
757- // Reflow has made the cached logical coordinates unreliable.789+ // A text-only logical buffer can be rebased without resetting the
758- // Repaint only the visible tail: replaying the full transcript790+ // terminal viewport. Advance physical rows when it grows, then
759- // duplicates scrollback, while rebasing by the total height delta791+ // repaint the visible tail in place for both growth and shrinkage.
760- // can drift when some new rows landed inside the viewport.792+ // Cursor-home plus erase-to-end makes several terminals visibly
761- w.WriteString(SeqDeleteKittyImages + SeqCursorHome + SeqClearToEnd)793+ // jump on streaming-markdown reflows.
762- viewportTop := max(0, len(lines)-r.rows)794+ growth := len(lines) - len(r.logLines)
763- start := viewportTop795+ newViewportTop := max(0, len(lines)-r.rows)
764- if start > 0 && imageFootprintBlank(lines[start]) {796+ hasVisibleImages := visibleTailHasImage(r.logLines, r.logViewportTop) ||
765- for idx := start - 1; idx >= 0; idx-- {797+ visibleTailHasImage(lines, newViewportTop)
766- if containsImageEscape(lines[idx]) {798+ if !hasVisibleImages && len(lines) >= r.rows {
767- start = idx799+ currentScreenRow := r.logHardwareRow - r.logViewportTop
768- break800+ if currentScreenRow < 0 {
801+ currentScreenRow = 0
802+ }
803+ if currentScreenRow >= r.rows {
804+ currentScreenRow = r.rows - 1
805+ }
806+ if growth > 0 {
807+ if down := r.rows - 1 - currentScreenRow; down > 0 {
808+ w.WriteString("\x1b[" + itoa(down) + "B")
769 }809 }
770- if !imageFootprintBlank(lines[idx]) {810+ for range growth {
771- break811+ w.WriteString("\r\n")
772 }812 }
813+ currentScreenRow = r.rows - 1
773 }814 }
774- }815+ if currentScreenRow > 0 {
775- for idx := start; idx < len(lines); idx++ {816+ w.WriteString("\x1b[" + itoa(currentScreenRow) + "A")
776- if idx > start {817+ }
777- w.WriteString("\r\n")818+ w.WriteString("\r")
819+ for idx := newViewportTop; idx < len(lines); idx++ {
820+ if idx > newViewportTop {
821+ w.WriteString("\r\n")
822+ }
823+ w.WriteString("\x1b[0m")
824+ w.WriteString(SeqClearLine)
825+ w.WriteString(lines[idx])
826+ }
827+ r.logViewportTop = newViewportTop
828+ r.logHardwareRow = len(lines) - 1
829+ } else {
830+ // Inline-image reflows and buffers shorter than the viewport need
831+ // the conservative repaint path. Replaying the full transcript
832+ // would duplicate retained scrollback.
833+ w.WriteString(SeqDeleteKittyImages + SeqCursorHome + SeqClearToEnd)
834+ viewportTop := max(0, len(lines)-r.rows)
835+ start := viewportTop
836+ if start > 0 && imageFootprintBlank(lines[start]) {
837+ for idx := start - 1; idx >= 0; idx-- {
838+ if containsImageEscape(lines[idx]) {
839+ start = idx
840+ break
841+ }
842+ if !imageFootprintBlank(lines[idx]) {
843+ break
844+ }
845+ }
846+ }
847+ for idx := start; idx < len(lines); idx++ {
848+ if idx > start {
849+ w.WriteString("\r\n")
850+ }
851+ w.WriteString("\x1b[0m")
852+ w.WriteString(SeqClearLine)
853+ w.WriteString(lines[idx])
778 }854 }
779- w.WriteString("\x1b[0m")855+ r.logViewportTop = viewportTop
780- w.WriteString(SeqClearLine)856+ r.logHardwareRow = len(lines) - 1
781- w.WriteString(lines[idx])
782 }857 }
783- r.logViewportTop = viewportTop
784- r.logHardwareRow = len(lines) - 1
785 firstChanged = -1858 firstChanged = -1
786 lastChanged = -1859 lastChanged = -1
787 } else {860 } else {
M packages/tui/render_test.go +58 -8
@@ -126,7 +126,7 @@ func TestDrawLogInaccessibleChangePreservesScrollbackSelection(t *testing.T) {
126126 }
127127 }
128128
129-func TestDrawLogStructuralReflowResetsVisibleViewport(t *testing.T) {
129+func TestDrawLogStructuralGrowthScrollsBeforeRepaintingVisibleTail(t *testing.T) {
130130 t.Setenv("TERM_PROGRAM", "ghostty")
131131 var buf bytes.Buffer
132132 r := NewRenderer(&buf)
@@ -135,16 +135,20 @@ func TestDrawLogStructuralReflowResetsVisibleViewport(t *testing.T) {
135135 buf.Reset()
136136
137137 // 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.
138+ // viewport. Scroll by the logical growth before repainting the visible
139+ // tail. Homing and clearing the viewport here makes terminals visibly
140+ // jump during every streaming markdown reflow.
140141 chat := []string{"new row", "old 0", "old 1"}
141142 r.DrawLog(chat, []string{"working 0s", "editor"}, 1, 3)
142143 got := buf.String()
143- if !strings.Contains(got, SeqCursorHome+SeqClearToEnd) {
144- t.Fatalf("structural reflow did not reset the visible viewport: %q", got)
144+ if strings.Contains(got, SeqCursorHome+SeqClearToEnd) {
145+ t.Fatalf("structural growth reset the visible viewport: %q", got)
145146 }
146147 if strings.Contains(got, SeqClearScreenNoHome) || strings.Contains(got, SeqClearScrollback) {
147- t.Fatalf("structural reflow cleared retained scrollback: %q", got)
148+ t.Fatalf("structural growth cleared retained scrollback: %q", got)
149+ }
150+ if !strings.Contains(got, "\r\n\r\n") {
151+ t.Fatalf("structural growth did not advance the viewport by two consecutive rows: %q", got)
148152 }
149153 if strings.Contains(got, "old 1") {
150154 t.Fatalf("viewport repaint included a row above the visible tail: %q", got)
@@ -164,13 +168,59 @@ func TestDrawLogStructuralReflowResetsVisibleViewport(t *testing.T) {
164168 buf.Reset()
165169 r.DrawLog(chat, []string{"working 1s", "editor"}, 1, 3)
166170 if got := buf.String(); !strings.Contains(got, "working 1s") {
167- t.Fatalf("status update after viewport reset was lost: %q", got)
171+ t.Fatalf("status update after viewport repaint was lost: %q", got)
168172 }
169173
170174 buf.Reset()
171175 r.DrawLog(append(chat, "next tool"), []string{"working 2s", "editor"}, 1, 3)
172176 if got := buf.String(); !strings.Contains(got, "next tool") {
173- t.Fatalf("append after viewport reset was lost: %q", got)
177+ t.Fatalf("append after viewport repaint was lost: %q", got)
178+ }
179+}
180+
181+func TestDrawLogStructuralShrinkRepaintsVisibleTailInPlace(t *testing.T) {
182+ t.Setenv("TERM_PROGRAM", "ghostty")
183+ var buf bytes.Buffer
184+ r := NewRenderer(&buf)
185+ r.Resize(80, 4)
186+ r.DrawLog([]string{"partial", "old 0", "old 1", "old 2"}, []string{"working", "editor"}, 1, 2)
187+ buf.Reset()
188+
189+ chat := []string{"old 0", "old 1", "old 2"}
190+ r.DrawLog(chat, []string{"working", "editor"}, 1, 2)
191+ got := buf.String()
192+ if strings.Contains(got, SeqCursorHome+SeqClearToEnd) {
193+ t.Fatalf("structural shrink reset the visible viewport: %q", got)
194+ }
195+ for _, visible := range []string{"old 2", "working", "editor"} {
196+ if !strings.Contains(got, visible) {
197+ t.Fatalf("viewport repaint missing %q after shrink: %q", visible, got)
198+ }
199+ }
200+ if !strings.Contains(got, "\r\x1b[0m"+SeqClearLine+"old 2") {
201+ t.Fatalf("structural shrink did not return to column zero before repaint: %q", got)
202+ }
203+ if want := len(r.logLines) - r.rows; r.logViewportTop != want {
204+ t.Fatalf("viewport top = %d, want %d", r.logViewportTop, want)
205+ }
206+}
207+
208+func TestDrawLogStructuralGrowthIgnoresImagesOutsideVisibleTail(t *testing.T) {
209+ t.Setenv("TERM_PROGRAM", "ghostty")
210+ var buf bytes.Buffer
211+ r := NewRenderer(&buf)
212+ r.Resize(80, 3)
213+ image := "\x1b_Ga=T;payload\x1b\\"
214+ r.DrawLog([]string{image, "line 1", "line 2", "line 3"}, []string{"input"}, -1, 0)
215+ buf.Reset()
216+
217+ r.DrawLog([]string{"new row", image, "line 1", "line 2", "line 3"}, []string{"input"}, -1, 0)
218+ got := buf.String()
219+ if strings.Contains(got, SeqCursorHome+SeqClearToEnd) {
220+ t.Fatalf("image in retained scrollback forced a viewport reset: %q", got)
221+ }
222+ if !strings.Contains(got, "line 3") || !strings.Contains(got, "input") {
223+ t.Fatalf("visible tail was not repainted after growth: %q", got)
174224 }
175225 }
176226
@@ -126,7 +126,7 @@ func TestDrawLogInaccessibleChangePreservesScrollbackSelection(t *testing.T) {
126 }126 }
127 }127 }
128 128
129-func TestDrawLogStructuralReflowResetsVisibleViewport(t *testing.T) {129+func TestDrawLogStructuralGrowthScrollsBeforeRepaintingVisibleTail(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)
@@ -135,16 +135,20 @@ func TestDrawLogStructuralReflowResetsVisibleViewport(t *testing.T) {
135 buf.Reset()135 buf.Reset()
136 136
137 // Chat and the bottom band both grow while an old row is above the137 // Chat and the bottom band both grow while an old row is above the
138- // viewport. Rebasing by chat growth alone leaves both tracked terminal138+ // viewport. Scroll by the logical growth before repainting the visible
139- // coordinates one row behind, so future status updates paint off-screen.139+ // tail. Homing and clearing the viewport here makes terminals visibly
140+ // jump during every streaming markdown reflow.
140 chat := []string{"new row", "old 0", "old 1"}141 chat := []string{"new row", "old 0", "old 1"}
141 r.DrawLog(chat, []string{"working 0s", "editor"}, 1, 3)142 r.DrawLog(chat, []string{"working 0s", "editor"}, 1, 3)
142 got := buf.String()143 got := buf.String()
143- if !strings.Contains(got, SeqCursorHome+SeqClearToEnd) {144+ if strings.Contains(got, SeqCursorHome+SeqClearToEnd) {
144- t.Fatalf("structural reflow did not reset the visible viewport: %q", got)145+ t.Fatalf("structural growth reset the visible viewport: %q", got)
145 }146 }
146 if strings.Contains(got, SeqClearScreenNoHome) || strings.Contains(got, SeqClearScrollback) {147 if strings.Contains(got, SeqClearScreenNoHome) || strings.Contains(got, SeqClearScrollback) {
147- t.Fatalf("structural reflow cleared retained scrollback: %q", got)148+ t.Fatalf("structural growth cleared retained scrollback: %q", got)
149+ }
150+ if !strings.Contains(got, "\r\n\r\n") {
151+ t.Fatalf("structural growth did not advance the viewport by two consecutive rows: %q", got)
148 }152 }
149 if strings.Contains(got, "old 1") {153 if strings.Contains(got, "old 1") {
150 t.Fatalf("viewport repaint included a row above the visible tail: %q", got)154 t.Fatalf("viewport repaint included a row above the visible tail: %q", got)
@@ -164,13 +168,59 @@ func TestDrawLogStructuralReflowResetsVisibleViewport(t *testing.T) {
164 buf.Reset()168 buf.Reset()
165 r.DrawLog(chat, []string{"working 1s", "editor"}, 1, 3)169 r.DrawLog(chat, []string{"working 1s", "editor"}, 1, 3)
166 if got := buf.String(); !strings.Contains(got, "working 1s") {170 if got := buf.String(); !strings.Contains(got, "working 1s") {
167- t.Fatalf("status update after viewport reset was lost: %q", got)171+ t.Fatalf("status update after viewport repaint was lost: %q", got)
168 }172 }
169 173
170 buf.Reset()174 buf.Reset()
171 r.DrawLog(append(chat, "next tool"), []string{"working 2s", "editor"}, 1, 3)175 r.DrawLog(append(chat, "next tool"), []string{"working 2s", "editor"}, 1, 3)
172 if got := buf.String(); !strings.Contains(got, "next tool") {176 if got := buf.String(); !strings.Contains(got, "next tool") {
173- t.Fatalf("append after viewport reset was lost: %q", got)177+ t.Fatalf("append after viewport repaint was lost: %q", got)
178+ }
179+}
180+
181+func TestDrawLogStructuralShrinkRepaintsVisibleTailInPlace(t *testing.T) {
182+ t.Setenv("TERM_PROGRAM", "ghostty")
183+ var buf bytes.Buffer
184+ r := NewRenderer(&buf)
185+ r.Resize(80, 4)
186+ r.DrawLog([]string{"partial", "old 0", "old 1", "old 2"}, []string{"working", "editor"}, 1, 2)
187+ buf.Reset()
188+
189+ chat := []string{"old 0", "old 1", "old 2"}
190+ r.DrawLog(chat, []string{"working", "editor"}, 1, 2)
191+ got := buf.String()
192+ if strings.Contains(got, SeqCursorHome+SeqClearToEnd) {
193+ t.Fatalf("structural shrink reset the visible viewport: %q", got)
194+ }
195+ for _, visible := range []string{"old 2", "working", "editor"} {
196+ if !strings.Contains(got, visible) {
197+ t.Fatalf("viewport repaint missing %q after shrink: %q", visible, got)
198+ }
199+ }
200+ if !strings.Contains(got, "\r\x1b[0m"+SeqClearLine+"old 2") {
201+ t.Fatalf("structural shrink did not return to column zero before repaint: %q", got)
202+ }
203+ if want := len(r.logLines) - r.rows; r.logViewportTop != want {
204+ t.Fatalf("viewport top = %d, want %d", r.logViewportTop, want)
205+ }
206+}
207+
208+func TestDrawLogStructuralGrowthIgnoresImagesOutsideVisibleTail(t *testing.T) {
209+ t.Setenv("TERM_PROGRAM", "ghostty")
210+ var buf bytes.Buffer
211+ r := NewRenderer(&buf)
212+ r.Resize(80, 3)
213+ image := "\x1b_Ga=T;payload\x1b\\"
214+ r.DrawLog([]string{image, "line 1", "line 2", "line 3"}, []string{"input"}, -1, 0)
215+ buf.Reset()
216+
217+ r.DrawLog([]string{"new row", image, "line 1", "line 2", "line 3"}, []string{"input"}, -1, 0)
218+ got := buf.String()
219+ if strings.Contains(got, SeqCursorHome+SeqClearToEnd) {
220+ t.Fatalf("image in retained scrollback forced a viewport reset: %q", got)
221+ }
222+ if !strings.Contains(got, "line 3") || !strings.Contains(got, "input") {
223+ t.Fatalf("visible tail was not repainted after growth: %q", got)
174 }224 }
175 }225 }
176 226

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