detached 9b7bb6a

Fix session timeline controls and completion

commit
9b7bb6a4f36bc8c8deb2cc5796a8f557f4fb7479
author
patriceckhart · · 2026-08-22 15:37 UTC
parent
eadc321
signature
unsigned
refs
tag: v0.3.48
6 files · +161 -12
M packages/agent/modes/slash_suggest.go +31 -7
@@ -42,7 +42,7 @@ var slashCatalog = []slashCommand{
4242 {Name: "/reasoning", Desc: "set the reasoning level"},
4343 {Name: "/llama", Desc: "manage llama.cpp router models"},
4444 {Name: "/sessions", Desc: "resume a previous session for this directory"},
45- {Name: "/session", Desc: "inspect, export, import, fork, or navigate the current session"},
45+ {Name: "/session", Desc: "manage the current session"},
4646 {Name: "/jump", Desc: "scroll the chat to a previous turn (or /jump <text>)"},
4747 {Name: "/compact", Desc: "summarize and replace the transcript to free up context"},
4848 {Name: "/study", Desc: "read every file in the cwd (or a passed file/dir) so the agent has full context"},
@@ -58,6 +58,14 @@ var slashCatalog = []slashCommand{
5858 {Name: "/exit", Desc: "exit zot"},
5959 }
6060
61+var sessionSlashCommands = []slashCommand{
62+ {Name: "/session timeline", Desc: "inspect context, messages, and tool calls"},
63+ {Name: "/session export", Desc: "write the current session to a .zotsession file"},
64+ {Name: "/session import", Desc: "load a .zotsession file into this directory"},
65+ {Name: "/session fork", Desc: "branch from a past user message"},
66+ {Name: "/session tree", Desc: "switch between session branches"},
67+}
68+
6169 // slashSuggester renders the popup that appears when the editor starts
6270 // with "/". It does not own any input state — the editor drives.
6371 const slashSuggestPageSize = 8
@@ -266,17 +274,25 @@ var hiddenSlashCommands = []string{
266274 func newSlashSuggester() *slashSuggester { return &slashSuggester{} }
267275
268276 // matches returns the commands whose name has input as a prefix.
269-// If input is just "/", everything is shown.
277+// If input is just "/", everything is shown. The /session command exposes
278+// its actions after the first space.
270279 func (s *slashSuggester) matches(input string) []slashCommand {
271- input = strings.TrimRight(input, " ")
272280 if input == "" || !strings.HasPrefix(input, "/") {
273281 return nil
274282 }
275- // If there is a space, the user has moved past the command name.
276- if idx := strings.IndexByte(input, ' '); idx >= 0 {
283+ matchPrefix := strings.ToLower(input)
284+ if strings.HasPrefix(matchPrefix, "/session ") {
285+ var out []slashCommand
286+ for _, c := range sessionSlashCommands {
287+ if strings.HasPrefix(strings.ToLower(c.Name), matchPrefix) {
288+ out = append(out, c)
289+ }
290+ }
291+ return out
292+ }
293+ if strings.ContainsRune(input, ' ') {
277294 return nil
278295 }
279- matchPrefix := strings.ToLower(input)
280296 catalog := s.allCatalog()
281297 if strings.HasPrefix(matchPrefix, skillCommandPrefix) {
282298 catalog = s.skills
@@ -294,7 +310,15 @@ func (s *slashSuggester) matches(input string) []slashCommand {
294310 out = append(out, c)
295311 }
296312 }
297- return pruneOrphanHeaders(out)
313+ out = pruneOrphanHeaders(out)
314+ // Prefer an exact command over longer commands sharing its prefix. This
315+ // keeps /session selected instead of the earlier /sessions entry.
316+ for _, c := range out {
317+ if !c.Header && strings.EqualFold(c.Name, input) {
318+ return []slashCommand{c}
319+ }
320+ }
321+ return out
298322 }
299323
300324 // pruneOrphanHeaders removes header rows that have no commands
@@ -42,7 +42,7 @@ var slashCatalog = []slashCommand{
42 {Name: "/reasoning", Desc: "set the reasoning level"},42 {Name: "/reasoning", Desc: "set the reasoning level"},
43 {Name: "/llama", Desc: "manage llama.cpp router models"},43 {Name: "/llama", Desc: "manage llama.cpp router models"},
44 {Name: "/sessions", Desc: "resume a previous session for this directory"},44 {Name: "/sessions", Desc: "resume a previous session for this directory"},
45- {Name: "/session", Desc: "inspect, export, import, fork, or navigate the current session"},45+ {Name: "/session", Desc: "manage the current session"},
46 {Name: "/jump", Desc: "scroll the chat to a previous turn (or /jump <text>)"},46 {Name: "/jump", Desc: "scroll the chat to a previous turn (or /jump <text>)"},
47 {Name: "/compact", Desc: "summarize and replace the transcript to free up context"},47 {Name: "/compact", Desc: "summarize and replace the transcript to free up context"},
48 {Name: "/study", Desc: "read every file in the cwd (or a passed file/dir) so the agent has full context"},48 {Name: "/study", Desc: "read every file in the cwd (or a passed file/dir) so the agent has full context"},
@@ -58,6 +58,14 @@ var slashCatalog = []slashCommand{
58 {Name: "/exit", Desc: "exit zot"},58 {Name: "/exit", Desc: "exit zot"},
59 }59 }
60 60
61+var sessionSlashCommands = []slashCommand{
62+ {Name: "/session timeline", Desc: "inspect context, messages, and tool calls"},
63+ {Name: "/session export", Desc: "write the current session to a .zotsession file"},
64+ {Name: "/session import", Desc: "load a .zotsession file into this directory"},
65+ {Name: "/session fork", Desc: "branch from a past user message"},
66+ {Name: "/session tree", Desc: "switch between session branches"},
67+}
68+
61 // slashSuggester renders the popup that appears when the editor starts69 // slashSuggester renders the popup that appears when the editor starts
62 // with "/". It does not own any input state — the editor drives.70 // with "/". It does not own any input state — the editor drives.
63 const slashSuggestPageSize = 871 const slashSuggestPageSize = 8
@@ -266,17 +274,25 @@ var hiddenSlashCommands = []string{
266 func newSlashSuggester() *slashSuggester { return &slashSuggester{} }274 func newSlashSuggester() *slashSuggester { return &slashSuggester{} }
267 275
268 // matches returns the commands whose name has input as a prefix.276 // matches returns the commands whose name has input as a prefix.
269-// If input is just "/", everything is shown.277+// If input is just "/", everything is shown. The /session command exposes
278+// its actions after the first space.
270 func (s *slashSuggester) matches(input string) []slashCommand {279 func (s *slashSuggester) matches(input string) []slashCommand {
271- input = strings.TrimRight(input, " ")
272 if input == "" || !strings.HasPrefix(input, "/") {280 if input == "" || !strings.HasPrefix(input, "/") {
273 return nil281 return nil
274 }282 }
275- // If there is a space, the user has moved past the command name.283+ matchPrefix := strings.ToLower(input)
276- if idx := strings.IndexByte(input, ' '); idx >= 0 {284+ if strings.HasPrefix(matchPrefix, "/session ") {
285+ var out []slashCommand
286+ for _, c := range sessionSlashCommands {
287+ if strings.HasPrefix(strings.ToLower(c.Name), matchPrefix) {
288+ out = append(out, c)
289+ }
290+ }
291+ return out
292+ }
293+ if strings.ContainsRune(input, ' ') {
277 return nil294 return nil
278 }295 }
279- matchPrefix := strings.ToLower(input)
280 catalog := s.allCatalog()296 catalog := s.allCatalog()
281 if strings.HasPrefix(matchPrefix, skillCommandPrefix) {297 if strings.HasPrefix(matchPrefix, skillCommandPrefix) {
282 catalog = s.skills298 catalog = s.skills
@@ -294,7 +310,15 @@ func (s *slashSuggester) matches(input string) []slashCommand {
294 out = append(out, c)310 out = append(out, c)
295 }311 }
296 }312 }
297- return pruneOrphanHeaders(out)313+ out = pruneOrphanHeaders(out)
314+ // Prefer an exact command over longer commands sharing its prefix. This
315+ // keeps /session selected instead of the earlier /sessions entry.
316+ for _, c := range out {
317+ if !c.Header && strings.EqualFold(c.Name, input) {
318+ return []slashCommand{c}
319+ }
320+ }
321+ return out
298 }322 }
299 323
300 // pruneOrphanHeaders removes header rows that have no commands324 // pruneOrphanHeaders removes header rows that have no commands
M packages/agent/modes/slash_suggest_test.go +29 -0
@@ -44,6 +44,35 @@ func TestSlashSuggesterHasSwarm(t *testing.T) {
4444 }
4545 }
4646
47+func TestSlashSuggesterPrefersExactSessionCommand(t *testing.T) {
48+ s := newSlashSuggester()
49+ if got := commandNames(s.matches("/session")); !slices.Equal(got, []string{"/session"}) {
50+ t.Fatalf("matches = %v, want exact /session", got)
51+ }
52+ if got := s.Selection("/session"); got != "/session" {
53+ t.Fatalf("selection = %q, want /session", got)
54+ }
55+}
56+
57+func TestSlashSuggesterCompletesSessionActions(t *testing.T) {
58+ s := newSlashSuggester()
59+ if got, want := commandNames(s.matches("/session ")), []string{
60+ "/session timeline",
61+ "/session export",
62+ "/session import",
63+ "/session fork",
64+ "/session tree",
65+ }; !slices.Equal(got, want) {
66+ t.Fatalf("matches = %v, want %v", got, want)
67+ }
68+ if got := commandNames(s.matches("/session t")); !slices.Equal(got, []string{"/session timeline", "/session tree"}) {
69+ t.Fatalf("filtered matches = %v", got)
70+ }
71+ if got := s.Selection("/session t"); got != "/session timeline" {
72+ t.Fatalf("selection = %q, want /session timeline", got)
73+ }
74+}
75+
4776 func TestSlashCommandsAreCaseInsensitive(t *testing.T) {
4877 s := newSlashSuggester()
4978 if got := commandNames(s.matches("/EX")); !contains(got, "/exit") {
@@ -44,6 +44,35 @@ func TestSlashSuggesterHasSwarm(t *testing.T) {
44 }44 }
45 }45 }
46 46
47+func TestSlashSuggesterPrefersExactSessionCommand(t *testing.T) {
48+ s := newSlashSuggester()
49+ if got := commandNames(s.matches("/session")); !slices.Equal(got, []string{"/session"}) {
50+ t.Fatalf("matches = %v, want exact /session", got)
51+ }
52+ if got := s.Selection("/session"); got != "/session" {
53+ t.Fatalf("selection = %q, want /session", got)
54+ }
55+}
56+
57+func TestSlashSuggesterCompletesSessionActions(t *testing.T) {
58+ s := newSlashSuggester()
59+ if got, want := commandNames(s.matches("/session ")), []string{
60+ "/session timeline",
61+ "/session export",
62+ "/session import",
63+ "/session fork",
64+ "/session tree",
65+ }; !slices.Equal(got, want) {
66+ t.Fatalf("matches = %v, want %v", got, want)
67+ }
68+ if got := commandNames(s.matches("/session t")); !slices.Equal(got, []string{"/session timeline", "/session tree"}) {
69+ t.Fatalf("filtered matches = %v", got)
70+ }
71+ if got := s.Selection("/session t"); got != "/session timeline" {
72+ t.Fatalf("selection = %q, want /session timeline", got)
73+ }
74+}
75+
47 func TestSlashCommandsAreCaseInsensitive(t *testing.T) {76 func TestSlashCommandsAreCaseInsensitive(t *testing.T) {
48 s := newSlashSuggester()77 s := newSlashSuggester()
49 if got := commandNames(s.matches("/EX")); !contains(got, "/exit") {78 if got := commandNames(s.matches("/EX")); !contains(got, "/exit") {
M packages/agent/modes/timeline_view.go +32 -4
@@ -167,7 +167,7 @@ func (v *timelineView) Render(th tui.Theme, width, height int, data timelineData
167167 lines := []string{frameHeader(th, "timeline", width)}
168168 lines = append(lines, renderTimelineContext(th, width, data)...)
169169
170- hint := "up/down select pgup/pgdn move tab details / search ctrl+e export esc chat"
170+ hint := "↑/↓ select pgup/pgdn move tab details / search ctrl+e export esc chat"
171171 if v.searching {
172172 hint = "search: " + v.filter + "_ (esc clears)"
173173 }
@@ -397,15 +397,43 @@ func timelineColor(th tui.Theme, entry timelineEntry, line string) string {
397397 }
398398
399399 func renderTimelineTabs(th tui.Theme, width, selected int) string {
400- var tabs []string
401- for idx, name := range timelineTabNames {
400+ if selected < 0 || selected >= len(timelineTabNames) || width < 1 {
401+ return ""
402+ }
403+ start, end := selected, selected+1
404+ for start > 0 || end < len(timelineTabNames) {
405+ grew := false
406+ if start > 0 && timelineTabsWidth(start-1, end) <= width {
407+ start--
408+ grew = true
409+ }
410+ if end < len(timelineTabNames) && timelineTabsWidth(start, end+1) <= width {
411+ end++
412+ grew = true
413+ }
414+ if !grew {
415+ break
416+ }
417+ }
418+
419+ tabs := make([]string, 0, end-start)
420+ for idx := start; idx < end; idx++ {
421+ name := timelineTabNames[idx]
402422 if idx == selected {
403423 tabs = append(tabs, tui.Bold(th.FG256(th.Accent, "["+name+"]")))
404424 } else {
405425 tabs = append(tabs, th.FG256(th.Muted, " "+name+" "))
406426 }
407427 }
408- return fitTimelineLine(strings.Join(tabs, " "), width)
428+ return strings.Join(tabs, " ")
429+}
430+
431+func timelineTabsWidth(start, end int) int {
432+ width := 2 * (end - start - 1)
433+ for idx := start; idx < end; idx++ {
434+ width += runewidth.StringWidth(timelineTabNames[idx]) + 2
435+ }
436+ return width
409437 }
410438
411439 func renderTimelineDetail(th tui.Theme, width int, entry timelineEntry, tab, maxRows int) []string {
@@ -167,7 +167,7 @@ func (v *timelineView) Render(th tui.Theme, width, height int, data timelineData
167 lines := []string{frameHeader(th, "timeline", width)}167 lines := []string{frameHeader(th, "timeline", width)}
168 lines = append(lines, renderTimelineContext(th, width, data)...)168 lines = append(lines, renderTimelineContext(th, width, data)...)
169 169
170- hint := "up/down select pgup/pgdn move tab details / search ctrl+e export esc chat"170+ hint := "↑/↓ select pgup/pgdn move tab details / search ctrl+e export esc chat"
171 if v.searching {171 if v.searching {
172 hint = "search: " + v.filter + "_ (esc clears)"172 hint = "search: " + v.filter + "_ (esc clears)"
173 }173 }
@@ -397,15 +397,43 @@ func timelineColor(th tui.Theme, entry timelineEntry, line string) string {
397 }397 }
398 398
399 func renderTimelineTabs(th tui.Theme, width, selected int) string {399 func renderTimelineTabs(th tui.Theme, width, selected int) string {
400- var tabs []string400+ if selected < 0 || selected >= len(timelineTabNames) || width < 1 {
401- for idx, name := range timelineTabNames {401+ return ""
402+ }
403+ start, end := selected, selected+1
404+ for start > 0 || end < len(timelineTabNames) {
405+ grew := false
406+ if start > 0 && timelineTabsWidth(start-1, end) <= width {
407+ start--
408+ grew = true
409+ }
410+ if end < len(timelineTabNames) && timelineTabsWidth(start, end+1) <= width {
411+ end++
412+ grew = true
413+ }
414+ if !grew {
415+ break
416+ }
417+ }
418+
419+ tabs := make([]string, 0, end-start)
420+ for idx := start; idx < end; idx++ {
421+ name := timelineTabNames[idx]
402 if idx == selected {422 if idx == selected {
403 tabs = append(tabs, tui.Bold(th.FG256(th.Accent, "["+name+"]")))423 tabs = append(tabs, tui.Bold(th.FG256(th.Accent, "["+name+"]")))
404 } else {424 } else {
405 tabs = append(tabs, th.FG256(th.Muted, " "+name+" "))425 tabs = append(tabs, th.FG256(th.Muted, " "+name+" "))
406 }426 }
407 }427 }
408- return fitTimelineLine(strings.Join(tabs, " "), width)428+ return strings.Join(tabs, " ")
429+}
430+
431+func timelineTabsWidth(start, end int) int {
432+ width := 2 * (end - start - 1)
433+ for idx := start; idx < end; idx++ {
434+ width += runewidth.StringWidth(timelineTabNames[idx]) + 2
435+ }
436+ return width
409 }437 }
410 438
411 func renderTimelineDetail(th tui.Theme, width int, entry timelineEntry, tab, maxRows int) []string {439 func renderTimelineDetail(th tui.Theme, width int, entry timelineEntry, tab, maxRows int) []string {
M packages/agent/modes/timeline_view_test.go +42 -0
@@ -9,6 +9,8 @@ import (
99 "testing"
1010 "time"
1111
12+ "github.com/mattn/go-runewidth"
13+
1214 "github.com/patriceckhart/zot/packages/provider"
1315 "github.com/patriceckhart/zot/packages/tui"
1416 )
@@ -72,6 +74,32 @@ func TestBuildTimelineEntriesPairsToolDetails(t *testing.T) {
7274 }
7375 }
7476
77+func TestTimelineNavigationKeysMoveSelection(t *testing.T) {
78+ data := timelineTestData()
79+ view := newTimelineView()
80+ view.Open(data)
81+ last := len(buildTimelineEntries(data)) - 1
82+ if view.cursor != last {
83+ t.Fatalf("initial cursor = %d, want %d", view.cursor, last)
84+ }
85+ view.HandleKey(tui.Key{Kind: tui.KeyUp}, data)
86+ if view.cursor != last-1 {
87+ t.Fatalf("cursor after up = %d, want %d", view.cursor, last-1)
88+ }
89+ view.HandleKey(tui.Key{Kind: tui.KeyDown}, data)
90+ if view.cursor != last {
91+ t.Fatalf("cursor after down = %d, want %d", view.cursor, last)
92+ }
93+ view.HandleKey(tui.Key{Kind: tui.KeyPageUp}, data)
94+ if view.cursor != 0 {
95+ t.Fatalf("cursor after page up = %d, want 0", view.cursor)
96+ }
97+ view.HandleKey(tui.Key{Kind: tui.KeyPageDown}, data)
98+ if view.cursor != last {
99+ t.Fatalf("cursor after page down = %d, want %d", view.cursor, last)
100+ }
101+}
102+
75103 func TestTimelineSearchAndTabs(t *testing.T) {
76104 data := timelineTestData()
77105 view := newTimelineView()
@@ -94,6 +122,20 @@ func TestTimelineSearchAndTabs(t *testing.T) {
94122 }
95123 }
96124
125+func TestTimelineTabsKeepSelectedTabVisible(t *testing.T) {
126+ const width = 30
127+ for selected, name := range timelineTabNames {
128+ rendered := renderTimelineTabs(tui.Theme{}, width, selected)
129+ plain := stripANSIBytes(rendered)
130+ if !strings.Contains(plain, "["+name+"]") {
131+ t.Errorf("selected tab %q not visible in %q", name, plain)
132+ }
133+ if got := runewidth.StringWidth(plain); got > width {
134+ t.Errorf("tabs width = %d, want <= %d: %q", got, width, plain)
135+ }
136+ }
137+}
138+
97139 func TestTimelineRenderShowsContextAndControls(t *testing.T) {
98140 data := timelineTestData()
99141 if contextLines := renderTimelineContext(tui.Theme{}, 100, data); len(contextLines) != 2 {
@@ -9,6 +9,8 @@ import (
9 "testing"9 "testing"
10 "time"10 "time"
11 11
12+ "github.com/mattn/go-runewidth"
13+
12 "github.com/patriceckhart/zot/packages/provider"14 "github.com/patriceckhart/zot/packages/provider"
13 "github.com/patriceckhart/zot/packages/tui"15 "github.com/patriceckhart/zot/packages/tui"
14 )16 )
@@ -72,6 +74,32 @@ func TestBuildTimelineEntriesPairsToolDetails(t *testing.T) {
72 }74 }
73 }75 }
74 76
77+func TestTimelineNavigationKeysMoveSelection(t *testing.T) {
78+ data := timelineTestData()
79+ view := newTimelineView()
80+ view.Open(data)
81+ last := len(buildTimelineEntries(data)) - 1
82+ if view.cursor != last {
83+ t.Fatalf("initial cursor = %d, want %d", view.cursor, last)
84+ }
85+ view.HandleKey(tui.Key{Kind: tui.KeyUp}, data)
86+ if view.cursor != last-1 {
87+ t.Fatalf("cursor after up = %d, want %d", view.cursor, last-1)
88+ }
89+ view.HandleKey(tui.Key{Kind: tui.KeyDown}, data)
90+ if view.cursor != last {
91+ t.Fatalf("cursor after down = %d, want %d", view.cursor, last)
92+ }
93+ view.HandleKey(tui.Key{Kind: tui.KeyPageUp}, data)
94+ if view.cursor != 0 {
95+ t.Fatalf("cursor after page up = %d, want 0", view.cursor)
96+ }
97+ view.HandleKey(tui.Key{Kind: tui.KeyPageDown}, data)
98+ if view.cursor != last {
99+ t.Fatalf("cursor after page down = %d, want %d", view.cursor, last)
100+ }
101+}
102+
75 func TestTimelineSearchAndTabs(t *testing.T) {103 func TestTimelineSearchAndTabs(t *testing.T) {
76 data := timelineTestData()104 data := timelineTestData()
77 view := newTimelineView()105 view := newTimelineView()
@@ -94,6 +122,20 @@ func TestTimelineSearchAndTabs(t *testing.T) {
94 }122 }
95 }123 }
96 124
125+func TestTimelineTabsKeepSelectedTabVisible(t *testing.T) {
126+ const width = 30
127+ for selected, name := range timelineTabNames {
128+ rendered := renderTimelineTabs(tui.Theme{}, width, selected)
129+ plain := stripANSIBytes(rendered)
130+ if !strings.Contains(plain, "["+name+"]") {
131+ t.Errorf("selected tab %q not visible in %q", name, plain)
132+ }
133+ if got := runewidth.StringWidth(plain); got > width {
134+ t.Errorf("tabs width = %d, want <= %d: %q", got, width, plain)
135+ }
136+ }
137+}
138+
97 func TestTimelineRenderShowsContextAndControls(t *testing.T) {139 func TestTimelineRenderShowsContextAndControls(t *testing.T) {
98 data := timelineTestData()140 data := timelineTestData()
99 if contextLines := renderTimelineContext(tui.Theme{}, 100, data); len(contextLines) != 2 {141 if contextLines := renderTimelineContext(tui.Theme{}, 100, data); len(contextLines) != 2 {
M packages/tui/input.go +10 -1
@@ -165,12 +165,21 @@ func (r *Reader) readEscape() (Key, error) {
165165 case '[':
166166 return r.readCSI()
167167 case 'O':
168- // SS3 sequences (function keys in some terminals).
168+ // SS3 sequences. Terminals in application cursor mode use these
169+ // for arrows, while some terminals also use them for Home and End.
169170 c, err := r.src()
170171 if err != nil {
171172 return Key{}, err
172173 }
173174 switch c {
175+ case 'A':
176+ return Key{Kind: KeyUp}, nil
177+ case 'B':
178+ return Key{Kind: KeyDown}, nil
179+ case 'C':
180+ return Key{Kind: KeyRight}, nil
181+ case 'D':
182+ return Key{Kind: KeyLeft}, nil
174183 case 'H':
175184 return Key{Kind: KeyHome}, nil
176185 case 'F':
@@ -165,12 +165,21 @@ func (r *Reader) readEscape() (Key, error) {
165 case '[':165 case '[':
166 return r.readCSI()166 return r.readCSI()
167 case 'O':167 case 'O':
168- // SS3 sequences (function keys in some terminals).168+ // SS3 sequences. Terminals in application cursor mode use these
169+ // for arrows, while some terminals also use them for Home and End.
169 c, err := r.src()170 c, err := r.src()
170 if err != nil {171 if err != nil {
171 return Key{}, err172 return Key{}, err
172 }173 }
173 switch c {174 switch c {
175+ case 'A':
176+ return Key{Kind: KeyUp}, nil
177+ case 'B':
178+ return Key{Kind: KeyDown}, nil
179+ case 'C':
180+ return Key{Kind: KeyRight}, nil
181+ case 'D':
182+ return Key{Kind: KeyLeft}, nil
174 case 'H':183 case 'H':
175 return Key{Kind: KeyHome}, nil184 return Key{Kind: KeyHome}, nil
176 case 'F':185 case 'F':
M packages/tui/input_test.go +17 -0
@@ -91,6 +91,23 @@ func TestReaderParsesCSIUTabAndBackspace(t *testing.T) {
9191 }
9292 }
9393
94+func TestReaderParsesSS3CursorKeys(t *testing.T) {
95+ cases := []struct {
96+ seq string
97+ want KeyKind
98+ }{
99+ {"\x1bOA", KeyUp},
100+ {"\x1bOB", KeyDown},
101+ {"\x1bOC", KeyRight},
102+ {"\x1bOD", KeyLeft},
103+ }
104+ for _, tc := range cases {
105+ if k := readKey(t, tc.seq); k.Kind != tc.want {
106+ t.Fatalf("Read(%q) kind=%v, want %v", tc.seq, k.Kind, tc.want)
107+ }
108+ }
109+}
110+
94111 func TestReaderParsesSGRMouseWheel(t *testing.T) {
95112 cases := []struct {
96113 seq string
@@ -91,6 +91,23 @@ func TestReaderParsesCSIUTabAndBackspace(t *testing.T) {
91 }91 }
92 }92 }
93 93
94+func TestReaderParsesSS3CursorKeys(t *testing.T) {
95+ cases := []struct {
96+ seq string
97+ want KeyKind
98+ }{
99+ {"\x1bOA", KeyUp},
100+ {"\x1bOB", KeyDown},
101+ {"\x1bOC", KeyRight},
102+ {"\x1bOD", KeyLeft},
103+ }
104+ for _, tc := range cases {
105+ if k := readKey(t, tc.seq); k.Kind != tc.want {
106+ t.Fatalf("Read(%q) kind=%v, want %v", tc.seq, k.Kind, tc.want)
107+ }
108+ }
109+}
110+
94 func TestReaderParsesSGRMouseWheel(t *testing.T) {111 func TestReaderParsesSGRMouseWheel(t *testing.T) {
95 cases := []struct {112 cases := []struct {
96 seq string113 seq string

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