detached 7e66a81

feat(tui): cycle reasoning levels with ctrl+r (#160)

commit
7e66a813ff8acdec17fd70259b72f0e843059085
author
bnema · · 2026-08-29 15:53 UTC
committer
GitHub · · 2026-08-29 15:53 UTC
parent
7a5217b
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v0.26.0
7 files · +89 -1
M README.md +2 -1
@@ -450,7 +450,7 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o
450450 - **compact transcript rendering**: reduce visual chrome in the chat transcript. Tool calls render as a quiet header plus indented output instead of a bordered box, and sent messages render without padded background bubbles. Off by default. Changes apply immediately and persist to `config.json` as `compact_mode`.
451451 - **show loaded resources at startup**: list the active Zutfile agent (for `zut run`), loaded `AGENTS.md` paths, extensions, and user-installed skills in compact sections above the transcript. Built-in skills are omitted. Off by default. Changes apply immediately and persist to `config.json` as `show_instructions_at_startup`.
452452 - **TUI settings**: opens a sub-view for input layout in display order: status, working spinner, input, then subagent operations. **Input style** can be `plain` (default prompt line), `lines` (separator lines above and below the input), or `block` (a user-bubble-style input block). **Status position** places model, usage, and working-directory information above or below the input. **Working spinner position** places the busy spinner above or below the input. Changes apply immediately and persist to `config.json` as `tui_input_style`, `tui_status_position`, and `tui_working_position`.
453-- **reasoning level**: choose reasoning for supported models: off, minimum, low, medium, high, xhigh, or max. The `max` tier is opt-in and sent natively to GPT-5.6 and adaptive-thinking Claude models; unsupported backends clamp it to their highest accepted effort. The change is persisted to `config.json` and applied to the next model call. Use `/reasoning` to open this selector directly. The selector only shows distinct levels supported by the active model; models without reasoning support only offer `off`.
453+- **reasoning level**: choose reasoning for supported models: off, minimum, low, medium, high, xhigh, or max. The `max` tier is opt-in and sent natively to GPT-5.6 and adaptive-thinking Claude models; unsupported backends clamp it to their highest accepted effort. The change is persisted to `config.json` and applied to the next model call. Use `/reasoning` to open this selector directly, or press `Ctrl+R` to cycle through the active model's supported levels. The selector only shows distinct levels supported by the active model; models without reasoning support only offer `off`.
454454 - **color theme** — `auto` follows the terminal foreground, background, ANSI palette, color depth, and supported live appearance changes. Choose fixed dark/light palettes or JSON overlays discovered under `$ZUT_HOME/themes` and loaded extensions. Active custom files reload safely; invalid edits retain the last valid appearance and persistent deletion resets to auto. See [docs/themes.md](docs/themes.md).
455455 - **model shortcuts** — opens a sub-view with nine slots (`model 1` ... `model 9`). `enter` on a slot opens the same `/model` selector and binds the chosen provider/model to that slot; `backspace` clears a slot. Once assigned, press `Ctrl+1` ... `Ctrl+9` from the editor to switch the active model instantly (the same cross-provider swap `/model` performs, transcript and cost carried over). Assigning a shortcut does not change the current model. Shortcuts are skipped while a turn is running.
456456
@@ -925,6 +925,7 @@ Slash commands also work while the agent is busy. Non-destructive ones (`/help`,
925925 | `ctrl+l` | Redraw the screen. |
926926 | `ctrl+v` | Paste clipboard text into the focused chat, side chat, dialog, filter, or credential input. In the main chat, image clipboard content is attached to the next prompt when the platform exposes it (macOS pasteboard, Wayland `wl-paste`, or X11 `xclip`). On Linux, text uses `wl-paste`, `xclip`, or `xsel`; terminal-native bracketed paste remains available without those commands. |
927927 | `ctrl+o` | Expand or collapse long tool results (read, write, edit, bash, create_worktree, grep, lsp, and web_search outputs over ~12 lines). |
928+| `ctrl+r` | Cycle through the reasoning levels supported by the active model. The selected level applies to subsequent model calls and persists to `config.json`. |
928929 | `ctrl+1` ... `ctrl+9` | Switch to the model bound to that quick-model slot (configured in `/settings` -> model shortcuts). No-op while a turn is running. |
929930 | `@` | Open the file picker. Browse files and directories in the working directory. |
930931
@@ -450,7 +450,7 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o
450 - **compact transcript rendering**: reduce visual chrome in the chat transcript. Tool calls render as a quiet header plus indented output instead of a bordered box, and sent messages render without padded background bubbles. Off by default. Changes apply immediately and persist to `config.json` as `compact_mode`.450 - **compact transcript rendering**: reduce visual chrome in the chat transcript. Tool calls render as a quiet header plus indented output instead of a bordered box, and sent messages render without padded background bubbles. Off by default. Changes apply immediately and persist to `config.json` as `compact_mode`.
451 - **show loaded resources at startup**: list the active Zutfile agent (for `zut run`), loaded `AGENTS.md` paths, extensions, and user-installed skills in compact sections above the transcript. Built-in skills are omitted. Off by default. Changes apply immediately and persist to `config.json` as `show_instructions_at_startup`.451 - **show loaded resources at startup**: list the active Zutfile agent (for `zut run`), loaded `AGENTS.md` paths, extensions, and user-installed skills in compact sections above the transcript. Built-in skills are omitted. Off by default. Changes apply immediately and persist to `config.json` as `show_instructions_at_startup`.
452 - **TUI settings**: opens a sub-view for input layout in display order: status, working spinner, input, then subagent operations. **Input style** can be `plain` (default prompt line), `lines` (separator lines above and below the input), or `block` (a user-bubble-style input block). **Status position** places model, usage, and working-directory information above or below the input. **Working spinner position** places the busy spinner above or below the input. Changes apply immediately and persist to `config.json` as `tui_input_style`, `tui_status_position`, and `tui_working_position`.452 - **TUI settings**: opens a sub-view for input layout in display order: status, working spinner, input, then subagent operations. **Input style** can be `plain` (default prompt line), `lines` (separator lines above and below the input), or `block` (a user-bubble-style input block). **Status position** places model, usage, and working-directory information above or below the input. **Working spinner position** places the busy spinner above or below the input. Changes apply immediately and persist to `config.json` as `tui_input_style`, `tui_status_position`, and `tui_working_position`.
453-- **reasoning level**: choose reasoning for supported models: off, minimum, low, medium, high, xhigh, or max. The `max` tier is opt-in and sent natively to GPT-5.6 and adaptive-thinking Claude models; unsupported backends clamp it to their highest accepted effort. The change is persisted to `config.json` and applied to the next model call. Use `/reasoning` to open this selector directly. The selector only shows distinct levels supported by the active model; models without reasoning support only offer `off`.453+- **reasoning level**: choose reasoning for supported models: off, minimum, low, medium, high, xhigh, or max. The `max` tier is opt-in and sent natively to GPT-5.6 and adaptive-thinking Claude models; unsupported backends clamp it to their highest accepted effort. The change is persisted to `config.json` and applied to the next model call. Use `/reasoning` to open this selector directly, or press `Ctrl+R` to cycle through the active model's supported levels. The selector only shows distinct levels supported by the active model; models without reasoning support only offer `off`.
454 - **color theme** — `auto` follows the terminal foreground, background, ANSI palette, color depth, and supported live appearance changes. Choose fixed dark/light palettes or JSON overlays discovered under `$ZUT_HOME/themes` and loaded extensions. Active custom files reload safely; invalid edits retain the last valid appearance and persistent deletion resets to auto. See [docs/themes.md](docs/themes.md).454 - **color theme** — `auto` follows the terminal foreground, background, ANSI palette, color depth, and supported live appearance changes. Choose fixed dark/light palettes or JSON overlays discovered under `$ZUT_HOME/themes` and loaded extensions. Active custom files reload safely; invalid edits retain the last valid appearance and persistent deletion resets to auto. See [docs/themes.md](docs/themes.md).
455 - **model shortcuts** — opens a sub-view with nine slots (`model 1` ... `model 9`). `enter` on a slot opens the same `/model` selector and binds the chosen provider/model to that slot; `backspace` clears a slot. Once assigned, press `Ctrl+1` ... `Ctrl+9` from the editor to switch the active model instantly (the same cross-provider swap `/model` performs, transcript and cost carried over). Assigning a shortcut does not change the current model. Shortcuts are skipped while a turn is running.455 - **model shortcuts** — opens a sub-view with nine slots (`model 1` ... `model 9`). `enter` on a slot opens the same `/model` selector and binds the chosen provider/model to that slot; `backspace` clears a slot. Once assigned, press `Ctrl+1` ... `Ctrl+9` from the editor to switch the active model instantly (the same cross-provider swap `/model` performs, transcript and cost carried over). Assigning a shortcut does not change the current model. Shortcuts are skipped while a turn is running.
456 456
@@ -925,6 +925,7 @@ Slash commands also work while the agent is busy. Non-destructive ones (`/help`,
925 | `ctrl+l` | Redraw the screen. |925 | `ctrl+l` | Redraw the screen. |
926 | `ctrl+v` | Paste clipboard text into the focused chat, side chat, dialog, filter, or credential input. In the main chat, image clipboard content is attached to the next prompt when the platform exposes it (macOS pasteboard, Wayland `wl-paste`, or X11 `xclip`). On Linux, text uses `wl-paste`, `xclip`, or `xsel`; terminal-native bracketed paste remains available without those commands. |926 | `ctrl+v` | Paste clipboard text into the focused chat, side chat, dialog, filter, or credential input. In the main chat, image clipboard content is attached to the next prompt when the platform exposes it (macOS pasteboard, Wayland `wl-paste`, or X11 `xclip`). On Linux, text uses `wl-paste`, `xclip`, or `xsel`; terminal-native bracketed paste remains available without those commands. |
927 | `ctrl+o` | Expand or collapse long tool results (read, write, edit, bash, create_worktree, grep, lsp, and web_search outputs over ~12 lines). |927 | `ctrl+o` | Expand or collapse long tool results (read, write, edit, bash, create_worktree, grep, lsp, and web_search outputs over ~12 lines). |
928+| `ctrl+r` | Cycle through the reasoning levels supported by the active model. The selected level applies to subsequent model calls and persists to `config.json`. |
928 | `ctrl+1` ... `ctrl+9` | Switch to the model bound to that quick-model slot (configured in `/settings` -> model shortcuts). No-op while a turn is running. |929 | `ctrl+1` ... `ctrl+9` | Switch to the model bound to that quick-model slot (configured in `/settings` -> model shortcuts). No-op while a turn is running. |
929 | `@` | Open the file picker. Browse files and directories in the working directory. |930 | `@` | Open the file picker. Browse files and directories in the working directory. |
930 931
M packages/agent/modes/help.go +1 -0
@@ -22,6 +22,7 @@ var helpKeyRows = [][2]string{
2222 {"alt+← / alt+→", "jump one word back / forward"},
2323 {"ctrl+l", "redraw the screen"},
2424 {"ctrl+o", "expand / collapse long tool results"},
25+ {"ctrl+r", "cycle reasoning levels for the active model"},
2526 {"pgup / pgdn", "scroll the chat one page up / down"},
2627 {"up / down", "move in input, browse prompt history at top edge, scroll chat when empty"},
2728 }
@@ -22,6 +22,7 @@ var helpKeyRows = [][2]string{
22 {"alt+← / alt+→", "jump one word back / forward"},22 {"alt+← / alt+→", "jump one word back / forward"},
23 {"ctrl+l", "redraw the screen"},23 {"ctrl+l", "redraw the screen"},
24 {"ctrl+o", "expand / collapse long tool results"},24 {"ctrl+o", "expand / collapse long tool results"},
25+ {"ctrl+r", "cycle reasoning levels for the active model"},
25 {"pgup / pgdn", "scroll the chat one page up / down"},26 {"pgup / pgdn", "scroll the chat one page up / down"},
26 {"up / down", "move in input, browse prompt history at top edge, scroll chat when empty"},27 {"up / down", "move in input, browse prompt history at top edge, scroll chat when empty"},
27 }28 }
M packages/agent/modes/interactive_input_routing.go +3 -0
@@ -647,6 +647,9 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
647647 case tui.KeyCtrlO:
648648 i.toggleToolExpansion()
649649 return false
650+ case tui.KeyCtrlR:
651+ i.cycleReasoningSetting()
652+ return false
650653 case tui.KeyPageUp:
651654 i.scrollBy(+i.chatPage())
652655 return false
@@ -647,6 +647,9 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
647 case tui.KeyCtrlO:647 case tui.KeyCtrlO:
648 i.toggleToolExpansion()648 i.toggleToolExpansion()
649 return false649 return false
650+ case tui.KeyCtrlR:
651+ i.cycleReasoningSetting()
652+ return false
650 case tui.KeyPageUp:653 case tui.KeyPageUp:
651 i.scrollBy(+i.chatPage())654 i.scrollBy(+i.chatPage())
652 return false655 return false
M packages/agent/modes/interactive_model_test.go +50 -0
@@ -6,6 +6,7 @@ import (
66 "strings"
77 "testing"
88
9+ "github.com/bnema/zut/packages/core"
910 "github.com/bnema/zut/packages/provider"
1011 "github.com/bnema/zut/packages/tui"
1112 )
@@ -47,6 +48,55 @@ func TestReasoningSelectorOnlyOffersOffForUnsupportedModel(t *testing.T) {
4748 }
4849 }
4950
51+func TestCtrlRCyclesCurrentModelReasoningLevels(t *testing.T) {
52+ var changed []string
53+ i := NewInteractive(InteractiveConfig{
54+ Theme: tui.Dark,
55+ Provider: "openai",
56+ Model: "gpt-5.6-sol",
57+ Reasoning: "high",
58+ OnReasoningChanged: func(level string) {
59+ changed = append(changed, level)
60+ },
61+ })
62+ i.agent = &core.Agent{Reasoning: "high"}
63+ i.rend = nil
64+
65+ for _, want := range []string{"xhigh", "max", ""} {
66+ if done := i.handleKey(context.Background(), tui.Key{Kind: tui.KeyCtrlR}); done {
67+ t.Fatal("ctrl+r exited the interactive session")
68+ }
69+ if i.cfg.Reasoning != want || i.agent.Reasoning != want {
70+ t.Fatalf("reasoning = cfg %q agent %q, want %q", i.cfg.Reasoning, i.agent.Reasoning, want)
71+ }
72+ }
73+ if !slices.Equal(changed, []string{"xhigh", "max", ""}) {
74+ t.Fatalf("reasoning callbacks = %q", changed)
75+ }
76+ if i.statusOK != "reasoning level off" {
77+ t.Fatalf("reasoning status = %q", i.statusOK)
78+ }
79+}
80+
81+func TestCtrlRReportsUnsupportedCurrentModel(t *testing.T) {
82+ i := NewInteractive(InteractiveConfig{
83+ Theme: tui.Dark,
84+ Provider: "google",
85+ Model: "gemini-2.0-flash",
86+ Reasoning: "high",
87+ })
88+ i.rend = nil
89+
90+ i.handleKey(context.Background(), tui.Key{Kind: tui.KeyCtrlR})
91+
92+ if i.cfg.Reasoning != "high" {
93+ t.Fatalf("reasoning changed to %q for unsupported model", i.cfg.Reasoning)
94+ }
95+ if i.statusOK != "current model does not support reasoning" {
96+ t.Fatalf("reasoning status = %q", i.statusOK)
97+ }
98+}
99+
50100 func TestReasoningCommandOpensDirectSelector(t *testing.T) {
51101 i := &Interactive{
52102 cfg: InteractiveConfig{
@@ -6,6 +6,7 @@ import (
6 "strings"6 "strings"
7 "testing"7 "testing"
8 8
9+ "github.com/bnema/zut/packages/core"
9 "github.com/bnema/zut/packages/provider"10 "github.com/bnema/zut/packages/provider"
10 "github.com/bnema/zut/packages/tui"11 "github.com/bnema/zut/packages/tui"
11 )12 )
@@ -47,6 +48,55 @@ func TestReasoningSelectorOnlyOffersOffForUnsupportedModel(t *testing.T) {
47 }48 }
48 }49 }
49 50
51+func TestCtrlRCyclesCurrentModelReasoningLevels(t *testing.T) {
52+ var changed []string
53+ i := NewInteractive(InteractiveConfig{
54+ Theme: tui.Dark,
55+ Provider: "openai",
56+ Model: "gpt-5.6-sol",
57+ Reasoning: "high",
58+ OnReasoningChanged: func(level string) {
59+ changed = append(changed, level)
60+ },
61+ })
62+ i.agent = &core.Agent{Reasoning: "high"}
63+ i.rend = nil
64+
65+ for _, want := range []string{"xhigh", "max", ""} {
66+ if done := i.handleKey(context.Background(), tui.Key{Kind: tui.KeyCtrlR}); done {
67+ t.Fatal("ctrl+r exited the interactive session")
68+ }
69+ if i.cfg.Reasoning != want || i.agent.Reasoning != want {
70+ t.Fatalf("reasoning = cfg %q agent %q, want %q", i.cfg.Reasoning, i.agent.Reasoning, want)
71+ }
72+ }
73+ if !slices.Equal(changed, []string{"xhigh", "max", ""}) {
74+ t.Fatalf("reasoning callbacks = %q", changed)
75+ }
76+ if i.statusOK != "reasoning level off" {
77+ t.Fatalf("reasoning status = %q", i.statusOK)
78+ }
79+}
80+
81+func TestCtrlRReportsUnsupportedCurrentModel(t *testing.T) {
82+ i := NewInteractive(InteractiveConfig{
83+ Theme: tui.Dark,
84+ Provider: "google",
85+ Model: "gemini-2.0-flash",
86+ Reasoning: "high",
87+ })
88+ i.rend = nil
89+
90+ i.handleKey(context.Background(), tui.Key{Kind: tui.KeyCtrlR})
91+
92+ if i.cfg.Reasoning != "high" {
93+ t.Fatalf("reasoning changed to %q for unsupported model", i.cfg.Reasoning)
94+ }
95+ if i.statusOK != "current model does not support reasoning" {
96+ t.Fatalf("reasoning status = %q", i.statusOK)
97+ }
98+}
99+
50 func TestReasoningCommandOpensDirectSelector(t *testing.T) {100 func TestReasoningCommandOpensDirectSelector(t *testing.T) {
51 i := &Interactive{101 i := &Interactive{
52 cfg: InteractiveConfig{102 cfg: InteractiveConfig{
M packages/agent/modes/interactive_settings.go +13 -0
@@ -118,6 +118,19 @@ func (i *Interactive) reasoningSettingItem() settingsItem {
118118 func (i *Interactive) openReasoningDialog() {
119119 i.settingsDialog.OpenDirectOption(i.reasoningSettingItem())
120120 }
121+func (i *Interactive) cycleReasoningSetting() {
122+ item := i.reasoningSettingItem()
123+ if len(item.options) <= 1 {
124+ i.mu.Lock()
125+ i.statusOK = item.hint
126+ i.statusErr = ""
127+ i.mu.Unlock()
128+ i.invalidate()
129+ return
130+ }
131+ next := item.options[(item.choice+1)%len(item.options)]
132+ i.applyReasoningSetting(next.value)
133+}
121134 func (i *Interactive) openSettingsDialog() {
122135 detected := tui.DetectImageProtocol()
123136 imgEnabled := detected != tui.ImageProtocolNone
@@ -118,6 +118,19 @@ func (i *Interactive) reasoningSettingItem() settingsItem {
118 func (i *Interactive) openReasoningDialog() {118 func (i *Interactive) openReasoningDialog() {
119 i.settingsDialog.OpenDirectOption(i.reasoningSettingItem())119 i.settingsDialog.OpenDirectOption(i.reasoningSettingItem())
120 }120 }
121+func (i *Interactive) cycleReasoningSetting() {
122+ item := i.reasoningSettingItem()
123+ if len(item.options) <= 1 {
124+ i.mu.Lock()
125+ i.statusOK = item.hint
126+ i.statusErr = ""
127+ i.mu.Unlock()
128+ i.invalidate()
129+ return
130+ }
131+ next := item.options[(item.choice+1)%len(item.options)]
132+ i.applyReasoningSetting(next.value)
133+}
121 func (i *Interactive) openSettingsDialog() {134 func (i *Interactive) openSettingsDialog() {
122 detected := tui.DetectImageProtocol()135 detected := tui.DetectImageProtocol()
123 imgEnabled := detected != tui.ImageProtocolNone136 imgEnabled := detected != tui.ImageProtocolNone
M packages/tui/input.go +5 -0
@@ -44,6 +44,7 @@ const (
4444 KeyCtrlE
4545 KeyCtrlW
4646 KeyCtrlO
47+ KeyCtrlR
4748 KeyPaste
4849 KeyPasteClipboard
4950 KeyMouseWheelUp
@@ -185,6 +186,8 @@ func (r *Reader) Read() (Key, error) {
185186 return Key{Kind: KeyCtrlW}, nil
186187 case b == 0x0f:
187188 return Key{Kind: KeyCtrlO}, nil
189+ case b == 0x12:
190+ return Key{Kind: KeyCtrlR}, nil
188191 case b == 0x16:
189192 return Key{Kind: KeyPasteClipboard, Ctrl: true}, nil
190193 case b == '\r', b == '\n':
@@ -494,6 +497,8 @@ func keyFromModifiedCode(code, mod int) (Key, bool) {
494497 return Key{Kind: KeyCtrlW, Shift: shift, Alt: alt, Ctrl: true}, true
495498 case 'o', 'O':
496499 return Key{Kind: KeyCtrlO, Shift: shift, Alt: alt, Ctrl: true}, true
500+ case 'r', 'R':
501+ return Key{Kind: KeyCtrlR, Shift: shift, Alt: alt, Ctrl: true}, true
497502 case 'v', 'V':
498503 return Key{Kind: KeyPasteClipboard, Shift: shift, Alt: alt, Ctrl: true}, true
499504 }
@@ -44,6 +44,7 @@ const (
44 KeyCtrlE44 KeyCtrlE
45 KeyCtrlW45 KeyCtrlW
46 KeyCtrlO46 KeyCtrlO
47+ KeyCtrlR
47 KeyPaste48 KeyPaste
48 KeyPasteClipboard49 KeyPasteClipboard
49 KeyMouseWheelUp50 KeyMouseWheelUp
@@ -185,6 +186,8 @@ func (r *Reader) Read() (Key, error) {
185 return Key{Kind: KeyCtrlW}, nil186 return Key{Kind: KeyCtrlW}, nil
186 case b == 0x0f:187 case b == 0x0f:
187 return Key{Kind: KeyCtrlO}, nil188 return Key{Kind: KeyCtrlO}, nil
189+ case b == 0x12:
190+ return Key{Kind: KeyCtrlR}, nil
188 case b == 0x16:191 case b == 0x16:
189 return Key{Kind: KeyPasteClipboard, Ctrl: true}, nil192 return Key{Kind: KeyPasteClipboard, Ctrl: true}, nil
190 case b == '\r', b == '\n':193 case b == '\r', b == '\n':
@@ -494,6 +497,8 @@ func keyFromModifiedCode(code, mod int) (Key, bool) {
494 return Key{Kind: KeyCtrlW, Shift: shift, Alt: alt, Ctrl: true}, true497 return Key{Kind: KeyCtrlW, Shift: shift, Alt: alt, Ctrl: true}, true
495 case 'o', 'O':498 case 'o', 'O':
496 return Key{Kind: KeyCtrlO, Shift: shift, Alt: alt, Ctrl: true}, true499 return Key{Kind: KeyCtrlO, Shift: shift, Alt: alt, Ctrl: true}, true
500+ case 'r', 'R':
501+ return Key{Kind: KeyCtrlR, Shift: shift, Alt: alt, Ctrl: true}, true
497 case 'v', 'V':502 case 'v', 'V':
498 return Key{Kind: KeyPasteClipboard, Shift: shift, Alt: alt, Ctrl: true}, true503 return Key{Kind: KeyPasteClipboard, Shift: shift, Alt: alt, Ctrl: true}, true
499 }504 }
M packages/tui/input_test.go +15 -0
@@ -27,6 +27,21 @@ func TestReaderParsesCSIUCtrlC(t *testing.T) {
2727 }
2828 }
2929
30+func TestReaderParsesCtrlR(t *testing.T) {
31+ for name, sequence := range map[string]string{
32+ "raw": "\x12",
33+ "kitty protocol": "\x1b[114;5u",
34+ "modify other keys": "\x1b[27;5;114~",
35+ } {
36+ t.Run(name, func(t *testing.T) {
37+ k := readKey(t, sequence)
38+ if k.Kind != KeyCtrlR {
39+ t.Fatalf("Read kind=%v, want ctrl+r", k.Kind)
40+ }
41+ })
42+ }
43+}
44+
3045 func TestReaderParsesCSIUCtrlNumber(t *testing.T) {
3146 k := readKey(t, "\x1b[49;5u")
3247 if k.Kind != KeyRune || k.Rune != '1' || !k.Ctrl {
@@ -27,6 +27,21 @@ func TestReaderParsesCSIUCtrlC(t *testing.T) {
27 }27 }
28 }28 }
29 29
30+func TestReaderParsesCtrlR(t *testing.T) {
31+ for name, sequence := range map[string]string{
32+ "raw": "\x12",
33+ "kitty protocol": "\x1b[114;5u",
34+ "modify other keys": "\x1b[27;5;114~",
35+ } {
36+ t.Run(name, func(t *testing.T) {
37+ k := readKey(t, sequence)
38+ if k.Kind != KeyCtrlR {
39+ t.Fatalf("Read kind=%v, want ctrl+r", k.Kind)
40+ }
41+ })
42+ }
43+}
44+
30 func TestReaderParsesCSIUCtrlNumber(t *testing.T) {45 func TestReaderParsesCSIUCtrlNumber(t *testing.T) {
31 k := readKey(t, "\x1b[49;5u")46 k := readKey(t, "\x1b[49;5u")
32 if k.Kind != KeyRune || k.Rune != '1' || !k.Ctrl {47 if k.Kind != KeyRune || k.Rune != '1' || !k.Ctrl {

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