detached b45f107

interactive: cache idle chat render

The redraw path rebuilt the full transcript on every key event:
filtered the agent's full message slice, refreshed tool path maps,
walked every message through the per-message render cache, and
re-assembled the entire chat line buffer. With a long session, the
O(N) work per keystroke made typing visibly lag.

Add an idle render cache: the previously built chat is reused when
nothing relevant changed (terminal width, transcript revision,
status notes, help/update banners, expand-all). The agent now
exposes a cheap monotonically increasing Revision() that ticks
whenever messages are appended or replaced, so the cache key stays
trivial. Live turns (busy/streaming/tool-call mutations) keep the
old rebuild path.
commit
b45f10718f6993cb18eea301e3a0a17351a572f7
author
patriceckhart · · 2026-05-04 10:14 UTC
parent
2eab133
signature
unsigned
2 files · +147 -56
M internal/agent/modes/interactive.go +127 -55
@@ -153,6 +153,21 @@ type ChangelogPayload struct {
153153 }
154154
155155 // Interactive is the TUI chat loop.
156+type chatCacheKey struct {
157+ cols int
158+ agentRev uint64
159+ statusOK string
160+ statusErr string
161+ help string
162+ extNotes string
163+ updateAvailable bool
164+ updateCurrent string
165+ updateLatest string
166+ updateURL string
167+ welcomeShowVer bool
168+ expandAll bool
169+}
170+
156171 type Interactive struct {
157172 cfg InteractiveConfig
158173 view *tui.View
@@ -203,6 +218,14 @@ type Interactive struct {
203218 prevChatLen int
204219 prevChatCols int
205220
221+ // chatCache stores the built transcript/status-note rows for idle
222+ // frames. Editor typing changes only the bottom input region, so
223+ // reusing this cache avoids copying/walking/reassembling a long
224+ // session on every keypress.
225+ chatCache []string
226+ chatCacheKey chatCacheKey
227+ chatCacheValid bool
228+
206229 // Messages typed while a turn is in flight. Each is delivered as
207230 // its own follow-up turn once the current one finishes. Rendered
208231 // above the status bar as "sliding in: ..." chips.
@@ -573,66 +596,51 @@ func mouseWheelScrollRows() int {
573596 return 6
574597 }
575598
576-// lastCols returns the current terminal width in columns.
577-func (i *Interactive) lastCols() int {
578- cols, _ := i.cfg.Terminal.Size()
579- return cols
580-}
581-
582-// chatPage returns the number of chat rows currently visible, used
583-// as the page size for PageUp/PageDown.
584-func (i *Interactive) chatPage() int {
585- _, rows := i.cfg.Terminal.Size()
586- p := rows - 6 // rough reservation for status + editor + a dialog line
587- if p < 4 {
588- p = 4
599+func (i *Interactive) cachedChatLocked(cols int) []string {
600+ key, cacheable := i.chatCacheKeyLocked(cols)
601+ if cacheable && i.chatCacheValid && i.chatCacheKey == key {
602+ return append([]string(nil), i.chatCache...)
589603 }
590- return p
591-}
592-
593-// scrollBy adjusts the scroll offset. Positive = up (into history).
594-// Clearing the parked-turn label when we're back at the bottom means
595-// the "viewing turn N" footer goes away automatically as soon as you
596-// scroll back to the live tail.
597-func (i *Interactive) scrollBy(delta int) {
598- i.mu.Lock()
599- i.scrollOffset += delta
600- if i.scrollOffset < 0 {
601- i.scrollOffset = 0
602- }
603- if i.scrollOffset == 0 {
604- i.parkedTurn = 0
605- i.parkedTotal = 0
606- }
607- if i.rend != nil {
608- // VS Code's terminal is especially prone to leaving stray
609- // wrapped-character fragments behind during scroll-driven
610- // viewport changes. Force a full repaint on scroll, but
611- // avoid a whole-screen clear because that visibly flickers.
612- i.rend.Invalidate()
604+ chat := i.buildChatLocked(cols)
605+ if cacheable {
606+ i.chatCache = append(i.chatCache[:0], chat...)
607+ i.chatCacheKey = key
608+ i.chatCacheValid = true
609+ } else {
610+ i.chatCacheValid = false
613611 }
614- i.mu.Unlock()
615- i.invalidate()
612+ return chat
616613 }
617614
618-// scrollToBottom pins the view to the latest content.
619-func (i *Interactive) scrollToBottom() {
620- i.mu.Lock()
621- i.scrollOffset = 0
622- i.parkedTurn = 0
623- i.parkedTotal = 0
624- if i.rend != nil {
625- i.rend.Invalidate()
615+func (i *Interactive) chatCacheKeyLocked(cols int) (chatCacheKey, bool) {
616+ // Live turns mutate streaming/tool-call state at high frequency;
617+ // keep those on the old rebuild path. The cache targets the common
618+ // idle case where only the editor contents changed between redraws.
619+ if i.busy || i.streamOn || i.streamFlushPending {
620+ return chatCacheKey{}, false
626621 }
627- i.mu.Unlock()
628- i.invalidate()
629-}
630-
631-func (i *Interactive) redraw() {
632- i.mu.Lock()
633- defer i.mu.Unlock()
634-
635- cols, _ := i.cfg.Terminal.Size()
622+ var rev uint64
623+ if i.agent != nil {
624+ rev = i.agent.Revision()
625+ }
626+ showVer := len(i.view.Messages) == 0 && !i.streamOn && len(i.toolOrder) == 0 && !i.welcomeStart.IsZero() && time.Since(i.welcomeStart) < welcomeVersionDuration
627+ return chatCacheKey{
628+ cols: cols,
629+ agentRev: rev,
630+ statusOK: i.statusOK,
631+ statusErr: i.statusErr,
632+ help: strings.Join(i.helpBlock, "\n"),
633+ extNotes: strings.Join(i.extNotes, "\n"),
634+ updateAvailable: i.updateInfo.Available,
635+ updateCurrent: i.updateInfo.Current,
636+ updateLatest: i.updateInfo.Latest,
637+ updateURL: i.updateInfo.URL,
638+ welcomeShowVer: showVer,
639+ expandAll: i.view.ExpandAll,
640+ }, true
641+}
642+
643+func (i *Interactive) buildChatLocked(cols int) []string {
636644 if i.agent != nil {
637645 i.view.Messages = filterHiddenTranscriptMessages(i.agent.Messages())
638646 } else {
@@ -744,6 +752,70 @@ func (i *Interactive) redraw() {
744752 for len(chat) > 0 && strings.TrimSpace(chat[len(chat)-1]) == "" {
745753 chat = chat[:len(chat)-1]
746754 }
755+ return chat
756+}
757+
758+// lastCols returns the current terminal width in columns.
759+func (i *Interactive) lastCols() int {
760+ cols, _ := i.cfg.Terminal.Size()
761+ return cols
762+}
763+
764+// chatPage returns the number of chat rows currently visible, used
765+// as the page size for PageUp/PageDown.
766+func (i *Interactive) chatPage() int {
767+ _, rows := i.cfg.Terminal.Size()
768+ p := rows - 6 // rough reservation for status + editor + a dialog line
769+ if p < 4 {
770+ p = 4
771+ }
772+ return p
773+}
774+
775+// scrollBy adjusts the scroll offset. Positive = up (into history).
776+// Clearing the parked-turn label when we're back at the bottom means
777+// the "viewing turn N" footer goes away automatically as soon as you
778+// scroll back to the live tail.
779+func (i *Interactive) scrollBy(delta int) {
780+ i.mu.Lock()
781+ i.scrollOffset += delta
782+ if i.scrollOffset < 0 {
783+ i.scrollOffset = 0
784+ }
785+ if i.scrollOffset == 0 {
786+ i.parkedTurn = 0
787+ i.parkedTotal = 0
788+ }
789+ if i.rend != nil {
790+ // VS Code's terminal is especially prone to leaving stray
791+ // wrapped-character fragments behind during scroll-driven
792+ // viewport changes. Force a full repaint on scroll, but
793+ // avoid a whole-screen clear because that visibly flickers.
794+ i.rend.Invalidate()
795+ }
796+ i.mu.Unlock()
797+ i.invalidate()
798+}
799+
800+// scrollToBottom pins the view to the latest content.
801+func (i *Interactive) scrollToBottom() {
802+ i.mu.Lock()
803+ i.scrollOffset = 0
804+ i.parkedTurn = 0
805+ i.parkedTotal = 0
806+ if i.rend != nil {
807+ i.rend.Invalidate()
808+ }
809+ i.mu.Unlock()
810+ i.invalidate()
811+}
812+
813+func (i *Interactive) redraw() {
814+ i.mu.Lock()
815+ defer i.mu.Unlock()
816+
817+ cols, _ := i.cfg.Terminal.Size()
818+ chat := i.cachedChatLocked(cols)
747819
748820 // Dialogs (login or model picker) render between chat and the editor.
749821 var dialog []string
@@ -153,6 +153,21 @@ type ChangelogPayload struct {
153 }153 }
154 154
155 // Interactive is the TUI chat loop.155 // Interactive is the TUI chat loop.
156+type chatCacheKey struct {
157+ cols int
158+ agentRev uint64
159+ statusOK string
160+ statusErr string
161+ help string
162+ extNotes string
163+ updateAvailable bool
164+ updateCurrent string
165+ updateLatest string
166+ updateURL string
167+ welcomeShowVer bool
168+ expandAll bool
169+}
170+
156 type Interactive struct {171 type Interactive struct {
157 cfg InteractiveConfig172 cfg InteractiveConfig
158 view *tui.View173 view *tui.View
@@ -203,6 +218,14 @@ type Interactive struct {
203 prevChatLen int218 prevChatLen int
204 prevChatCols int219 prevChatCols int
205 220
221+ // chatCache stores the built transcript/status-note rows for idle
222+ // frames. Editor typing changes only the bottom input region, so
223+ // reusing this cache avoids copying/walking/reassembling a long
224+ // session on every keypress.
225+ chatCache []string
226+ chatCacheKey chatCacheKey
227+ chatCacheValid bool
228+
206 // Messages typed while a turn is in flight. Each is delivered as229 // Messages typed while a turn is in flight. Each is delivered as
207 // its own follow-up turn once the current one finishes. Rendered230 // its own follow-up turn once the current one finishes. Rendered
208 // above the status bar as "sliding in: ..." chips.231 // above the status bar as "sliding in: ..." chips.
@@ -573,66 +596,51 @@ func mouseWheelScrollRows() int {
573 return 6596 return 6
574 }597 }
575 598
576-// lastCols returns the current terminal width in columns.599+func (i *Interactive) cachedChatLocked(cols int) []string {
577-func (i *Interactive) lastCols() int {600+ key, cacheable := i.chatCacheKeyLocked(cols)
578- cols, _ := i.cfg.Terminal.Size()601+ if cacheable && i.chatCacheValid && i.chatCacheKey == key {
579- return cols602+ return append([]string(nil), i.chatCache...)
580-}
581-
582-// chatPage returns the number of chat rows currently visible, used
583-// as the page size for PageUp/PageDown.
584-func (i *Interactive) chatPage() int {
585- _, rows := i.cfg.Terminal.Size()
586- p := rows - 6 // rough reservation for status + editor + a dialog line
587- if p < 4 {
588- p = 4
589 }603 }
590- return p604+ chat := i.buildChatLocked(cols)
591-}605+ if cacheable {
592-606+ i.chatCache = append(i.chatCache[:0], chat...)
593-// scrollBy adjusts the scroll offset. Positive = up (into history).607+ i.chatCacheKey = key
594-// Clearing the parked-turn label when we're back at the bottom means608+ i.chatCacheValid = true
595-// the "viewing turn N" footer goes away automatically as soon as you609+ } else {
596-// scroll back to the live tail.610+ i.chatCacheValid = false
597-func (i *Interactive) scrollBy(delta int) {
598- i.mu.Lock()
599- i.scrollOffset += delta
600- if i.scrollOffset < 0 {
601- i.scrollOffset = 0
602- }
603- if i.scrollOffset == 0 {
604- i.parkedTurn = 0
605- i.parkedTotal = 0
606- }
607- if i.rend != nil {
608- // VS Code's terminal is especially prone to leaving stray
609- // wrapped-character fragments behind during scroll-driven
610- // viewport changes. Force a full repaint on scroll, but
611- // avoid a whole-screen clear because that visibly flickers.
612- i.rend.Invalidate()
613 }611 }
614- i.mu.Unlock()612+ return chat
615- i.invalidate()
616 }613 }
617 614
618-// scrollToBottom pins the view to the latest content.615+func (i *Interactive) chatCacheKeyLocked(cols int) (chatCacheKey, bool) {
619-func (i *Interactive) scrollToBottom() {616+ // Live turns mutate streaming/tool-call state at high frequency;
620- i.mu.Lock()617+ // keep those on the old rebuild path. The cache targets the common
621- i.scrollOffset = 0618+ // idle case where only the editor contents changed between redraws.
622- i.parkedTurn = 0619+ if i.busy || i.streamOn || i.streamFlushPending {
623- i.parkedTotal = 0620+ return chatCacheKey{}, false
624- if i.rend != nil {
625- i.rend.Invalidate()
626 }621 }
627- i.mu.Unlock()622+ var rev uint64
628- i.invalidate()623+ if i.agent != nil {
629-}624+ rev = i.agent.Revision()
630-625+ }
631-func (i *Interactive) redraw() {626+ showVer := len(i.view.Messages) == 0 && !i.streamOn && len(i.toolOrder) == 0 && !i.welcomeStart.IsZero() && time.Since(i.welcomeStart) < welcomeVersionDuration
632- i.mu.Lock()627+ return chatCacheKey{
633- defer i.mu.Unlock()628+ cols: cols,
634-629+ agentRev: rev,
635- cols, _ := i.cfg.Terminal.Size()630+ statusOK: i.statusOK,
631+ statusErr: i.statusErr,
632+ help: strings.Join(i.helpBlock, "\n"),
633+ extNotes: strings.Join(i.extNotes, "\n"),
634+ updateAvailable: i.updateInfo.Available,
635+ updateCurrent: i.updateInfo.Current,
636+ updateLatest: i.updateInfo.Latest,
637+ updateURL: i.updateInfo.URL,
638+ welcomeShowVer: showVer,
639+ expandAll: i.view.ExpandAll,
640+ }, true
641+}
642+
643+func (i *Interactive) buildChatLocked(cols int) []string {
636 if i.agent != nil {644 if i.agent != nil {
637 i.view.Messages = filterHiddenTranscriptMessages(i.agent.Messages())645 i.view.Messages = filterHiddenTranscriptMessages(i.agent.Messages())
638 } else {646 } else {
@@ -744,6 +752,70 @@ func (i *Interactive) redraw() {
744 for len(chat) > 0 && strings.TrimSpace(chat[len(chat)-1]) == "" {752 for len(chat) > 0 && strings.TrimSpace(chat[len(chat)-1]) == "" {
745 chat = chat[:len(chat)-1]753 chat = chat[:len(chat)-1]
746 }754 }
755+ return chat
756+}
757+
758+// lastCols returns the current terminal width in columns.
759+func (i *Interactive) lastCols() int {
760+ cols, _ := i.cfg.Terminal.Size()
761+ return cols
762+}
763+
764+// chatPage returns the number of chat rows currently visible, used
765+// as the page size for PageUp/PageDown.
766+func (i *Interactive) chatPage() int {
767+ _, rows := i.cfg.Terminal.Size()
768+ p := rows - 6 // rough reservation for status + editor + a dialog line
769+ if p < 4 {
770+ p = 4
771+ }
772+ return p
773+}
774+
775+// scrollBy adjusts the scroll offset. Positive = up (into history).
776+// Clearing the parked-turn label when we're back at the bottom means
777+// the "viewing turn N" footer goes away automatically as soon as you
778+// scroll back to the live tail.
779+func (i *Interactive) scrollBy(delta int) {
780+ i.mu.Lock()
781+ i.scrollOffset += delta
782+ if i.scrollOffset < 0 {
783+ i.scrollOffset = 0
784+ }
785+ if i.scrollOffset == 0 {
786+ i.parkedTurn = 0
787+ i.parkedTotal = 0
788+ }
789+ if i.rend != nil {
790+ // VS Code's terminal is especially prone to leaving stray
791+ // wrapped-character fragments behind during scroll-driven
792+ // viewport changes. Force a full repaint on scroll, but
793+ // avoid a whole-screen clear because that visibly flickers.
794+ i.rend.Invalidate()
795+ }
796+ i.mu.Unlock()
797+ i.invalidate()
798+}
799+
800+// scrollToBottom pins the view to the latest content.
801+func (i *Interactive) scrollToBottom() {
802+ i.mu.Lock()
803+ i.scrollOffset = 0
804+ i.parkedTurn = 0
805+ i.parkedTotal = 0
806+ if i.rend != nil {
807+ i.rend.Invalidate()
808+ }
809+ i.mu.Unlock()
810+ i.invalidate()
811+}
812+
813+func (i *Interactive) redraw() {
814+ i.mu.Lock()
815+ defer i.mu.Unlock()
816+
817+ cols, _ := i.cfg.Terminal.Size()
818+ chat := i.cachedChatLocked(cols)
747 819
748 // Dialogs (login or model picker) render between chat and the editor.820 // Dialogs (login or model picker) render between chat and the editor.
749 var dialog []string821 var dialog []string
M internal/core/agent.go +20 -1
@@ -68,7 +68,12 @@ type Agent struct {
6868
6969 mu sync.Mutex
7070 messages []provider.Message
71- cost CostTracker
71+ // rev increments whenever the transcript slice is replaced or a
72+ // message is appended. The TUI uses it as a cheap redraw cache key
73+ // so editor-only typing doesn't copy/rebuild a long transcript on
74+ // every keypress.
75+ rev uint64
76+ cost CostTracker
7277 }
7378
7479 // NewAgent returns an Agent with sensible defaults.
@@ -91,6 +96,15 @@ func (a *Agent) Messages() []provider.Message {
9196 return out
9297 }
9398
99+// Revision returns a monotonically increasing transcript version.
100+// It is cheap to query and changes whenever Messages() would return
101+// different transcript content because of append/set operations.
102+func (a *Agent) Revision() uint64 {
103+ a.mu.Lock()
104+ defer a.mu.Unlock()
105+ return a.rev
106+}
107+
94108 // SetTools swaps the tool registry. Used by /reload-ext to hand
95109 // the agent a fresh registry after extension subprocesses have been
96110 // respawned (and their freshly-registered tools merged in).
@@ -105,6 +119,7 @@ func (a *Agent) SetMessages(msgs []provider.Message) {
105119 a.mu.Lock()
106120 defer a.mu.Unlock()
107121 a.messages = append(a.messages[:0], msgs...)
122+ a.rev++
108123 }
109124
110125 // Cost returns the cumulative usage.
@@ -152,6 +167,7 @@ func (a *Agent) Prompt(ctx context.Context, text string, images []provider.Image
152167
153168 a.mu.Lock()
154169 a.messages = append(a.messages, user)
170+ a.rev++
155171 a.mu.Unlock()
156172 a.fireMessageAppended(user)
157173 sink(EvUserMessage{Message: user})
@@ -207,6 +223,7 @@ func (a *Agent) runLoop(ctx context.Context, sink func(AgentEvent)) error {
207223 toolMsg, hadError := a.executeTools(ctx, assistantMsg, sink)
208224 a.mu.Lock()
209225 a.messages = append(a.messages, toolMsg)
226+ a.rev++
210227 // OpenAI's chat-completions tool message shape is text-centric.
211228 // Vision models reliably consume images when they arrive as user
212229 // content, so when a tool result contains images we mirror them
@@ -218,6 +235,7 @@ func (a *Agent) runLoop(ctx context.Context, sink func(AgentEvent)) error {
218235 if a.Client != nil && a.Client.Name() == "openai" {
219236 if mirror := mirrorToolImagesAsUser(toolMsg); len(mirror.Content) > 0 {
220237 a.messages = append(a.messages, mirror)
238+ a.rev++
221239 imageMirror = mirror
222240 }
223241 }
@@ -336,6 +354,7 @@ func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent)) (provider.St
336354
337355 a.mu.Lock()
338356 a.messages = append(a.messages, finalMsg)
357+ a.rev++
339358 a.mu.Unlock()
340359 a.fireMessageAppended(finalMsg)
341360 if !suppress {
@@ -68,7 +68,12 @@ type Agent struct {
68 68
69 mu sync.Mutex69 mu sync.Mutex
70 messages []provider.Message70 messages []provider.Message
71- cost CostTracker71+ // rev increments whenever the transcript slice is replaced or a
72+ // message is appended. The TUI uses it as a cheap redraw cache key
73+ // so editor-only typing doesn't copy/rebuild a long transcript on
74+ // every keypress.
75+ rev uint64
76+ cost CostTracker
72 }77 }
73 78
74 // NewAgent returns an Agent with sensible defaults.79 // NewAgent returns an Agent with sensible defaults.
@@ -91,6 +96,15 @@ func (a *Agent) Messages() []provider.Message {
91 return out96 return out
92 }97 }
93 98
99+// Revision returns a monotonically increasing transcript version.
100+// It is cheap to query and changes whenever Messages() would return
101+// different transcript content because of append/set operations.
102+func (a *Agent) Revision() uint64 {
103+ a.mu.Lock()
104+ defer a.mu.Unlock()
105+ return a.rev
106+}
107+
94 // SetTools swaps the tool registry. Used by /reload-ext to hand108 // SetTools swaps the tool registry. Used by /reload-ext to hand
95 // the agent a fresh registry after extension subprocesses have been109 // the agent a fresh registry after extension subprocesses have been
96 // respawned (and their freshly-registered tools merged in).110 // respawned (and their freshly-registered tools merged in).
@@ -105,6 +119,7 @@ func (a *Agent) SetMessages(msgs []provider.Message) {
105 a.mu.Lock()119 a.mu.Lock()
106 defer a.mu.Unlock()120 defer a.mu.Unlock()
107 a.messages = append(a.messages[:0], msgs...)121 a.messages = append(a.messages[:0], msgs...)
122+ a.rev++
108 }123 }
109 124
110 // Cost returns the cumulative usage.125 // Cost returns the cumulative usage.
@@ -152,6 +167,7 @@ func (a *Agent) Prompt(ctx context.Context, text string, images []provider.Image
152 167
153 a.mu.Lock()168 a.mu.Lock()
154 a.messages = append(a.messages, user)169 a.messages = append(a.messages, user)
170+ a.rev++
155 a.mu.Unlock()171 a.mu.Unlock()
156 a.fireMessageAppended(user)172 a.fireMessageAppended(user)
157 sink(EvUserMessage{Message: user})173 sink(EvUserMessage{Message: user})
@@ -207,6 +223,7 @@ func (a *Agent) runLoop(ctx context.Context, sink func(AgentEvent)) error {
207 toolMsg, hadError := a.executeTools(ctx, assistantMsg, sink)223 toolMsg, hadError := a.executeTools(ctx, assistantMsg, sink)
208 a.mu.Lock()224 a.mu.Lock()
209 a.messages = append(a.messages, toolMsg)225 a.messages = append(a.messages, toolMsg)
226+ a.rev++
210 // OpenAI's chat-completions tool message shape is text-centric.227 // OpenAI's chat-completions tool message shape is text-centric.
211 // Vision models reliably consume images when they arrive as user228 // Vision models reliably consume images when they arrive as user
212 // content, so when a tool result contains images we mirror them229 // content, so when a tool result contains images we mirror them
@@ -218,6 +235,7 @@ func (a *Agent) runLoop(ctx context.Context, sink func(AgentEvent)) error {
218 if a.Client != nil && a.Client.Name() == "openai" {235 if a.Client != nil && a.Client.Name() == "openai" {
219 if mirror := mirrorToolImagesAsUser(toolMsg); len(mirror.Content) > 0 {236 if mirror := mirrorToolImagesAsUser(toolMsg); len(mirror.Content) > 0 {
220 a.messages = append(a.messages, mirror)237 a.messages = append(a.messages, mirror)
238+ a.rev++
221 imageMirror = mirror239 imageMirror = mirror
222 }240 }
223 }241 }
@@ -336,6 +354,7 @@ func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent)) (provider.St
336 354
337 a.mu.Lock()355 a.mu.Lock()
338 a.messages = append(a.messages, finalMsg)356 a.messages = append(a.messages, finalMsg)
357+ a.rev++
339 a.mu.Unlock()358 a.mu.Unlock()
340 a.fireMessageAppended(finalMsg)359 a.fireMessageAppended(finalMsg)
341 if !suppress {360 if !suppress {

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