detached 6019404

fix(tui): blinking cursor in /btw, proper idle redraws, tighter status bar

Three related tweaks to how the interactive mode drives its redraw
loop, the terminal cursor, and the status-bar layout.

1) Redraw-on-tick narrowed to things that actually animate.

   The render loop used to force a redraw every 120ms for every
   open dialog (model picker, jump, sessions, btw, etc). Most
   of those are static pickers \u2014 the repaint was wasted and had
   a visible side effect: the re-emitted hide-cursor / show-cursor
   pair at the start of each frame cancels the terminal's blink
   cycle for any dialog that hosts its own input field. Concretely
   the blinking cursor in /btw never blinked, it just sat as a
   steady reverse-video block.

   Tick-driven redraw is now only triggered when i.busy (main
   spinner animates) or btw.Loading() (side-chat spinner animates).
   Static dialogs rely on the dirty-channel invalidations that
   fire on key events, which is sufficient because nothing else
   on screen is moving.

2) btw side-chat redraws on completion.

   Consequence of (1): the btw goroutine that streams a model
   response used to depend on the 120ms tick to make the final
   answer visible. With the tick gone for idle dialogs, the
   answer landed in d.turns but the screen stayed blank until
   the user pressed a key.

   btwDialog.Open and submit now take an invalidate callback
   that the goroutine fires after completeTurn, including the
   early-error branch. While the request is in flight,
   btw.Loading() returns true so the tick keeps the spinner
   animating; once the answer lands, a single invalidate()
   redraws and the tick stops.

3) Cursor routed into btw while it's open.

   btwDialog already had a CursorPos(width) method that returns
   where the side-chat editor's caret sits within the dialog's
   rendered rows. The host never used it \u2014 the real terminal
   cursor stayed on the main editor below. Now when btw is
   active the host picks up CursorPos and points the terminal
   cursor there, so the blink shows in the correct input and
   the main editor has no cursor.

4) Status bar idle-path spacing.

   Idle row used to render as "<pad><pad>(provider) model" =
   4 spaces before the model, so the line started at column 4
   while busy and idle didn't match. Dropped one pad in the
   idle branch; both paths now start at column 2, aligned with
   the conversation column ('  you' / '  zot' message markers).

No semantic change to transcripts or provider calls. All tests
pass.
commit
6019404644dd1d4e6779fa2a61cc138195baf1aa
author
patriceckhart · · 2026-04-21 16:09 UTC
parent
fc47398
signature
unsigned
refs
tag: v0.0.82
3 files · +63 -15
M internal/agent/modes/btw_dialog.go +32 -7
@@ -73,11 +73,27 @@ func (d *btwDialog) Active() bool {
7373 return d.active
7474 }
7575
76+// Loading reports whether the dialog is currently awaiting a
77+// model response (and therefore rendering an animated spinner).
78+// Used by the host to decide whether a periodic redraw is worth
79+// triggering; when false and the user is just typing, we can
80+// skip the tick and let the terminal drive the cursor blink.
81+func (d *btwDialog) Loading() bool {
82+ if d == nil {
83+ return false
84+ }
85+ d.mu.Lock()
86+ defer d.mu.Unlock()
87+ return d.active && d.loading
88+}
89+
7690 // Open enters the side chat. agent supplies the live transcript and
7791 // system prompt, plus the underlying provider client to use for the
7892 // one-off completion. seed is an optional first question that gets
79-// auto-submitted (so /btw <text> behaves like the reference).
80-func (d *btwDialog) Open(th tui.Theme, agent *core.Agent, system, model, seed string) {
93+// auto-submitted (so /btw <text> starts a conversation right away).
94+// invalidate, if non-nil, is called after each state change so the
95+// host redraw loop can pick up the update without polling.
96+func (d *btwDialog) Open(th tui.Theme, agent *core.Agent, system, model, seed string, invalidate func()) {
8197 d.mu.Lock()
8298 d.active = true
8399 d.theme = th
@@ -93,7 +109,7 @@ func (d *btwDialog) Open(th tui.Theme, agent *core.Agent, system, model, seed st
93109
94110 if seed = strings.TrimSpace(seed); seed != "" {
95111 d.editor.SetValue(seed)
96- d.submit()
112+ d.submit(invalidate)
97113 }
98114 }
99115
@@ -151,14 +167,17 @@ func (d *btwDialog) HandleKey(k tui.Key, invalidate func()) (closed bool) {
151167 submitted := editor.HandleKey(k)
152168 invalidate()
153169 if submitted && !loading {
154- d.submit()
170+ d.submit(invalidate)
155171 }
156172 return false
157173 }
158174
159175 // submit fires the LLM call for the current input and, on success,
160-// appends a new turn to d.turns.
161-func (d *btwDialog) submit() {
176+// appends a new turn to d.turns. invalidate is called every time
177+// the turn's visible state changes (text delta, error, complete)
178+// so the host redraw loop picks up the update without relying on
179+// a periodic tick.
180+func (d *btwDialog) submit(invalidate func()) {
162181 d.mu.Lock()
163182 if d.editor == nil || d.loading {
164183 d.mu.Unlock()
@@ -219,6 +238,9 @@ func (d *btwDialog) submit() {
219238 stream, err := client.Stream(ctx, req)
220239 if err != nil {
221240 d.completeTurn(turnIdx, "", err.Error())
241+ if invalidate != nil {
242+ invalidate()
243+ }
222244 return
223245 }
224246
@@ -240,6 +262,9 @@ func (d *btwDialog) submit() {
240262 errMsg = finalErr.Error()
241263 }
242264 d.completeTurn(turnIdx, reply.String(), errMsg)
265+ if invalidate != nil {
266+ invalidate()
267+ }
243268 }()
244269 }
245270
@@ -267,7 +292,7 @@ func (d *btwDialog) Render(th tui.Theme, width int) []string {
267292 }
268293
269294 var out []string
270- out = append(out, frameHeaderColor(th, "btw — side chat (esc closes; nothing is added to the main thread)", width, th.Accent))
295+ out = append(out, frameHeaderColor(th, "btw - side chat (esc closes; nothing is added to the main thread)", width, th.Accent))
271296
272297 if len(d.turns) == 0 && !d.loading {
273298 out = append(out, " "+th.FG256(th.Muted, "ask anything; replies stay private to this side chat."))
@@ -73,11 +73,27 @@ func (d *btwDialog) Active() bool {
73 return d.active73 return d.active
74 }74 }
75 75
76+// Loading reports whether the dialog is currently awaiting a
77+// model response (and therefore rendering an animated spinner).
78+// Used by the host to decide whether a periodic redraw is worth
79+// triggering; when false and the user is just typing, we can
80+// skip the tick and let the terminal drive the cursor blink.
81+func (d *btwDialog) Loading() bool {
82+ if d == nil {
83+ return false
84+ }
85+ d.mu.Lock()
86+ defer d.mu.Unlock()
87+ return d.active && d.loading
88+}
89+
76 // Open enters the side chat. agent supplies the live transcript and90 // Open enters the side chat. agent supplies the live transcript and
77 // system prompt, plus the underlying provider client to use for the91 // system prompt, plus the underlying provider client to use for the
78 // one-off completion. seed is an optional first question that gets92 // one-off completion. seed is an optional first question that gets
79-// auto-submitted (so /btw <text> behaves like the reference).93+// auto-submitted (so /btw <text> starts a conversation right away).
80-func (d *btwDialog) Open(th tui.Theme, agent *core.Agent, system, model, seed string) {94+// invalidate, if non-nil, is called after each state change so the
95+// host redraw loop can pick up the update without polling.
96+func (d *btwDialog) Open(th tui.Theme, agent *core.Agent, system, model, seed string, invalidate func()) {
81 d.mu.Lock()97 d.mu.Lock()
82 d.active = true98 d.active = true
83 d.theme = th99 d.theme = th
@@ -93,7 +109,7 @@ func (d *btwDialog) Open(th tui.Theme, agent *core.Agent, system, model, seed st
93 109
94 if seed = strings.TrimSpace(seed); seed != "" {110 if seed = strings.TrimSpace(seed); seed != "" {
95 d.editor.SetValue(seed)111 d.editor.SetValue(seed)
96- d.submit()112+ d.submit(invalidate)
97 }113 }
98 }114 }
99 115
@@ -151,14 +167,17 @@ func (d *btwDialog) HandleKey(k tui.Key, invalidate func()) (closed bool) {
151 submitted := editor.HandleKey(k)167 submitted := editor.HandleKey(k)
152 invalidate()168 invalidate()
153 if submitted && !loading {169 if submitted && !loading {
154- d.submit()170+ d.submit(invalidate)
155 }171 }
156 return false172 return false
157 }173 }
158 174
159 // submit fires the LLM call for the current input and, on success,175 // submit fires the LLM call for the current input and, on success,
160-// appends a new turn to d.turns.176+// appends a new turn to d.turns. invalidate is called every time
161-func (d *btwDialog) submit() {177+// the turn's visible state changes (text delta, error, complete)
178+// so the host redraw loop picks up the update without relying on
179+// a periodic tick.
180+func (d *btwDialog) submit(invalidate func()) {
162 d.mu.Lock()181 d.mu.Lock()
163 if d.editor == nil || d.loading {182 if d.editor == nil || d.loading {
164 d.mu.Unlock()183 d.mu.Unlock()
@@ -219,6 +238,9 @@ func (d *btwDialog) submit() {
219 stream, err := client.Stream(ctx, req)238 stream, err := client.Stream(ctx, req)
220 if err != nil {239 if err != nil {
221 d.completeTurn(turnIdx, "", err.Error())240 d.completeTurn(turnIdx, "", err.Error())
241+ if invalidate != nil {
242+ invalidate()
243+ }
222 return244 return
223 }245 }
224 246
@@ -240,6 +262,9 @@ func (d *btwDialog) submit() {
240 errMsg = finalErr.Error()262 errMsg = finalErr.Error()
241 }263 }
242 d.completeTurn(turnIdx, reply.String(), errMsg)264 d.completeTurn(turnIdx, reply.String(), errMsg)
265+ if invalidate != nil {
266+ invalidate()
267+ }
243 }()268 }()
244 }269 }
245 270
@@ -267,7 +292,7 @@ func (d *btwDialog) Render(th tui.Theme, width int) []string {
267 }292 }
268 293
269 var out []string294 var out []string
270- out = append(out, frameHeaderColor(th, "btw — side chat (esc closes; nothing is added to the main thread)", width, th.Accent))295+ out = append(out, frameHeaderColor(th, "btw - side chat (esc closes; nothing is added to the main thread)", width, th.Accent))
271 296
272 if len(d.turns) == 0 && !d.loading {297 if len(d.turns) == 0 && !d.loading {
273 out = append(out, " "+th.FG256(th.Muted, "ask anything; replies stay private to this side chat."))298 out = append(out, " "+th.FG256(th.Muted, "ask anything; replies stay private to this side chat."))
M internal/agent/modes/interactive.go +24 -5
@@ -478,8 +478,15 @@ func (i *Interactive) Run(ctx context.Context) error {
478478 // and the AfterFunc-driven invalidate got dropped on a
479479 // full channel.
480480 drainPending()
481- if i.busy || i.dialog.Active() || i.modelDialog.Active() || i.sessionDialog.Active() || i.jumpDialog.Active() || i.btwDialog.Active() || i.skillsDialog.Active() || i.changelogDialog.Active() || i.confirmDialog.Active() || i.logoutDialog.Active() || i.telegramDialog.Active() || i.sessionOpsDialog.Active() || i.sessionTreeDialog.Active() {
482- requestRedraw() // keep the spinner / dialog animation moving
481+ // Only force a periodic redraw when something is actually
482+ // animating (the main spinner during a busy turn, or the
483+ // btw side-chat spinner while it's awaiting a response).
484+ // Static pickers (model, session, jump, etc.) don't need
485+ // the tick and firing it cancels the terminal's cursor
486+ // blink inside dialogs that host their own editor (btw),
487+ // because each frame re-emits hide-cursor + show-cursor.
488+ if i.busy || i.btwDialog.Loading() {
489+ requestRedraw()
483490 }
484491 }
485492 }
@@ -755,7 +762,7 @@ func (i *Interactive) redraw() {
755762 var queue []string
756763 if len(i.queued) > 0 {
757764 for _, q := range i.queued {
758- label := i.cfg.Theme.FG256(i.cfg.Theme.Accent, "▸ sliding in: ")
765+ label := i.cfg.Theme.FG256(i.cfg.Theme.Accent, "sliding in: ")
759766 text := truncateLine(q, cols-15)
760767 queue = append(queue, label+i.cfg.Theme.FG256(i.cfg.Theme.Muted, text))
761768 }
@@ -822,8 +829,20 @@ func (i *Interactive) redraw() {
822829 frame = append(frame, visibleChat...)
823830 frame = append(frame, bottom...)
824831
832+ // Default: the real terminal cursor sits on the main editor's
833+ // input position. When an overlay dialog has its own input
834+ // field (today just /btw), route the cursor there instead so
835+ // the blinking cursor shows where the user is actually typing.
836+ // Dialogs without a cursor (model picker, /help, /login, etc.)
837+ // return -1 and the main editor keeps the cursor.
825838 cursorRow := len(visibleChat) + len(dialog) + len(suggest) + len(queue) + len(statusLines) + curR
826839 cursorCol := curC
840+ if i.btwDialog.Active() {
841+ if r, c := i.btwDialog.CursorPos(cols); r >= 0 {
842+ cursorRow = len(visibleChat) + r
843+ cursorCol = c
844+ }
845+ }
827846 i.rend.Draw(frame, cursorRow, cursorCol)
828847 }
829848
@@ -1716,7 +1735,7 @@ func (i *Interactive) openBtwDialog(args []string) {
17161735 return
17171736 }
17181737 seed := strings.TrimSpace(strings.Join(args, " "))
1719- i.btwDialog.Open(i.cfg.Theme, i.agent, i.agent.System, i.cfg.Model, seed)
1738+ i.btwDialog.Open(i.cfg.Theme, i.agent, i.agent.System, i.cfg.Model, seed, i.invalidate)
17201739 i.invalidate()
17211740 }
17221741
@@ -2289,7 +2308,7 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
22892308 if tc, ok := i.toolCalls[e.ID]; ok {
22902309 tc.RawJSONBuf += e.Delta
22912310 // Refresh the live path as soon as it parses; used in
2292- // the header (▸ write /Users/pat/Desktop/demo.ts)
2311+ // the header (write /Users/pat/Desktop/demo.ts)
22932312 // while the content is still streaming.
22942313 if p, pok, _ := tui.ExtractPartialStringField(tc.RawJSONBuf, "path"); pok {
22952314 tc.LivePath = p
@@ -478,8 +478,15 @@ func (i *Interactive) Run(ctx context.Context) error {
478 // and the AfterFunc-driven invalidate got dropped on a478 // and the AfterFunc-driven invalidate got dropped on a
479 // full channel.479 // full channel.
480 drainPending()480 drainPending()
481- if i.busy || i.dialog.Active() || i.modelDialog.Active() || i.sessionDialog.Active() || i.jumpDialog.Active() || i.btwDialog.Active() || i.skillsDialog.Active() || i.changelogDialog.Active() || i.confirmDialog.Active() || i.logoutDialog.Active() || i.telegramDialog.Active() || i.sessionOpsDialog.Active() || i.sessionTreeDialog.Active() {481+ // Only force a periodic redraw when something is actually
482- requestRedraw() // keep the spinner / dialog animation moving482+ // animating (the main spinner during a busy turn, or the
483+ // btw side-chat spinner while it's awaiting a response).
484+ // Static pickers (model, session, jump, etc.) don't need
485+ // the tick and firing it cancels the terminal's cursor
486+ // blink inside dialogs that host their own editor (btw),
487+ // because each frame re-emits hide-cursor + show-cursor.
488+ if i.busy || i.btwDialog.Loading() {
489+ requestRedraw()
483 }490 }
484 }491 }
485 }492 }
@@ -755,7 +762,7 @@ func (i *Interactive) redraw() {
755 var queue []string762 var queue []string
756 if len(i.queued) > 0 {763 if len(i.queued) > 0 {
757 for _, q := range i.queued {764 for _, q := range i.queued {
758- label := i.cfg.Theme.FG256(i.cfg.Theme.Accent, "▸ sliding in: ")765+ label := i.cfg.Theme.FG256(i.cfg.Theme.Accent, "sliding in: ")
759 text := truncateLine(q, cols-15)766 text := truncateLine(q, cols-15)
760 queue = append(queue, label+i.cfg.Theme.FG256(i.cfg.Theme.Muted, text))767 queue = append(queue, label+i.cfg.Theme.FG256(i.cfg.Theme.Muted, text))
761 }768 }
@@ -822,8 +829,20 @@ func (i *Interactive) redraw() {
822 frame = append(frame, visibleChat...)829 frame = append(frame, visibleChat...)
823 frame = append(frame, bottom...)830 frame = append(frame, bottom...)
824 831
832+ // Default: the real terminal cursor sits on the main editor's
833+ // input position. When an overlay dialog has its own input
834+ // field (today just /btw), route the cursor there instead so
835+ // the blinking cursor shows where the user is actually typing.
836+ // Dialogs without a cursor (model picker, /help, /login, etc.)
837+ // return -1 and the main editor keeps the cursor.
825 cursorRow := len(visibleChat) + len(dialog) + len(suggest) + len(queue) + len(statusLines) + curR838 cursorRow := len(visibleChat) + len(dialog) + len(suggest) + len(queue) + len(statusLines) + curR
826 cursorCol := curC839 cursorCol := curC
840+ if i.btwDialog.Active() {
841+ if r, c := i.btwDialog.CursorPos(cols); r >= 0 {
842+ cursorRow = len(visibleChat) + r
843+ cursorCol = c
844+ }
845+ }
827 i.rend.Draw(frame, cursorRow, cursorCol)846 i.rend.Draw(frame, cursorRow, cursorCol)
828 }847 }
829 848
@@ -1716,7 +1735,7 @@ func (i *Interactive) openBtwDialog(args []string) {
1716 return1735 return
1717 }1736 }
1718 seed := strings.TrimSpace(strings.Join(args, " "))1737 seed := strings.TrimSpace(strings.Join(args, " "))
1719- i.btwDialog.Open(i.cfg.Theme, i.agent, i.agent.System, i.cfg.Model, seed)1738+ i.btwDialog.Open(i.cfg.Theme, i.agent, i.agent.System, i.cfg.Model, seed, i.invalidate)
1720 i.invalidate()1739 i.invalidate()
1721 }1740 }
1722 1741
@@ -2289,7 +2308,7 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
2289 if tc, ok := i.toolCalls[e.ID]; ok {2308 if tc, ok := i.toolCalls[e.ID]; ok {
2290 tc.RawJSONBuf += e.Delta2309 tc.RawJSONBuf += e.Delta
2291 // Refresh the live path as soon as it parses; used in2310 // Refresh the live path as soon as it parses; used in
2292- // the header (▸ write /Users/pat/Desktop/demo.ts)2311+ // the header (write /Users/pat/Desktop/demo.ts)
2293 // while the content is still streaming.2312 // while the content is still streaming.
2294 if p, pok, _ := tui.ExtractPartialStringField(tc.RawJSONBuf, "path"); pok {2313 if p, pok, _ := tui.ExtractPartialStringField(tc.RawJSONBuf, "path"); pok {
2295 tc.LivePath = p2314 tc.LivePath = p
M internal/tui/view.go +7 -3
@@ -445,7 +445,7 @@ func (v *View) renderMessage(m provider.Message, width int) []string {
445445 // assistant prose above. The matching closing rule
446446 // is emitted at the end of the tool-role message.
447447 lines = append(lines, toolBlockRule(v.Theme, width))
448- lines = append(lines, indent+v.Theme.FG256(v.Theme.Tool, "▸ "+b.Name+" "+ShortArgs(b.Name, b.Arguments)))
448+ lines = append(lines, indent+v.Theme.FG256(v.Theme.Tool, ""+b.Name+" "+ShortArgs(b.Name, b.Arguments)))
449449 }
450450 }
451451 case provider.RoleTool:
@@ -493,7 +493,7 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string {
493493 if arg == "" && tc.LivePath != "" {
494494 arg = tc.LivePath
495495 }
496- head := v.Theme.FG256(v.Theme.Tool, "▸ "+tc.Name+" "+arg)
496+ head := v.Theme.FG256(v.Theme.Tool, ""+tc.Name+" "+arg)
497497
498498 // Live streaming body: pulled out of the partial JSON buffer for
499499 // tools whose interesting content is a string field (currently
@@ -1377,10 +1377,14 @@ func StatusBar(p StatusBarParams) []string {
13771377 // Exactly one pad (2 spaces) between the busy segment and
13781378 // the provider/model block. The leading pad above covers
13791379 // the left indent.
1380+ leftBuilder.WriteString(pad)
13801381 } else {
1382+ // Idle path: a single pad of left inset so the line
1383+ // aligns with the conversation column on its left edge
1384+ // (" you" / " zot" message markers). Without the busy
1385+ // prefix there's no trailing separator to double-pad.
13811386 leftBuilder.WriteString(pad)
13821387 }
1383- leftBuilder.WriteString(pad)
13841388 leftBuilder.WriteString(th.FG256(th.Muted, left))
13851389 if middle != "" {
13861390 leftBuilder.WriteString(pad)
@@ -445,7 +445,7 @@ func (v *View) renderMessage(m provider.Message, width int) []string {
445 // assistant prose above. The matching closing rule445 // assistant prose above. The matching closing rule
446 // is emitted at the end of the tool-role message.446 // is emitted at the end of the tool-role message.
447 lines = append(lines, toolBlockRule(v.Theme, width))447 lines = append(lines, toolBlockRule(v.Theme, width))
448- lines = append(lines, indent+v.Theme.FG256(v.Theme.Tool, "▸ "+b.Name+" "+ShortArgs(b.Name, b.Arguments)))448+ lines = append(lines, indent+v.Theme.FG256(v.Theme.Tool, ""+b.Name+" "+ShortArgs(b.Name, b.Arguments)))
449 }449 }
450 }450 }
451 case provider.RoleTool:451 case provider.RoleTool:
@@ -493,7 +493,7 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string {
493 if arg == "" && tc.LivePath != "" {493 if arg == "" && tc.LivePath != "" {
494 arg = tc.LivePath494 arg = tc.LivePath
495 }495 }
496- head := v.Theme.FG256(v.Theme.Tool, "▸ "+tc.Name+" "+arg)496+ head := v.Theme.FG256(v.Theme.Tool, ""+tc.Name+" "+arg)
497 497
498 // Live streaming body: pulled out of the partial JSON buffer for498 // Live streaming body: pulled out of the partial JSON buffer for
499 // tools whose interesting content is a string field (currently499 // tools whose interesting content is a string field (currently
@@ -1377,10 +1377,14 @@ func StatusBar(p StatusBarParams) []string {
1377 // Exactly one pad (2 spaces) between the busy segment and1377 // Exactly one pad (2 spaces) between the busy segment and
1378 // the provider/model block. The leading pad above covers1378 // the provider/model block. The leading pad above covers
1379 // the left indent.1379 // the left indent.
1380+ leftBuilder.WriteString(pad)
1380 } else {1381 } else {
1382+ // Idle path: a single pad of left inset so the line
1383+ // aligns with the conversation column on its left edge
1384+ // (" you" / " zot" message markers). Without the busy
1385+ // prefix there's no trailing separator to double-pad.
1381 leftBuilder.WriteString(pad)1386 leftBuilder.WriteString(pad)
1382 }1387 }
1383- leftBuilder.WriteString(pad)
1384 leftBuilder.WriteString(th.FG256(th.Muted, left))1388 leftBuilder.WriteString(th.FG256(th.Muted, left))
1385 if middle != "" {1389 if middle != "" {
1386 leftBuilder.WriteString(pad)1390 leftBuilder.WriteString(pad)

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