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 Unverified
B5690EEEBB952194The 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 | |||
| 450 | 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 | 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 | 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 | 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 | 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 | 925 | | `ctrl+l` | Redraw the screen. | | |
| 926 | 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 | 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 | 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 | 930 | | `@` | Open the file picker. Browse files and directories in the working directory. | | |
| 930 | 931 | ||
| @@ -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{ | |||
| 22 | 22 | {"alt+← / alt+→", "jump one word back / forward"}, | |
| 23 | 23 | {"ctrl+l", "redraw the screen"}, | |
| 24 | 24 | {"ctrl+o", "expand / collapse long tool results"}, | |
| 25 | + | {"ctrl+r", "cycle reasoning levels for the active model"}, | |
| 25 | 26 | {"pgup / pgdn", "scroll the chat one page up / down"}, | |
| 26 | 27 | {"up / down", "move in input, browse prompt history at top edge, scroll chat when empty"}, | |
| 27 | 28 | } | |
| @@ -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) { | |||
| 647 | 647 | case tui.KeyCtrlO: | |
| 648 | 648 | i.toggleToolExpansion() | |
| 649 | 649 | return false | |
| 650 | + | case tui.KeyCtrlR: | |
| 651 | + | i.cycleReasoningSetting() | |
| 652 | + | return false | |
| 650 | 653 | case tui.KeyPageUp: | |
| 651 | 654 | i.scrollBy(+i.chatPage()) | |
| 652 | 655 | 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 false | 649 | 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 false | 655 | return false | ||
M packages/agent/modes/interactive_model_test.go +50 -0
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | "strings" | |
| 7 | 7 | "testing" | |
| 8 | 8 | ||
| 9 | + | "github.com/bnema/zut/packages/core" | |
| 9 | 10 | "github.com/bnema/zut/packages/provider" | |
| 10 | 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 | 100 | func TestReasoningCommandOpensDirectSelector(t *testing.T) { | |
| 51 | 101 | i := &Interactive{ | |
| 52 | 102 | 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 { | |||
| 118 | 118 | func (i *Interactive) openReasoningDialog() { | |
| 119 | 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 | 134 | func (i *Interactive) openSettingsDialog() { | |
| 122 | 135 | detected := tui.DetectImageProtocol() | |
| 123 | 136 | 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.ImageProtocolNone | 136 | imgEnabled := detected != tui.ImageProtocolNone | ||
M packages/tui/input.go +5 -0
| @@ -44,6 +44,7 @@ const ( | |||
| 44 | 44 | KeyCtrlE | |
| 45 | 45 | KeyCtrlW | |
| 46 | 46 | KeyCtrlO | |
| 47 | + | KeyCtrlR | |
| 47 | 48 | KeyPaste | |
| 48 | 49 | KeyPasteClipboard | |
| 49 | 50 | KeyMouseWheelUp | |
| @@ -185,6 +186,8 @@ func (r *Reader) Read() (Key, error) { | |||
| 185 | 186 | return Key{Kind: KeyCtrlW}, nil | |
| 186 | 187 | case b == 0x0f: | |
| 187 | 188 | return Key{Kind: KeyCtrlO}, nil | |
| 189 | + | case b == 0x12: | |
| 190 | + | return Key{Kind: KeyCtrlR}, nil | |
| 188 | 191 | case b == 0x16: | |
| 189 | 192 | return Key{Kind: KeyPasteClipboard, Ctrl: true}, nil | |
| 190 | 193 | case b == '\r', b == '\n': | |
| @@ -494,6 +497,8 @@ func keyFromModifiedCode(code, mod int) (Key, bool) { | |||
| 494 | 497 | return Key{Kind: KeyCtrlW, Shift: shift, Alt: alt, Ctrl: true}, true | |
| 495 | 498 | case 'o', 'O': | |
| 496 | 499 | 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 | 502 | case 'v', 'V': | |
| 498 | 503 | return Key{Kind: KeyPasteClipboard, Shift: shift, Alt: alt, Ctrl: true}, true | |
| 499 | 504 | } | |
| @@ -44,6 +44,7 @@ const ( | |||||
| 44 | KeyCtrlE | 44 | KeyCtrlE | ||
| 45 | KeyCtrlW | 45 | KeyCtrlW | ||
| 46 | KeyCtrlO | 46 | KeyCtrlO | ||
| 47 | + | KeyCtrlR | |||
| 47 | KeyPaste | 48 | KeyPaste | ||
| 48 | KeyPasteClipboard | 49 | KeyPasteClipboard | ||
| 49 | KeyMouseWheelUp | 50 | KeyMouseWheelUp | ||
| @@ -185,6 +186,8 @@ func (r *Reader) Read() (Key, error) { | |||||
| 185 | return Key{Kind: KeyCtrlW}, nil | 186 | return Key{Kind: KeyCtrlW}, nil | ||
| 186 | case b == 0x0f: | 187 | case b == 0x0f: | ||
| 187 | return Key{Kind: KeyCtrlO}, nil | 188 | 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}, nil | 192 | 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}, true | 497 | 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}, true | 499 | 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}, true | 503 | 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) { | |||
| 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 | 45 | func TestReaderParsesCSIUCtrlNumber(t *testing.T) { | |
| 31 | 46 | k := readKey(t, "\x1b[49;5u") | |
| 32 | 47 | 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 { | ||