detached 63e33d9

Add clear_notes extension frame and clear notes on new prompt

commit
63e33d9aa94038490742d786bc58b2d13ee75195
author
patriceckhart · · 2026-06-07 09:10 UTC
parent
30cff88
signature
unsigned
refs
tag: v0.2.14
9 files · +137 -5
M docs/extensions.md +17 -2
@@ -337,7 +337,22 @@ Closes a previously-open panel.
337337 ```
338338
339339 `level` is one of `info`, `success`, `warn`, `error`. The note shows
340-up below the transcript with the extension's name in brackets.
340+up below the transcript with the extension's name in brackets. Notes
341+are one-shot: they clear automatically when the user sends their next
342+prompt (and on `esc` / `/clear`).
343+
344+#### `clear_notes` (one-way, any time)
345+
346+Removes every note this extension previously pushed via `notify` /
347+`display`. Use it for transient status lines (e.g. an approval prompt)
348+so they do not stack up; notes from other extensions are untouched.
349+
350+```json
351+{"type":"clear_notes"}
352+```
353+
354+In `--mode rpc`, this surfaces to the host as an `ext_clear_notes`
355+event (alongside `ext_notify` / `ext_display`).
341356
342357 #### `shutdown_ack`
343358
@@ -593,7 +608,7 @@ trust or run zot under your platform's sandboxing tool (`bwrap` /
593608 Phase 1 (shipped):
594609 - [x] subprocess lifecycle + hello handshake
595610 - [x] `register_command` + `command_invoked`
596-- [x] `notify`
611+- [x] `notify` + `clear_notes`
597612 - [x] `zot ext` CLI
598613
599614 Phase 2 (shipped):
@@ -337,7 +337,22 @@ Closes a previously-open panel.
337 ```337 ```
338 338
339 `level` is one of `info`, `success`, `warn`, `error`. The note shows339 `level` is one of `info`, `success`, `warn`, `error`. The note shows
340-up below the transcript with the extension's name in brackets.340+up below the transcript with the extension's name in brackets. Notes
341+are one-shot: they clear automatically when the user sends their next
342+prompt (and on `esc` / `/clear`).
343+
344+#### `clear_notes` (one-way, any time)
345+
346+Removes every note this extension previously pushed via `notify` /
347+`display`. Use it for transient status lines (e.g. an approval prompt)
348+so they do not stack up; notes from other extensions are untouched.
349+
350+```json
351+{"type":"clear_notes"}
352+```
353+
354+In `--mode rpc`, this surfaces to the host as an `ext_clear_notes`
355+event (alongside `ext_notify` / `ext_display`).
341 356
342 #### `shutdown_ack`357 #### `shutdown_ack`
343 358
@@ -593,7 +608,7 @@ trust or run zot under your platform's sandboxing tool (`bwrap` /
593 Phase 1 (shipped):608 Phase 1 (shipped):
594 - [x] subprocess lifecycle + hello handshake609 - [x] subprocess lifecycle + hello handshake
595 - [x] `register_command` + `command_invoked`610 - [x] `register_command` + `command_invoked`
596-- [x] `notify`611+- [x] `notify` + `clear_notes`
597 - [x] `zot ext` CLI612 - [x] `zot ext` CLI
598 613
599 Phase 2 (shipped):614 Phase 2 (shipped):
M packages/agent/cli.go +6 -0
@@ -67,6 +67,11 @@ func (h *interactiveExtHooks) Display(extName, text string) {
6767 iv.Display(extName, text)
6868 }
6969 }
70+func (h *interactiveExtHooks) ClearNotes(extName string) {
71+ if iv := h.iv(); iv != nil {
72+ iv.ClearNotes(extName)
73+ }
74+}
7075 func (h *interactiveExtHooks) OpenPanel(extName string, spec extproto.PanelSpec) {
7176 if iv := h.iv(); iv != nil {
7277 iv.OpenPanel(extName, spec)
@@ -256,6 +261,7 @@ func (nonInteractiveExtHooks) Submit(string)
256261 func (nonInteractiveExtHooks) SubmitSlash(string) {}
257262 func (nonInteractiveExtHooks) Insert(string) {}
258263 func (nonInteractiveExtHooks) Display(string, string) {}
264+func (nonInteractiveExtHooks) ClearNotes(string) {}
259265 func (nonInteractiveExtHooks) OpenPanel(string, extproto.PanelSpec) {}
260266 func (nonInteractiveExtHooks) UpdatePanel(string, string, string, []string, string) {}
261267 func (nonInteractiveExtHooks) ClosePanel(string, string) {}
@@ -67,6 +67,11 @@ func (h *interactiveExtHooks) Display(extName, text string) {
67 iv.Display(extName, text)67 iv.Display(extName, text)
68 }68 }
69 }69 }
70+func (h *interactiveExtHooks) ClearNotes(extName string) {
71+ if iv := h.iv(); iv != nil {
72+ iv.ClearNotes(extName)
73+ }
74+}
70 func (h *interactiveExtHooks) OpenPanel(extName string, spec extproto.PanelSpec) {75 func (h *interactiveExtHooks) OpenPanel(extName string, spec extproto.PanelSpec) {
71 if iv := h.iv(); iv != nil {76 if iv := h.iv(); iv != nil {
72 iv.OpenPanel(extName, spec)77 iv.OpenPanel(extName, spec)
@@ -256,6 +261,7 @@ func (nonInteractiveExtHooks) Submit(string)
256 func (nonInteractiveExtHooks) SubmitSlash(string) {}261 func (nonInteractiveExtHooks) SubmitSlash(string) {}
257 func (nonInteractiveExtHooks) Insert(string) {}262 func (nonInteractiveExtHooks) Insert(string) {}
258 func (nonInteractiveExtHooks) Display(string, string) {}263 func (nonInteractiveExtHooks) Display(string, string) {}
264+func (nonInteractiveExtHooks) ClearNotes(string) {}
259 func (nonInteractiveExtHooks) OpenPanel(string, extproto.PanelSpec) {}265 func (nonInteractiveExtHooks) OpenPanel(string, extproto.PanelSpec) {}
260 func (nonInteractiveExtHooks) UpdatePanel(string, string, string, []string, string) {}266 func (nonInteractiveExtHooks) UpdatePanel(string, string, string, []string, string) {}
261 func (nonInteractiveExtHooks) ClosePanel(string, string) {}267 func (nonInteractiveExtHooks) ClosePanel(string, string) {}
M packages/agent/extensions/manager.go +6 -0
@@ -119,6 +119,10 @@ type HostHooks interface {
119119 // invoking the model and without writing to the transcript.
120120 Display(extName, text string)
121121
122+ // ClearNotes removes any notes previously pushed by extName via
123+ // Notify/Display so transient status lines do not stack forever.
124+ ClearNotes(extName string)
125+
122126 OpenPanel(extName string, spec extproto.PanelSpec)
123127 UpdatePanel(extName, panelID, title string, lines []string, footer string)
124128 ClosePanel(extName, panelID string)
@@ -750,6 +754,8 @@ func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) {
750754 if err := json.Unmarshal(line, &n); err == nil {
751755 m.hooks.Notify(ext.Manifest.Name, n.Level, n.Message)
752756 }
757+ case "clear_notes":
758+ m.hooks.ClearNotes(ext.Manifest.Name)
753759 case "submit_slash":
754760 // Spontaneous request to invoke a slash command in the
755761 // TUI. Refused unless the payload looks like a slash
@@ -119,6 +119,10 @@ type HostHooks interface {
119 // invoking the model and without writing to the transcript.119 // invoking the model and without writing to the transcript.
120 Display(extName, text string)120 Display(extName, text string)
121 121
122+ // ClearNotes removes any notes previously pushed by extName via
123+ // Notify/Display so transient status lines do not stack forever.
124+ ClearNotes(extName string)
125+
122 OpenPanel(extName string, spec extproto.PanelSpec)126 OpenPanel(extName string, spec extproto.PanelSpec)
123 UpdatePanel(extName, panelID, title string, lines []string, footer string)127 UpdatePanel(extName, panelID, title string, lines []string, footer string)
124 ClosePanel(extName, panelID string)128 ClosePanel(extName, panelID string)
@@ -750,6 +754,8 @@ func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) {
750 if err := json.Unmarshal(line, &n); err == nil {754 if err := json.Unmarshal(line, &n); err == nil {
751 m.hooks.Notify(ext.Manifest.Name, n.Level, n.Message)755 m.hooks.Notify(ext.Manifest.Name, n.Level, n.Message)
752 }756 }
757+ case "clear_notes":
758+ m.hooks.ClearNotes(ext.Manifest.Name)
753 case "submit_slash":759 case "submit_slash":
754 // Spontaneous request to invoke a slash command in the760 // Spontaneous request to invoke a slash command in the
755 // TUI. Refused unless the payload looks like a slash761 // TUI. Refused unless the payload looks like a slash
M packages/agent/extensions/manager_test.go +9 -3
@@ -16,9 +16,10 @@ import (
1616
1717 // stubHooks records every callback so the test can assert on them.
1818 type stubHooks struct {
19- mu sync.Mutex
20- notifies []string
21- displays []string
19+ mu sync.Mutex
20+ notifies []string
21+ displays []string
22+ clearNotes []string
2223 }
2324
2425 func (s *stubHooks) Notify(name, level, message string) {
@@ -34,6 +35,11 @@ func (s *stubHooks) Display(name, text string) {
3435 defer s.mu.Unlock()
3536 s.displays = append(s.displays, name+":"+text)
3637 }
38+func (s *stubHooks) ClearNotes(name string) {
39+ s.mu.Lock()
40+ defer s.mu.Unlock()
41+ s.clearNotes = append(s.clearNotes, name)
42+}
3743 func (s *stubHooks) OpenPanel(string, extproto.PanelSpec) {}
3844 func (s *stubHooks) UpdatePanel(string, string, string, []string, string) {}
3945 func (s *stubHooks) ClosePanel(string, string) {}
@@ -16,9 +16,10 @@ import (
16 16
17 // stubHooks records every callback so the test can assert on them.17 // stubHooks records every callback so the test can assert on them.
18 type stubHooks struct {18 type stubHooks struct {
19- mu sync.Mutex19+ mu sync.Mutex
20- notifies []string20+ notifies []string
21- displays []string21+ displays []string
22+ clearNotes []string
22 }23 }
23 24
24 func (s *stubHooks) Notify(name, level, message string) {25 func (s *stubHooks) Notify(name, level, message string) {
@@ -34,6 +35,11 @@ func (s *stubHooks) Display(name, text string) {
34 defer s.mu.Unlock()35 defer s.mu.Unlock()
35 s.displays = append(s.displays, name+":"+text)36 s.displays = append(s.displays, name+":"+text)
36 }37 }
38+func (s *stubHooks) ClearNotes(name string) {
39+ s.mu.Lock()
40+ defer s.mu.Unlock()
41+ s.clearNotes = append(s.clearNotes, name)
42+}
37 func (s *stubHooks) OpenPanel(string, extproto.PanelSpec) {}43 func (s *stubHooks) OpenPanel(string, extproto.PanelSpec) {}
38 func (s *stubHooks) UpdatePanel(string, string, string, []string, string) {}44 func (s *stubHooks) UpdatePanel(string, string, string, []string, string) {}
39 func (s *stubHooks) ClosePanel(string, string) {}45 func (s *stubHooks) ClosePanel(string, string) {}
M packages/agent/extproto/extproto.go +8 -0
@@ -116,6 +116,14 @@ type NotifyFromExt struct {
116116 Message string `json:"message"`
117117 }
118118
119+// ClearNotesFromExt is a spontaneous frame an extension can send to
120+// retract every note it previously pushed via notify/display, so
121+// transient status lines (e.g. an approval prompt) do not stack up
122+// forever in the bottom-sticky notes block.
123+type ClearNotesFromExt struct {
124+ Type string `json:"type"`
125+}
126+
119127 // SubmitSlashFromExt is a spontaneous frame an extension can send at
120128 // any time (typically from a panel_key handler) to invoke a slash
121129 // command in the host's TUI as if the user had typed it. Text must
@@ -116,6 +116,14 @@ type NotifyFromExt struct {
116 Message string `json:"message"`116 Message string `json:"message"`
117 }117 }
118 118
119+// ClearNotesFromExt is a spontaneous frame an extension can send to
120+// retract every note it previously pushed via notify/display, so
121+// transient status lines (e.g. an approval prompt) do not stack up
122+// forever in the bottom-sticky notes block.
123+type ClearNotesFromExt struct {
124+ Type string `json:"type"`
125+}
126+
119 // SubmitSlashFromExt is a spontaneous frame an extension can send at127 // SubmitSlashFromExt is a spontaneous frame an extension can send at
120 // any time (typically from a panel_key handler) to invoke a slash128 // any time (typically from a panel_key handler) to invoke a slash
121 // command in the host's TUI as if the user had typed it. Text must129 // command in the host's TUI as if the user had typed it. Text must
A packages/agent/modes/ext_notes_test.go +46 -0
@@ -0,0 +1,46 @@
1+package modes
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/patriceckhart/zot/packages/tui"
8+)
9+
10+func newNotesTestInteractive() *Interactive {
11+ i := &Interactive{dirty: make(chan struct{}, 1)}
12+ i.cfg.Theme = tui.Theme{Muted: 8, Warning: 3, Error: 1, Tool: 2, Accent: 4}
13+ return i
14+}
15+
16+func TestClearNotesRemovesOnlyOwnerNotes(t *testing.T) {
17+ i := newNotesTestInteractive()
18+
19+ i.Notify("kagi", "info", "pending")
20+ i.Notify("kagi", "success", "approved")
21+ i.Notify("other", "info", "keep me")
22+
23+ if len(i.extNotes) != 3 {
24+ t.Fatalf("expected 3 notes, got %d", len(i.extNotes))
25+ }
26+
27+ i.ClearNotes("kagi")
28+
29+ if len(i.extNotes) != 1 {
30+ t.Fatalf("expected 1 note after clear, got %d: %v", len(i.extNotes), i.extNotes)
31+ }
32+ if !strings.Contains(i.extNotes[0], "[other] ") {
33+ t.Fatalf("expected the surviving note to belong to other, got %q", i.extNotes[0])
34+ }
35+}
36+
37+func TestClearNotesNoMatchKeepsNotes(t *testing.T) {
38+ i := newNotesTestInteractive()
39+ i.Notify("kagi", "info", "pending")
40+
41+ i.ClearNotes("nope")
42+
43+ if len(i.extNotes) != 1 {
44+ t.Fatalf("expected note to survive, got %d", len(i.extNotes))
45+ }
46+}
@@ -0,0 +1,46 @@
1+package modes
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/patriceckhart/zot/packages/tui"
8+)
9+
10+func newNotesTestInteractive() *Interactive {
11+ i := &Interactive{dirty: make(chan struct{}, 1)}
12+ i.cfg.Theme = tui.Theme{Muted: 8, Warning: 3, Error: 1, Tool: 2, Accent: 4}
13+ return i
14+}
15+
16+func TestClearNotesRemovesOnlyOwnerNotes(t *testing.T) {
17+ i := newNotesTestInteractive()
18+
19+ i.Notify("kagi", "info", "pending")
20+ i.Notify("kagi", "success", "approved")
21+ i.Notify("other", "info", "keep me")
22+
23+ if len(i.extNotes) != 3 {
24+ t.Fatalf("expected 3 notes, got %d", len(i.extNotes))
25+ }
26+
27+ i.ClearNotes("kagi")
28+
29+ if len(i.extNotes) != 1 {
30+ t.Fatalf("expected 1 note after clear, got %d: %v", len(i.extNotes), i.extNotes)
31+ }
32+ if !strings.Contains(i.extNotes[0], "[other] ") {
33+ t.Fatalf("expected the surviving note to belong to other, got %q", i.extNotes[0])
34+ }
35+}
36+
37+func TestClearNotesNoMatchKeepsNotes(t *testing.T) {
38+ i := newNotesTestInteractive()
39+ i.Notify("kagi", "info", "pending")
40+
41+ i.ClearNotes("nope")
42+
43+ if len(i.extNotes) != 1 {
44+ t.Fatalf("expected note to survive, got %d", len(i.extNotes))
45+ }
46+}
M packages/agent/modes/interactive.go +31 -0
@@ -2356,6 +2356,36 @@ func (i *Interactive) Notify(extName, level, message string) {
23562356 i.invalidate()
23572357 }
23582358
2359+// ClearNotes removes every note line owned by extName from the
2360+// bottom-sticky ext-notes block. Extensions use this to retract a
2361+// transient status line (e.g. an approval prompt) once it no longer
2362+// applies, instead of leaving it stacked forever. Notes from other
2363+// extensions and internal notes (auto-compact) are left untouched.
2364+func (i *Interactive) ClearNotes(extName string) {
2365+ marker := "[" + extName + "] "
2366+ i.mu.Lock()
2367+ if len(i.extNotes) == 0 {
2368+ i.mu.Unlock()
2369+ return
2370+ }
2371+ kept := i.extNotes[:0:0]
2372+ changed := false
2373+ for _, line := range i.extNotes {
2374+ if strings.Contains(line, marker) {
2375+ changed = true
2376+ continue
2377+ }
2378+ kept = append(kept, line)
2379+ }
2380+ if changed {
2381+ i.extNotes = kept
2382+ }
2383+ i.mu.Unlock()
2384+ if changed {
2385+ i.invalidate()
2386+ }
2387+}
2388+
23592389 // Submit feeds text through the agent loop as if the user had typed it.
23602390 func (i *Interactive) Submit(text string) {
23612391 if cmd, ok := shellEscapeCommand(text); ok {
@@ -4237,6 +4267,7 @@ func (i *Interactive) startTurnWithImages(parent context.Context, prompt string,
42374267 i.toolCalls = map[string]*tui.ToolCallView{}
42384268 i.toolOrder = nil
42394269 i.shellBlock = nil // sending a prompt clears any parked shell-escape log
4270+ i.extNotes = nil // ext notes are one-shot; a new prompt clears them
42404271 i.scrollOffset = 0 // jump back to the bottom on new turn
42414272 // Reset the auto-follow baseline so the very next render at
42424273 // interactive.go:1053 doesn't see a synthetic shrink between
@@ -2356,6 +2356,36 @@ func (i *Interactive) Notify(extName, level, message string) {
2356 i.invalidate()2356 i.invalidate()
2357 }2357 }
2358 2358
2359+// ClearNotes removes every note line owned by extName from the
2360+// bottom-sticky ext-notes block. Extensions use this to retract a
2361+// transient status line (e.g. an approval prompt) once it no longer
2362+// applies, instead of leaving it stacked forever. Notes from other
2363+// extensions and internal notes (auto-compact) are left untouched.
2364+func (i *Interactive) ClearNotes(extName string) {
2365+ marker := "[" + extName + "] "
2366+ i.mu.Lock()
2367+ if len(i.extNotes) == 0 {
2368+ i.mu.Unlock()
2369+ return
2370+ }
2371+ kept := i.extNotes[:0:0]
2372+ changed := false
2373+ for _, line := range i.extNotes {
2374+ if strings.Contains(line, marker) {
2375+ changed = true
2376+ continue
2377+ }
2378+ kept = append(kept, line)
2379+ }
2380+ if changed {
2381+ i.extNotes = kept
2382+ }
2383+ i.mu.Unlock()
2384+ if changed {
2385+ i.invalidate()
2386+ }
2387+}
2388+
2359 // Submit feeds text through the agent loop as if the user had typed it.2389 // Submit feeds text through the agent loop as if the user had typed it.
2360 func (i *Interactive) Submit(text string) {2390 func (i *Interactive) Submit(text string) {
2361 if cmd, ok := shellEscapeCommand(text); ok {2391 if cmd, ok := shellEscapeCommand(text); ok {
@@ -4237,6 +4267,7 @@ func (i *Interactive) startTurnWithImages(parent context.Context, prompt string,
4237 i.toolCalls = map[string]*tui.ToolCallView{}4267 i.toolCalls = map[string]*tui.ToolCallView{}
4238 i.toolOrder = nil4268 i.toolOrder = nil
4239 i.shellBlock = nil // sending a prompt clears any parked shell-escape log4269 i.shellBlock = nil // sending a prompt clears any parked shell-escape log
4270+ i.extNotes = nil // ext notes are one-shot; a new prompt clears them
4240 i.scrollOffset = 0 // jump back to the bottom on new turn4271 i.scrollOffset = 0 // jump back to the bottom on new turn
4241 // Reset the auto-follow baseline so the very next render at4272 // Reset the auto-follow baseline so the very next render at
4242 // interactive.go:1053 doesn't see a synthetic shrink between4273 // interactive.go:1053 doesn't see a synthetic shrink between
M packages/agent/rpc.go +8 -0
@@ -142,6 +142,14 @@ func (h *rpcExtHooks) Display(extName, text string) {
142142 })
143143 }
144144 }
145+func (h *rpcExtHooks) ClearNotes(extName string) {
146+ if h.server != nil {
147+ h.server.writeEvent(map[string]any{
148+ "type": "ext_clear_notes",
149+ "extension": extName,
150+ })
151+ }
152+}
145153 func (h *rpcExtHooks) Submit(string) {} // ignored in rpc mode
146154 func (h *rpcExtHooks) SubmitSlash(string) {} // ignored in rpc mode
147155 func (h *rpcExtHooks) Insert(string) {} // ignored in rpc mode
@@ -142,6 +142,14 @@ func (h *rpcExtHooks) Display(extName, text string) {
142 })142 })
143 }143 }
144 }144 }
145+func (h *rpcExtHooks) ClearNotes(extName string) {
146+ if h.server != nil {
147+ h.server.writeEvent(map[string]any{
148+ "type": "ext_clear_notes",
149+ "extension": extName,
150+ })
151+ }
152+}
145 func (h *rpcExtHooks) Submit(string) {} // ignored in rpc mode153 func (h *rpcExtHooks) Submit(string) {} // ignored in rpc mode
146 func (h *rpcExtHooks) SubmitSlash(string) {} // ignored in rpc mode154 func (h *rpcExtHooks) SubmitSlash(string) {} // ignored in rpc mode
147 func (h *rpcExtHooks) Insert(string) {} // ignored in rpc mode155 func (h *rpcExtHooks) Insert(string) {} // ignored in rpc mode
M packages/agent/skills/builtin/write-zot-extension/SKILL.md +6 -0
@@ -145,9 +145,15 @@ you can — newer extensions on faster hosts shave that 250ms off.
145145 {"type":"event_intercept_response","id":"ghi","block":true,
146146 "reason":"refused: command matches the danger pattern \"rm -rf\""}
147147 {"type":"notify","level":"info","message":"refreshed cache"}
148+{"type":"clear_notes"}
148149 {"type":"shutdown_ack"}
149150 ```
150151
152+`notify` notes are one-shot: they clear when the user sends their next
153+prompt (and on `esc` / `/clear`). Send `clear_notes` to retract every
154+note this extension pushed earlier (e.g. a transient approval prompt)
155+without waiting for the next turn; other extensions' notes are kept.
156+
151157 `command_response.action` values:
152158 - `"prompt"` — submit `prompt` as a fresh user message
153159 - `"insert"` — drop `insert` into the editor at the cursor
@@ -145,9 +145,15 @@ you can — newer extensions on faster hosts shave that 250ms off.
145 {"type":"event_intercept_response","id":"ghi","block":true,145 {"type":"event_intercept_response","id":"ghi","block":true,
146 "reason":"refused: command matches the danger pattern \"rm -rf\""}146 "reason":"refused: command matches the danger pattern \"rm -rf\""}
147 {"type":"notify","level":"info","message":"refreshed cache"}147 {"type":"notify","level":"info","message":"refreshed cache"}
148+{"type":"clear_notes"}
148 {"type":"shutdown_ack"}149 {"type":"shutdown_ack"}
149 ```150 ```
150 151
152+`notify` notes are one-shot: they clear when the user sends their next
153+prompt (and on `esc` / `/clear`). Send `clear_notes` to retract every
154+note this extension pushed earlier (e.g. a transient approval prompt)
155+without waiting for the next turn; other extensions' notes are kept.
156+
151 `command_response.action` values:157 `command_response.action` values:
152 - `"prompt"` — submit `prompt` as a fresh user message158 - `"prompt"` — submit `prompt` as a fresh user message
153 - `"insert"` — drop `insert` into the editor at the cursor159 - `"insert"` — drop `insert` into the editor at the cursor

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