detached aceaffd

tui: keep live output outside scrollback

commit
aceaffdec2477ae03d1ba6a0b7a97df673d16958
author
patriceckhart · · 2026-05-04 13:05 UTC
parent
76ca012
signature
unsigned
2 files · +108 -11
M internal/agent/modes/interactive.go +34 -6
@@ -198,6 +198,7 @@ type Interactive struct {
198198 toolOrder []string
199199 statusErr string
200200 statusOK string
201+ liveBlock []string // live streaming/tool progress rendered outside scrollback
201202 helpBlock []string // rendered above the chat when /help was typed
202203 cumUsage provider.Usage
203204 lastCtxInput int // input_tokens of the most recent turn — approximates current context size
@@ -686,8 +687,21 @@ func (i *Interactive) buildChatLocked(cols int) []string {
686687 }
687688 }
688689 i.view.Err = i.statusErr
689-
690+ i.liveBlock = i.view.BuildLive(cols)
691+
692+ streamingActive := i.view.StreamingActive
693+ streaming := i.view.Streaming
694+ toolCalls := i.view.ToolCalls
695+ errLine := i.view.Err
696+ i.view.StreamingActive = false
697+ i.view.Streaming = ""
698+ i.view.ToolCalls = nil
699+ i.view.Err = ""
690700 chat := i.view.Build(cols)
701+ i.view.StreamingActive = streamingActive
702+ i.view.Streaming = streaming
703+ i.view.ToolCalls = toolCalls
704+ i.view.Err = errLine
691705
692706 // Welcome banner: shown at the top of the chat area when there is
693707 // no transcript yet. Disappears after the first message is sent.
@@ -950,7 +964,15 @@ func (i *Interactive) redraw() {
950964 // content. The status block and editor get their own dedicated
951965 // blanks so spacing stays consistent whether or not a dialog or
952966 // popup is showing.
953- bottom := make([]string, 0, len(dialog)+len(suggest)+len(queue)+len(edLines)+8)
967+ bottom := make([]string, 0, len(i.liveBlock)+len(dialog)+len(suggest)+len(queue)+len(edLines)+9)
968+ if len(i.liveBlock) > 0 {
969+ // Live streaming/tool rows are rendered outside the immutable
970+ // transcript so native scrollback stays stable while the agent
971+ // works. Add the same breathing row the finalized transcript path
972+ // gets between adjacent messages.
973+ bottom = append(bottom, "")
974+ bottom = append(bottom, i.liveBlock...)
975+ }
954976 if len(dialog) > 0 {
955977 bottom = append(bottom, "")
956978 }
@@ -1079,23 +1101,29 @@ func (i *Interactive) redraw() {
10791101 // statusLines and edLines (input breathing room). Without
10801102 // these the rendered cursor would land on a blank instead of
10811103 // inside the editor row.
1082- cursorRow := dialogLead + len(dialog) + len(suggest) + len(queue) + 1 + len(statusLines) + 1 + curR
1104+ liveRows := len(i.liveBlock)
1105+ if liveRows > 0 {
1106+ // Account for the leading separator row inserted before live
1107+ // streaming/tool content in the bottom block.
1108+ liveRows++
1109+ }
1110+ cursorRow := liveRows + dialogLead + len(dialog) + len(suggest) + len(queue) + 1 + len(statusLines) + 1 + curR
10831111 cursorCol := curC
10841112 if i.btwDialog.Active() {
10851113 if r, c := i.btwDialog.CursorPos(cols); r >= 0 {
1086- cursorRow = dialogLead + r
1114+ cursorRow = liveRows + dialogLead + r
10871115 cursorCol = c
10881116 }
10891117 }
10901118 if i.dialog.Active() {
10911119 if r, c := i.dialog.CursorPos(cols); r >= 0 {
1092- cursorRow = dialogLead + r
1120+ cursorRow = liveRows + dialogLead + r
10931121 cursorCol = c
10941122 }
10951123 }
10961124 if i.sessionDialog.Active() {
10971125 if r, c := i.sessionDialog.CursorPos(); r >= 0 {
1098- cursorRow = dialogLead + r
1126+ cursorRow = liveRows + dialogLead + r
10991127 cursorCol = c
11001128 }
11011129 }
@@ -198,6 +198,7 @@ type Interactive struct {
198 toolOrder []string198 toolOrder []string
199 statusErr string199 statusErr string
200 statusOK string200 statusOK string
201+ liveBlock []string // live streaming/tool progress rendered outside scrollback
201 helpBlock []string // rendered above the chat when /help was typed202 helpBlock []string // rendered above the chat when /help was typed
202 cumUsage provider.Usage203 cumUsage provider.Usage
203 lastCtxInput int // input_tokens of the most recent turn — approximates current context size204 lastCtxInput int // input_tokens of the most recent turn — approximates current context size
@@ -686,8 +687,21 @@ func (i *Interactive) buildChatLocked(cols int) []string {
686 }687 }
687 }688 }
688 i.view.Err = i.statusErr689 i.view.Err = i.statusErr
689-690+ i.liveBlock = i.view.BuildLive(cols)
691+
692+ streamingActive := i.view.StreamingActive
693+ streaming := i.view.Streaming
694+ toolCalls := i.view.ToolCalls
695+ errLine := i.view.Err
696+ i.view.StreamingActive = false
697+ i.view.Streaming = ""
698+ i.view.ToolCalls = nil
699+ i.view.Err = ""
690 chat := i.view.Build(cols)700 chat := i.view.Build(cols)
701+ i.view.StreamingActive = streamingActive
702+ i.view.Streaming = streaming
703+ i.view.ToolCalls = toolCalls
704+ i.view.Err = errLine
691 705
692 // Welcome banner: shown at the top of the chat area when there is706 // Welcome banner: shown at the top of the chat area when there is
693 // no transcript yet. Disappears after the first message is sent.707 // no transcript yet. Disappears after the first message is sent.
@@ -950,7 +964,15 @@ func (i *Interactive) redraw() {
950 // content. The status block and editor get their own dedicated964 // content. The status block and editor get their own dedicated
951 // blanks so spacing stays consistent whether or not a dialog or965 // blanks so spacing stays consistent whether or not a dialog or
952 // popup is showing.966 // popup is showing.
953- bottom := make([]string, 0, len(dialog)+len(suggest)+len(queue)+len(edLines)+8)967+ bottom := make([]string, 0, len(i.liveBlock)+len(dialog)+len(suggest)+len(queue)+len(edLines)+9)
968+ if len(i.liveBlock) > 0 {
969+ // Live streaming/tool rows are rendered outside the immutable
970+ // transcript so native scrollback stays stable while the agent
971+ // works. Add the same breathing row the finalized transcript path
972+ // gets between adjacent messages.
973+ bottom = append(bottom, "")
974+ bottom = append(bottom, i.liveBlock...)
975+ }
954 if len(dialog) > 0 {976 if len(dialog) > 0 {
955 bottom = append(bottom, "")977 bottom = append(bottom, "")
956 }978 }
@@ -1079,23 +1101,29 @@ func (i *Interactive) redraw() {
1079 // statusLines and edLines (input breathing room). Without1101 // statusLines and edLines (input breathing room). Without
1080 // these the rendered cursor would land on a blank instead of1102 // these the rendered cursor would land on a blank instead of
1081 // inside the editor row.1103 // inside the editor row.
1082- cursorRow := dialogLead + len(dialog) + len(suggest) + len(queue) + 1 + len(statusLines) + 1 + curR1104+ liveRows := len(i.liveBlock)
1105+ if liveRows > 0 {
1106+ // Account for the leading separator row inserted before live
1107+ // streaming/tool content in the bottom block.
1108+ liveRows++
1109+ }
1110+ cursorRow := liveRows + dialogLead + len(dialog) + len(suggest) + len(queue) + 1 + len(statusLines) + 1 + curR
1083 cursorCol := curC1111 cursorCol := curC
1084 if i.btwDialog.Active() {1112 if i.btwDialog.Active() {
1085 if r, c := i.btwDialog.CursorPos(cols); r >= 0 {1113 if r, c := i.btwDialog.CursorPos(cols); r >= 0 {
1086- cursorRow = dialogLead + r1114+ cursorRow = liveRows + dialogLead + r
1087 cursorCol = c1115 cursorCol = c
1088 }1116 }
1089 }1117 }
1090 if i.dialog.Active() {1118 if i.dialog.Active() {
1091 if r, c := i.dialog.CursorPos(cols); r >= 0 {1119 if r, c := i.dialog.CursorPos(cols); r >= 0 {
1092- cursorRow = dialogLead + r1120+ cursorRow = liveRows + dialogLead + r
1093 cursorCol = c1121 cursorCol = c
1094 }1122 }
1095 }1123 }
1096 if i.sessionDialog.Active() {1124 if i.sessionDialog.Active() {
1097 if r, c := i.sessionDialog.CursorPos(); r >= 0 {1125 if r, c := i.sessionDialog.CursorPos(); r >= 0 {
1098- cursorRow = dialogLead + r1126+ cursorRow = liveRows + dialogLead + r
1099 cursorCol = c1127 cursorCol = c
1100 }1128 }
1101 }1129 }
M internal/tui/view.go +74 -5
@@ -183,6 +183,47 @@ func (v *View) Build(width int) []string {
183183 return lines
184184 }
185185
186+// BuildLive renders only in-flight assistant/tool/error state. Main-screen
187+// scrollback renderers can keep these rows outside the immutable transcript
188+// so native scrolling stays stable while a turn streams.
189+func (v *View) BuildLive(width int) []string {
190+ var out []string
191+ if v.StreamingActive && strings.TrimSpace(v.Streaming) != "" {
192+ const indent = " "
193+ inner := assistantBodyWidth(width - len(indent))
194+ md := RenderMarkdown(v.Streaming, v.Theme, inner)
195+ for _, l := range strings.Split(md, "\n") {
196+ for _, w := range wrapANSILine(l, inner) {
197+ out = append(out, indent+w)
198+ }
199+ }
200+ out = append(out, "")
201+ }
202+ finalised := map[string]bool{}
203+ for _, m := range v.Messages {
204+ for _, c := range m.Content {
205+ switch b := c.(type) {
206+ case provider.ToolCallBlock:
207+ finalised[b.ID] = true
208+ case provider.ToolResultBlock:
209+ finalised[b.CallID] = true
210+ }
211+ }
212+ }
213+ for _, tc := range v.ToolCalls {
214+ if finalised[tc.ID] {
215+ continue
216+ }
217+ out = append(out, v.renderToolCall(tc, width)...)
218+ out = append(out, "")
219+ }
220+ if v.Err != "" {
221+ out = append(out, v.Theme.FG256(v.Theme.Error, "✖ "+v.Err))
222+ out = append(out, "")
223+ }
224+ return out
225+}
226+
186227 // BuildWithAnchors is like Build but additionally reports the first
187228 // row occupied by each message in v.Messages. Callers that need to
188229 // scroll to a specific turn (the /jump dialog) use the anchor slice
@@ -646,7 +687,7 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string {
646687 if tc.Error {
647688 color = v.Theme.Error
648689 }
649- body := toolResultBlock(v.Theme, tc.Result, width, color)
690+ body := toolResultBlock(v.Theme, tc.Result, toolBoxBodyRenderWidth(width), color)
650691 for _, l := range v.collapseToolBody(body, false) {
651692 imgCells, stripped := parseImageFootprint(l)
652693 if hasImageEscapeLine(stripped) {
@@ -748,6 +789,7 @@ const toolBoxOuterMargin = 2
748789 // of the line so the right corner sits at column width-1, matching the
749790 // closing edge.
750791 func toolBoxTop(th Theme, label string, width int) string {
792+ label = oneLineToolLabel(label)
751793 w := width - 2*toolBoxOuterMargin
752794 if w < 12 {
753795 w = 12
@@ -782,6 +824,10 @@ func toolBoxTop(th Theme, label string, width int) string {
782824 return margin + th.FG256(th.Muted, prefix) + th.FG256(th.FG, name) + th.FG256(th.Muted, rest+suffix+fillStr+"┐") + margin
783825 }
784826
827+func oneLineToolLabel(label string) string {
828+ return strings.Join(strings.Fields(label), " ")
829+}
830+
785831 func splitToolLabel(label string) (name, rest string) {
786832 label = strings.TrimLeft(label, " ")
787833 if label == "" {
@@ -861,6 +907,25 @@ func parseImageFootprint(s string) (int, string) {
861907 // drifting too far right.
862908 const toolBoxBodyTrimLeft = 2
863909
910+// toolBoxBodyRenderWidth is the width body renderers should target before
911+// their rows are wrapped by toolBoxSide. Most body renderers emit a four-cell
912+// indent; toolBoxSide trims toolBoxBodyTrimLeft cells from that indent and
913+// then adds the box edge/padding. Passing the full terminal width lets long
914+// bash/heredoc lines overrun the inner box and makes terminals soft-wrap,
915+// visually breaking the right edge. This returns the maximum renderer width
916+// whose post-trim visible width fits inside the box.
917+func toolBoxBodyRenderWidth(width int) int {
918+ w := width - 2*toolBoxOuterMargin
919+ if w < 12 {
920+ w = 12
921+ }
922+ inner := w - 2 - 2*toolBoxInnerPad
923+ if inner < 1 {
924+ inner = 1
925+ }
926+ return inner + toolBoxBodyTrimLeft
927+}
928+
864929 // toolBoxSide wraps a single body line with vertical box edges:
865930 //
866931 // │ foo bar baz │
@@ -982,7 +1047,8 @@ func (v *View) renderToolResultContent(blocks []provider.Content, width, color i
9821047 for _, b := range blocks {
9831048 switch bb := b.(type) {
9841049 case provider.TextBlock:
985- body = append(body, v.renderToolText(bb.Text, width, color, sourcePath, startLine)...)
1050+ bodyWidth := toolBoxBodyRenderWidth(width)
1051+ body = append(body, v.renderToolText(bb.Text, bodyWidth, color, sourcePath, startLine)...)
9861052 case provider.ImageBlock:
9871053 hasImage = true
9881054 body = append(body, v.renderImageBlock(bb, width)...)
@@ -1399,7 +1465,9 @@ func (v *View) renderBashResult(lines []string, width, defaultColor int) []strin
13991465 out = append(out, " "+v.Theme.FG256(v.Theme.Accent, w))
14001466 }
14011467 case i == footerIdx:
1402- out = append(out, " "+v.Theme.FG256(v.Theme.Muted, l))
1468+ for _, w := range wrapLine(l, width-4, " ") {
1469+ out = append(out, " "+v.Theme.FG256(v.Theme.Muted, w))
1470+ }
14031471 default:
14041472 for _, w := range wrapLine(l, width-4, " ") {
14051473 out = append(out, " "+v.Theme.FG256(defaultColor, w))
@@ -1586,7 +1654,7 @@ func ShortArgs(tool string, raw json.RawMessage) string {
15861654 x, ok := v.(map[string]any)
15871655 if !ok {
15881656 b, _ := json.Marshal(v)
1589- s := string(b)
1657+ s := oneLineToolLabel(string(b))
15901658 if len(s) > 60 {
15911659 s = s[:57] + "..."
15921660 }
@@ -1601,12 +1669,13 @@ func ShortArgs(tool string, raw json.RawMessage) string {
16011669 }
16021670 if primary == "" {
16031671 b, _ := json.Marshal(v)
1604- s := string(b)
1672+ s := oneLineToolLabel(string(b))
16051673 if len(s) > 60 {
16061674 s = s[:57] + "..."
16071675 }
16081676 return s
16091677 }
1678+ primary = oneLineToolLabel(primary)
16101679
16111680 // Tool-specific decoration. Only the read tool gets a range
16121681 // suffix for now; other tools just truncate the primary arg.
@@ -183,6 +183,47 @@ func (v *View) Build(width int) []string {
183 return lines183 return lines
184 }184 }
185 185
186+// BuildLive renders only in-flight assistant/tool/error state. Main-screen
187+// scrollback renderers can keep these rows outside the immutable transcript
188+// so native scrolling stays stable while a turn streams.
189+func (v *View) BuildLive(width int) []string {
190+ var out []string
191+ if v.StreamingActive && strings.TrimSpace(v.Streaming) != "" {
192+ const indent = " "
193+ inner := assistantBodyWidth(width - len(indent))
194+ md := RenderMarkdown(v.Streaming, v.Theme, inner)
195+ for _, l := range strings.Split(md, "\n") {
196+ for _, w := range wrapANSILine(l, inner) {
197+ out = append(out, indent+w)
198+ }
199+ }
200+ out = append(out, "")
201+ }
202+ finalised := map[string]bool{}
203+ for _, m := range v.Messages {
204+ for _, c := range m.Content {
205+ switch b := c.(type) {
206+ case provider.ToolCallBlock:
207+ finalised[b.ID] = true
208+ case provider.ToolResultBlock:
209+ finalised[b.CallID] = true
210+ }
211+ }
212+ }
213+ for _, tc := range v.ToolCalls {
214+ if finalised[tc.ID] {
215+ continue
216+ }
217+ out = append(out, v.renderToolCall(tc, width)...)
218+ out = append(out, "")
219+ }
220+ if v.Err != "" {
221+ out = append(out, v.Theme.FG256(v.Theme.Error, "✖ "+v.Err))
222+ out = append(out, "")
223+ }
224+ return out
225+}
226+
186 // BuildWithAnchors is like Build but additionally reports the first227 // BuildWithAnchors is like Build but additionally reports the first
187 // row occupied by each message in v.Messages. Callers that need to228 // row occupied by each message in v.Messages. Callers that need to
188 // scroll to a specific turn (the /jump dialog) use the anchor slice229 // scroll to a specific turn (the /jump dialog) use the anchor slice
@@ -646,7 +687,7 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string {
646 if tc.Error {687 if tc.Error {
647 color = v.Theme.Error688 color = v.Theme.Error
648 }689 }
649- body := toolResultBlock(v.Theme, tc.Result, width, color)690+ body := toolResultBlock(v.Theme, tc.Result, toolBoxBodyRenderWidth(width), color)
650 for _, l := range v.collapseToolBody(body, false) {691 for _, l := range v.collapseToolBody(body, false) {
651 imgCells, stripped := parseImageFootprint(l)692 imgCells, stripped := parseImageFootprint(l)
652 if hasImageEscapeLine(stripped) {693 if hasImageEscapeLine(stripped) {
@@ -748,6 +789,7 @@ const toolBoxOuterMargin = 2
748 // of the line so the right corner sits at column width-1, matching the789 // of the line so the right corner sits at column width-1, matching the
749 // closing edge.790 // closing edge.
750 func toolBoxTop(th Theme, label string, width int) string {791 func toolBoxTop(th Theme, label string, width int) string {
792+ label = oneLineToolLabel(label)
751 w := width - 2*toolBoxOuterMargin793 w := width - 2*toolBoxOuterMargin
752 if w < 12 {794 if w < 12 {
753 w = 12795 w = 12
@@ -782,6 +824,10 @@ func toolBoxTop(th Theme, label string, width int) string {
782 return margin + th.FG256(th.Muted, prefix) + th.FG256(th.FG, name) + th.FG256(th.Muted, rest+suffix+fillStr+"┐") + margin824 return margin + th.FG256(th.Muted, prefix) + th.FG256(th.FG, name) + th.FG256(th.Muted, rest+suffix+fillStr+"┐") + margin
783 }825 }
784 826
827+func oneLineToolLabel(label string) string {
828+ return strings.Join(strings.Fields(label), " ")
829+}
830+
785 func splitToolLabel(label string) (name, rest string) {831 func splitToolLabel(label string) (name, rest string) {
786 label = strings.TrimLeft(label, " ")832 label = strings.TrimLeft(label, " ")
787 if label == "" {833 if label == "" {
@@ -861,6 +907,25 @@ func parseImageFootprint(s string) (int, string) {
861 // drifting too far right.907 // drifting too far right.
862 const toolBoxBodyTrimLeft = 2908 const toolBoxBodyTrimLeft = 2
863 909
910+// toolBoxBodyRenderWidth is the width body renderers should target before
911+// their rows are wrapped by toolBoxSide. Most body renderers emit a four-cell
912+// indent; toolBoxSide trims toolBoxBodyTrimLeft cells from that indent and
913+// then adds the box edge/padding. Passing the full terminal width lets long
914+// bash/heredoc lines overrun the inner box and makes terminals soft-wrap,
915+// visually breaking the right edge. This returns the maximum renderer width
916+// whose post-trim visible width fits inside the box.
917+func toolBoxBodyRenderWidth(width int) int {
918+ w := width - 2*toolBoxOuterMargin
919+ if w < 12 {
920+ w = 12
921+ }
922+ inner := w - 2 - 2*toolBoxInnerPad
923+ if inner < 1 {
924+ inner = 1
925+ }
926+ return inner + toolBoxBodyTrimLeft
927+}
928+
864 // toolBoxSide wraps a single body line with vertical box edges:929 // toolBoxSide wraps a single body line with vertical box edges:
865 //930 //
866 // │ foo bar baz │931 // │ foo bar baz │
@@ -982,7 +1047,8 @@ func (v *View) renderToolResultContent(blocks []provider.Content, width, color i
982 for _, b := range blocks {1047 for _, b := range blocks {
983 switch bb := b.(type) {1048 switch bb := b.(type) {
984 case provider.TextBlock:1049 case provider.TextBlock:
985- body = append(body, v.renderToolText(bb.Text, width, color, sourcePath, startLine)...)1050+ bodyWidth := toolBoxBodyRenderWidth(width)
1051+ body = append(body, v.renderToolText(bb.Text, bodyWidth, color, sourcePath, startLine)...)
986 case provider.ImageBlock:1052 case provider.ImageBlock:
987 hasImage = true1053 hasImage = true
988 body = append(body, v.renderImageBlock(bb, width)...)1054 body = append(body, v.renderImageBlock(bb, width)...)
@@ -1399,7 +1465,9 @@ func (v *View) renderBashResult(lines []string, width, defaultColor int) []strin
1399 out = append(out, " "+v.Theme.FG256(v.Theme.Accent, w))1465 out = append(out, " "+v.Theme.FG256(v.Theme.Accent, w))
1400 }1466 }
1401 case i == footerIdx:1467 case i == footerIdx:
1402- out = append(out, " "+v.Theme.FG256(v.Theme.Muted, l))1468+ for _, w := range wrapLine(l, width-4, " ") {
1469+ out = append(out, " "+v.Theme.FG256(v.Theme.Muted, w))
1470+ }
1403 default:1471 default:
1404 for _, w := range wrapLine(l, width-4, " ") {1472 for _, w := range wrapLine(l, width-4, " ") {
1405 out = append(out, " "+v.Theme.FG256(defaultColor, w))1473 out = append(out, " "+v.Theme.FG256(defaultColor, w))
@@ -1586,7 +1654,7 @@ func ShortArgs(tool string, raw json.RawMessage) string {
1586 x, ok := v.(map[string]any)1654 x, ok := v.(map[string]any)
1587 if !ok {1655 if !ok {
1588 b, _ := json.Marshal(v)1656 b, _ := json.Marshal(v)
1589- s := string(b)1657+ s := oneLineToolLabel(string(b))
1590 if len(s) > 60 {1658 if len(s) > 60 {
1591 s = s[:57] + "..."1659 s = s[:57] + "..."
1592 }1660 }
@@ -1601,12 +1669,13 @@ func ShortArgs(tool string, raw json.RawMessage) string {
1601 }1669 }
1602 if primary == "" {1670 if primary == "" {
1603 b, _ := json.Marshal(v)1671 b, _ := json.Marshal(v)
1604- s := string(b)1672+ s := oneLineToolLabel(string(b))
1605 if len(s) > 60 {1673 if len(s) > 60 {
1606 s = s[:57] + "..."1674 s = s[:57] + "..."
1607 }1675 }
1608 return s1676 return s
1609 }1677 }
1678+ primary = oneLineToolLabel(primary)
1610 1679
1611 // Tool-specific decoration. Only the read tool gets a range1680 // Tool-specific decoration. Only the read tool gets a range
1612 // suffix for now; other tools just truncate the primary arg.1681 // suffix for now; other tools just truncate the primary arg.

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 477

↑↓ moveenter openesc close