From fd8fa8ec861bc424ed470a2f3255acddbec6b5b3 Mon Sep 17 00:00:00 2001 From: Leonardo Faoro Date: Thu, 16 Jul 2026 01:01:28 +0300 Subject: [PATCH 1/3] feat(tui): Ctrl+X leader chords and emacs menu navigation Add Ctrl+X leader shortcuts for common slash commands (with Ctrl+X ? chord map), and Ctrl+P/N previous/next selection in modals and pickers. --- CHANGELOG.md | 9 + README.md | 2 + internal/tui/keybinding_help.go | 5 +- internal/tui/keybinding_help_test.go | 1 + internal/tui/leader.go | 172 ++++++++++++++ internal/tui/leader_test.go | 338 +++++++++++++++++++++++++++ internal/tui/modal_selection.go | 62 +++++ internal/tui/modal_selection_test.go | 189 +++++++++++++++ internal/tui/model.go | 224 +++++++++--------- internal/tui/onboarding.go | 4 +- internal/tui/sidebar.go | 2 +- 11 files changed, 884 insertions(+), 124 deletions(-) create mode 100644 internal/tui/leader.go create mode 100644 internal/tui/leader_test.go create mode 100644 internal/tui/modal_selection.go create mode 100644 internal/tui/modal_selection_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 3755047e9..10fd34e7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -127,6 +127,15 @@ tagged. Until then, source builds report the version `dev`. ## [Unreleased] ### Added +- **TUI keyboard shortcuts for common slash commands:** `Ctrl+X` is a leader key; a follow-up letter + runs the matching builtin command without clearing the composer draft (e.g. `Ctrl+X` `m` → `/model`, + `p` → `/provider`, `r` → `/resume`, `c` → `/clear`, `t` → `/tools`, `e` → `/edit`, plus plan, voice, + STT model, context, stop, image, rewind, and retry). `Ctrl+X` `?` opens a bordered chord-map modal + (same chrome as the `?` keyboard-shortcut overlay) listing every wired mapping; Esc / second + `Ctrl+X` / timeout cancel a pending leader chord. +- **Emacs-style menu navigation:** `Ctrl+P` / `Ctrl+N` move previous / next in pickers, slash/file + suggestions, permission prompts, ask-user option lists, and provider/MCP wizard lists (same as ↑/↓). + Idle `Ctrl+P` still toggles the plan panel when no menu is open. - `SECURITY.md` with a private vulnerability-reporting path, `CODE_OF_CONDUCT.md`, this changelog, and GitHub issue/PR templates. - Interactive `/theme` picker: bare `/theme` opens a popup that live-previews each palette as you move diff --git a/README.md b/README.md index 15a881fbb..ad36f596c 100644 --- a/README.md +++ b/README.md @@ -184,6 +184,8 @@ Useful controls: |---|---| | `Enter` | send the prompt | | `/` | open slash-command suggestions | +| `Ctrl+X` then letter | common slash commands (e.g. `m` → `/model`; `Ctrl+X` `?` for full list) | +| `Ctrl+P` / `Ctrl+N` | previous / next item in menus (arrows still work) | | `Shift+Tab` | cycle permission mode | | `Ctrl+B` | show/hide the sidebar | | `Ctrl+C` | cancel or exit | diff --git a/internal/tui/keybinding_help.go b/internal/tui/keybinding_help.go index 401ff7854..c8075b1c8 100644 --- a/internal/tui/keybinding_help.go +++ b/internal/tui/keybinding_help.go @@ -39,6 +39,8 @@ func (m model) buildKeybindingGroups() []keybindingGroup { {"Shift+Enter / Alt+Enter", "insert a newline (multi-line compose)"}, {"Esc (\u00d72)", "cancel the run / dismiss a popup / clear the input"}, {"Ctrl+C", "cancel the run, then quit"}, + {"Ctrl+X then letter", "common slash commands (m=/model, p=/provider, r=/resume, \u2026)"}, + {"Ctrl+X ?", "list every Ctrl+X slash shortcut"}, {"?", "show this help (on an empty input)"}, }, }, @@ -47,7 +49,7 @@ func (m model) buildKeybindingGroups() []keybindingGroup { bindings: []keybinding{ {labelOr(m.keyBindings.cycleReasoning, "Ctrl+T"), "cycle reasoning effort (auto \u2192 low \u2192 medium \u2192 high)"}, {"Shift+Tab", "cycle permission mode (auto \u2194 ask)"}, - {labelOr(m.keyBindings.togglePlan, "Ctrl+P"), "expand / collapse the plan panel"}, + {labelOr(m.keyBindings.togglePlan, "Ctrl+P"), "expand / collapse the plan panel (when no menu is open)"}, }, }, { @@ -55,6 +57,7 @@ func (m model) buildKeybindingGroups() []keybindingGroup { bindings: []keybinding{ {"PgUp / PgDn", "scroll the transcript by a page"}, {"\u2191 / \u2193", "scroll, or move within a popup / multi-line input"}, + {"Ctrl+P / Ctrl+N", "previous / next item in menus (emacs-style)"}, {labelOr(m.keyBindings.toggleDetailed, "Ctrl+O"), "toggle the detailed (full-screen) transcript"}, {labelOr(m.keyBindings.toggleSidebar, "Ctrl+B"), "hide / show the right context sidebar"}, {labelOr(m.keyBindings.toggleMouse, "Ctrl+E"), "release the mouse to drag-select & copy text"}, diff --git a/internal/tui/keybinding_help_test.go b/internal/tui/keybinding_help_test.go index 763fa9a38..2e5d4ec10 100644 --- a/internal/tui/keybinding_help_test.go +++ b/internal/tui/keybinding_help_test.go @@ -79,6 +79,7 @@ func TestHelpOverlayViewRendersGroupsAndKeys(t *testing.T) { "Ctrl+T", "cycle reasoning effort", "Shift+Tab", "Ctrl+P", "Ctrl+O", "drill into its sub-session", + "Ctrl+X then letter", "/model", keybindingHelpFooter, } { if !strings.Contains(view, want) { diff --git a/internal/tui/leader.go b/internal/tui/leader.go new file mode 100644 index 000000000..0c5a9d063 --- /dev/null +++ b/internal/tui/leader.go @@ -0,0 +1,172 @@ +package tui + +import ( + "time" + "unicode" + + tea "charm.land/bubbletea/v2" +) + +// leaderTimeout is how long Ctrl+X waits for a follow-up key before the chord +// cancels itself. Short enough to not feel sticky; long enough to find the +// second key without racing. +const leaderTimeout = 2 * time.Second + +// leaderCommandByKey maps the second key of a Ctrl+X chord to a builtin slash +// command. Case-sensitive so m/M and p/P stay distinct. Only bare commands (no +// args) — same as typing the slash and pressing Enter with an empty argument. +var leaderCommandByKey = map[rune]string{ + 'm': "/model", + 'p': "/provider", + 'P': "/plan", + 'M': "/stt-model", + 'v': "/voice", + 'c': "/clear", + 'C': "/context", + 's': "/stop", + 'i': "/image", + 'r': "/resume", + 'u': "/rewind", + 't': "/tools", + 'e': "/edit", + 'R': "/retry", +} + +// leaderExpiredMsg clears leader-pending when the follow-up window elapses. +// seq must match m.leaderSeq or the tick is stale (a later Ctrl+X re-armed). +type leaderExpiredMsg struct { + seq int +} + +func (m model) canArmLeader() bool { + return m.noBlockingModal() && !m.transcriptDetailed && !m.subchat.active && !m.helpOverlay && !m.leaderHelpOverlay +} + +func (m model) armLeader() (model, tea.Cmd) { + m.leaderPending = true + m.leaderSeq++ + seq := m.leaderSeq + return m, tea.Tick(leaderTimeout, func(time.Time) tea.Msg { + return leaderExpiredMsg{seq: seq} + }) +} + +func (m model) clearLeader() model { + m.leaderPending = false + // Bump seq so an in-flight timeout tick becomes a no-op. + m.leaderSeq++ + return m +} + +// handleLeaderKey consumes the key while leader-pending is armed. Never inserts +// into the composer; either runs a mapped slash command, opens the chord map +// (?), or cancels the chord. Ctrl+C is handled before this is called so +// exit/cancel still works. +func (m model) handleLeaderKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + // Second Ctrl+X cancels (does not re-arm). + if keyCtrl(msg, 'x') { + return m.clearLeader(), nil + } + if keyIs(msg, tea.KeyEsc) { + return m.clearLeader(), nil + } + // Ctrl+X ? opens the full leader-chord map (discoverability for every wired + // slash shortcut). Not a letter binding, so handle before leaderSecondKey. + if keyText(msg) == "?" && !keyAlt(msg) && !keyHasMod(msg, tea.ModCtrl) { + m = m.clearLeader() + m.leaderHelpOverlay = true + return m, nil + } + key, ok := leaderSecondKey(msg) + if !ok { + return m.clearLeader(), nil + } + slash, mapped := leaderCommandByKey[key] + if !mapped { + return m.clearLeader(), nil + } + m = m.clearLeader() + return m.executeSlash(slash) +} + +// leaderHelpBindings is the stable, display-ordered table of every wired +// Ctrl+X chord. Keep in sync with leaderCommandByKey. +func leaderHelpBindings() []keybinding { + return []keybinding{ + {"Ctrl+X m", "open /model"}, + {"Ctrl+X p", "open /provider"}, + {"Ctrl+X P", "run /plan"}, + {"Ctrl+X M", "open /stt-model"}, + {"Ctrl+X v", "toggle /voice"}, + {"Ctrl+X c", "run /clear"}, + {"Ctrl+X C", "run /context"}, + {"Ctrl+X s", "run /stop"}, + {"Ctrl+X i", "run /image"}, + {"Ctrl+X r", "open /resume"}, + {"Ctrl+X u", "run /rewind"}, + {"Ctrl+X t", "run /tools"}, + {"Ctrl+X e", "run /edit"}, + {"Ctrl+X R", "run /retry"}, + {"Ctrl+X ?", "show this list"}, + } +} + +const leaderHelpFooter = "? or Esc to close \u00b7 letter after Ctrl+X runs the command" + +// renderLeaderHelpLines builds the body of the Ctrl+X ? overlay — same shape as +// renderKeybindingHelpLines (group title + key rows + footer) so the two modals +// share layout and column alignment. +func renderLeaderHelpLines(innerWidth int) []string { + groups := []keybindingGroup{{ + title: "Slash commands", + bindings: leaderHelpBindings(), + }} + keyColumn := keybindingKeyColumnWidth(groups) + lines := make([]string, 0, 32) + for index, group := range groups { + if index > 0 { + lines = append(lines, "") + } + lines = append(lines, zeroTheme.accent.Render(group.title)) + for _, binding := range group.bindings { + lines = append(lines, formatKeybindingLine(binding, keyColumn, innerWidth)) + } + } + lines = append(lines, "") + lines = append(lines, zeroTheme.faint.Render(leaderHelpFooter)) + return lines +} + +// renderLeaderHelpOverlay frames the Ctrl+X ? chord map exactly like the +// general ? keyboard-shortcut overlay: same width helper, border style +// (zeroTheme.line), panel fill, and centered block. +func (m model) renderLeaderHelpOverlay(width int) string { + overlayWidth := keybindingHelpOverlayWidth(width) + lines := renderLeaderHelpLines(overlayWidth - 4) + block := styledBlockFillTitle(overlayWidth, "Ctrl+X Shortcuts", lines, zeroTheme.line, zeroTheme.panel) + return centerRenderedBlock(block, width) +} + +// leaderSecondKey extracts the letter for a leader follow-up. Prefers printable +// text ("m" / "M"); falls back to code+Shift so test harnesses and terminals +// that report only ModShift still work. +func leaderSecondKey(msg tea.KeyMsg) (rune, bool) { + if keyHasMod(msg, tea.ModCtrl) || keyAlt(msg) { + return 0, false + } + text := keyText(msg) + if runes := []rune(text); len(runes) == 1 && unicode.IsLetter(runes[0]) { + return runes[0], true + } + code := keyCode(msg) + if code >= 'a' && code <= 'z' { + if keyShift(msg) { + return code - ('a' - 'A'), true + } + return code, true + } + if code >= 'A' && code <= 'Z' { + return code, true + } + return 0, false +} diff --git a/internal/tui/leader_test.go b/internal/tui/leader_test.go new file mode 100644 index 000000000..3fe9e96cd --- /dev/null +++ b/internal/tui/leader_test.go @@ -0,0 +1,338 @@ +package tui + +import ( + "context" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" +) + +func TestLeaderArmsOnCtrlX(t *testing.T) { + m := newModel(context.Background(), Options{ModelName: "gpt-4o"}) + updated, cmd := m.Update(testKeyCtrl('x')) + next := updated.(model) + if !next.leaderPending { + t.Fatal("Ctrl+X should arm leader-pending") + } + if next.composerValue() != "" { + t.Fatalf("Ctrl+X must not type into the composer, got %q", next.composerValue()) + } + if cmd == nil { + t.Fatal("arming leader should schedule a timeout tick") + } +} + +func TestLeaderModelOpensPicker(t *testing.T) { + m := newModel(context.Background(), Options{ + ModelName: "gpt-4o", + ProviderName: "openai", + }) + updated, _ := m.Update(testKeyCtrl('x')) + m = updated.(model) + updated, _ = m.Update(testKeyText("m")) + next := updated.(model) + if next.leaderPending { + t.Fatal("mapped second key should clear leader-pending") + } + if next.picker == nil || next.picker.kind != pickerModel { + t.Fatalf("Ctrl+X m should open the model picker, picker=%#v", next.picker) + } + if next.composerValue() != "" { + t.Fatalf("leader chord must not leave /model in the composer, got %q", next.composerValue()) + } +} + +func TestLeaderPreservesComposerDraft(t *testing.T) { + m := newModel(context.Background(), Options{ + ModelName: "gpt-4o", + ProviderName: "openai", + }) + m = typeRunes(t, m, "draft text") + updated, _ := m.Update(testKeyCtrl('x')) + m = updated.(model) + updated, _ = m.Update(testKeyText("m")) + next := updated.(model) + if got := next.composerValue(); got != "draft text" { + t.Fatalf("leader /model must preserve composer draft, got %q", got) + } + if next.picker == nil || next.picker.kind != pickerModel { + t.Fatal("expected model picker after Ctrl+X m") + } +} + +func TestLeaderTimeoutClears(t *testing.T) { + m := newModel(context.Background(), Options{ModelName: "gpt-4o"}) + updated, _ := m.Update(testKeyCtrl('x')) + m = updated.(model) + if !m.leaderPending { + t.Fatal("expected leader pending after Ctrl+X") + } + seq := m.leaderSeq + updated, _ = m.Update(leaderExpiredMsg{seq: seq}) + next := updated.(model) + if next.leaderPending { + t.Fatal("matching timeout tick should clear leader-pending") + } + // Stale tick must not clear a re-armed leader. + updated, _ = next.Update(testKeyCtrl('x')) + next = updated.(model) + updated, _ = next.Update(leaderExpiredMsg{seq: seq}) + next = updated.(model) + if !next.leaderPending { + t.Fatal("stale timeout must not clear a re-armed leader") + } +} + +func TestLeaderEscCancels(t *testing.T) { + m := newModel(context.Background(), Options{ModelName: "gpt-4o"}) + m = typeRunes(t, m, "keep me") + updated, _ := m.Update(testKeyCtrl('x')) + m = updated.(model) + updated, _ = m.Update(testKey(tea.KeyEsc)) + next := updated.(model) + if next.leaderPending { + t.Fatal("Esc should clear leader-pending") + } + if got := next.composerValue(); got != "keep me" { + t.Fatalf("Esc cancel must not clear composer, got %q", got) + } +} + +func TestLeaderSecondCtrlXCancels(t *testing.T) { + m := newModel(context.Background(), Options{ModelName: "gpt-4o"}) + updated, _ := m.Update(testKeyCtrl('x')) + m = updated.(model) + updated, _ = m.Update(testKeyCtrl('x')) + next := updated.(model) + if next.leaderPending { + t.Fatal("second Ctrl+X should cancel, not re-arm") + } +} + +func TestLeaderUnknownKeyClearsWithoutCommand(t *testing.T) { + m := newModel(context.Background(), Options{ModelName: "gpt-4o"}) + before := len(m.transcript) + updated, _ := m.Update(testKeyCtrl('x')) + m = updated.(model) + updated, _ = m.Update(testKeyText("z")) + next := updated.(model) + if next.leaderPending { + t.Fatal("unknown second key should clear leader-pending") + } + if next.picker != nil { + t.Fatal("unknown second key must not open a picker") + } + if next.composerValue() != "" { + t.Fatalf("unknown key must not type into composer, got %q", next.composerValue()) + } + if len(next.transcript) != before { + t.Fatalf("unknown key should not append transcript rows, before=%d after=%d", before, len(next.transcript)) + } +} + +func TestLeaderDoesNotInsertText(t *testing.T) { + m := newModel(context.Background(), Options{ModelName: "gpt-4o"}) + updated, _ := m.Update(testKeyCtrl('x')) + m = updated.(model) + updated, _ = m.Update(testKeyText("q")) + next := updated.(model) + if next.composerValue() != "" { + t.Fatalf("follow-up key must never appear in composer, got %q", next.composerValue()) + } +} + +func TestLeaderIgnoredWhenPickerOpen(t *testing.T) { + m := newModel(context.Background(), Options{ + ModelName: "gpt-4o", + ProviderName: "openai", + }) + // Open model picker the normal way. + m.input.SetValue("/model") + updated, _ := m.handleSubmit() + m = updated.(model) + if m.picker == nil { + t.Fatal("setup: expected /model to open a picker") + } + updated, _ = m.Update(testKeyCtrl('x')) + next := updated.(model) + if next.leaderPending { + t.Fatal("Ctrl+X must not arm leader while a picker is open") + } + // Printable 'm' should filter the picker, not dispatch another /model. + updated, _ = next.Update(testKeyText("m")) + next = updated.(model) + if next.picker == nil { + t.Fatal("picker should still be open") + } + if next.leaderPending { + t.Fatal("picker keystrokes must not arm leader") + } +} + +func TestLeaderToolsRunsSlash(t *testing.T) { + m := newModel(context.Background(), Options{ModelName: "gpt-4o"}) + updated, _ := m.Update(testKeyCtrl('x')) + m = updated.(model) + updated, _ = m.Update(testKeyText("t")) + next := updated.(model) + if next.leaderPending { + t.Fatal("expected leader cleared after Ctrl+X t") + } + if !transcriptContains(next.transcript, "Tools") && !transcriptContains(next.transcript, "tool") { + // toolsText content varies; at least something was appended. + if len(next.transcript) == 0 { + t.Fatal("Ctrl+X t should run /tools and append output") + } + } +} + +func TestLeaderHelpOverlayListsChords(t *testing.T) { + m := newModel(context.Background(), Options{ModelName: "gpt-4o"}) + groups := m.buildKeybindingGroups() + var found bool + for _, g := range groups { + for _, b := range g.bindings { + if strings.Contains(b.keys, "Ctrl+X") && strings.Contains(b.desc, "/model") { + found = true + } + } + } + if !found { + t.Fatal("buildKeybindingGroups must document Ctrl+X slash-command chords") + } +} + +func TestLeaderQuestionMarkOpensChordMap(t *testing.T) { + m := newModel(context.Background(), Options{ModelName: "gpt-4o"}) + m.width = 100 + m.height = 50 + m.altScreen = true + updated, _ := m.Update(testKeyCtrl('x')) + m = updated.(model) + if !m.leaderPending { + t.Fatal("setup: Ctrl+X should arm leader") + } + updated, _ = m.Update(testKeyText("?")) + next := updated.(model) + if next.leaderPending { + t.Fatal("Ctrl+X ? should clear leader-pending") + } + if !next.leaderHelpOverlay { + t.Fatal("Ctrl+X ? should open the leader chord map") + } + if next.composerValue() != "" { + t.Fatalf("? must not type into composer, got %q", next.composerValue()) + } + view := plainRender(t, next.View()) + for _, want := range []string{ + "Ctrl+X Shortcuts", + "Slash commands", + "Ctrl+X m", "open /model", + "Ctrl+X p", "open /provider", + "Ctrl+X R", "run /retry", + "╭", "╰", "│", // same box border chrome as the ? keyboard-shortcut modal + } { + if !strings.Contains(view, want) { + t.Fatalf("leader help missing %q, got:\n%s", want, view) + } + } +} + +func TestLeaderHelpMatchesKeybindingHelpChrome(t *testing.T) { + // Both overlays must use the same frame builder (titled box + panel fill). + m := newModel(context.Background(), Options{ModelName: "gpt-4o"}) + m.width = 100 + help := m.renderKeybindingHelpOverlay(100) + leader := m.renderLeaderHelpOverlay(100) + for _, block := range []struct { + name string + s string + }{ + {"help", help}, + {"leader", leader}, + } { + plain := ansiPattern.ReplaceAllString(block.s, "") + for _, want := range []string{"╭", "╰", "│", "╮", "╯"} { + if !strings.Contains(plain, want) { + t.Fatalf("%s overlay missing border %q:\n%s", block.name, want, plain) + } + } + } + if !strings.Contains(ansiPattern.ReplaceAllString(help, ""), "Keyboard Shortcuts") { + t.Fatal("help overlay should keep Keyboard Shortcuts title") + } + if !strings.Contains(ansiPattern.ReplaceAllString(leader, ""), "Ctrl+X Shortcuts") { + t.Fatal("leader overlay should use Ctrl+X Shortcuts title in the border") + } +} + +func TestLeaderHelpOverlayClosesOnEsc(t *testing.T) { + m := newModel(context.Background(), Options{ModelName: "gpt-4o"}) + m.leaderHelpOverlay = true + updated, _ := m.Update(testKey(tea.KeyEsc)) + next := updated.(model) + if next.leaderHelpOverlay { + t.Fatal("Esc should close the leader help overlay") + } +} + +func TestLeaderHelpBindingsCoverEveryMapEntry(t *testing.T) { + listed := map[string]bool{} + for _, b := range leaderHelpBindings() { + // "Ctrl+X m" → last field is the letter (skip the "?" meta row for map check). + parts := strings.Fields(b.keys) + if len(parts) != 2 || parts[0] != "Ctrl+X" { + continue + } + letter := parts[1] + if letter == "?" { + continue + } + runes := []rune(letter) + if len(runes) != 1 { + t.Fatalf("unexpected key label %q", b.keys) + } + listed[string(runes[0])] = true + if _, ok := leaderCommandByKey[runes[0]]; !ok { + t.Fatalf("help lists %q but leaderCommandByKey has no entry", b.keys) + } + } + for key := range leaderCommandByKey { + if !listed[string(key)] { + t.Fatalf("leaderCommandByKey has %q but help table omits it", string(key)) + } + } +} + +func TestLeaderSecondKeyShiftCapital(t *testing.T) { + // Shift+p should map to /plan (capital P), not /provider. + m := newModel(context.Background(), Options{ModelName: "gpt-4o"}) + updated, _ := m.Update(testKeyCtrl('x')) + m = updated.(model) + updated, _ = m.Update(testKeyShift('p')) + next := updated.(model) + if next.leaderPending { + t.Fatal("Shift+p should resolve the leader chord") + } + if next.picker != nil { + t.Fatal("/plan should not open a picker") + } + if !transcriptContains(next.transcript, "Plan") && !transcriptContains(next.transcript, "plan") { + // planText wording may vary; require some transcript growth from /plan. + if len(next.transcript) == 0 { + t.Fatal("Ctrl+X P should run /plan") + } + } +} + +func TestLeaderSecondKeyFromTextCapital(t *testing.T) { + key, ok := leaderSecondKey(testKeyText("M")) + if !ok || key != 'M' { + t.Fatalf("leaderSecondKey(M) = %q,%v, want M,true", key, ok) + } + key, ok = leaderSecondKey(testKeyText("m")) + if !ok || key != 'm' { + t.Fatalf("leaderSecondKey(m) = %q,%v, want m,true", key, ok) + } +} diff --git a/internal/tui/modal_selection.go b/internal/tui/modal_selection.go new file mode 100644 index 000000000..ef49bcc59 --- /dev/null +++ b/internal/tui/modal_selection.go @@ -0,0 +1,62 @@ +package tui + +import tea "charm.land/bubbletea/v2" + +// moveModalSelection moves the highlight on an open selection surface by delta +// (−1 previous / +1 next), matching arrow-key behavior for that surface. +// handled is false when no list/selector modal owns the keys (caller may fall +// through to plan toggle, composer, etc.). +// +// Surfaces: permission options, ask_user option lists, provider/MCP wizards and +// managers, command pickers, slash/file autocomplete. Does not scroll the +// transcript or drive composer history — those stay arrow-only. +func (m model) moveModalSelection(delta int) (model, tea.Cmd, bool) { + if delta == 0 { + return m, nil, false + } + if m.pendingPermission != nil { + return m.movePermissionCursor(delta), nil, true + } + if m.pendingAskUser != nil { + // moveAskUserCursor is a no-op in free-text mode (parity with arrows). + return m.moveAskUserCursor(delta), nil, true + } + if m.providerWizard != nil { + m.burstCount = 0 + next, cmd := m.handleProviderWizardKey(syntheticArrowKey(delta)) + return next, cmd, true + } + if m.mcpAddWizard != nil { + m.burstCount = 0 + next, cmd := m.handleMCPAddWizardKey(syntheticArrowKey(delta)) + return next, cmd, true + } + if m.mcpManager != nil { + m.burstCount = 0 + next, cmd := m.handleMCPManagerKey(syntheticArrowKey(delta)) + return next, cmd, true + } + if m.picker != nil { + if m.modelPickerIsLoading() { + return m, nil, true + } + m.pickerMoved(delta) + return m, nil, true + } + if m.suggestionsActive() { + m.moveSuggestion(delta) + return m, nil, true + } + return m, nil, false +} + +// syntheticArrowKey builds a KeyUp (delta < 0) or KeyDown (delta > 0) message so +// wizard/manager handlers that only match arrow keys can share one code path +// with Ctrl+P / Ctrl+N. +func syntheticArrowKey(delta int) tea.KeyPressMsg { + code := tea.KeyDown + if delta < 0 { + code = tea.KeyUp + } + return tea.KeyPressMsg(tea.Key{Code: code}) +} diff --git a/internal/tui/modal_selection_test.go b/internal/tui/modal_selection_test.go new file mode 100644 index 000000000..eafc02bd2 --- /dev/null +++ b/internal/tui/modal_selection_test.go @@ -0,0 +1,189 @@ +package tui + +import ( + "context" + "testing" + + tea "charm.land/bubbletea/v2" + + "github.com/Gitlawb/zero/internal/agent" +) + +func TestCtrlPNMovesModelPicker(t *testing.T) { + m := newModel(context.Background(), Options{ + ModelName: "gpt-4o", + ProviderName: "openai", + Provider: &fakeProvider{}, + }) + m.input.SetValue("/model") + updated, _ := m.handleSubmit() + m = updated.(model) + if m.picker == nil { + t.Fatal("setup: expected model picker") + } + if len(m.picker.items) < 2 { + t.Skip("model picker has fewer than 2 items; cannot assert movement") + } + m.picker.selected = 0 + updated, _ = m.Update(testKey(tea.KeyDown)) + viaArrow := updated.(model).picker.selected + m.picker.selected = 0 + updated, _ = m.Update(testKeyCtrl('n')) + m = updated.(model) + if m.picker == nil { + t.Fatal("picker should stay open") + } + if m.picker.selected != viaArrow { + t.Fatalf("Ctrl+N selection = %d, KeyDown selection = %d", m.picker.selected, viaArrow) + } + updated, _ = m.Update(testKeyCtrl('p')) + m = updated.(model) + if m.picker.selected != 0 { + t.Fatalf("Ctrl+P should return to 0, got %d", m.picker.selected) + } +} + +func TestCtrlPNMovesSuggestions(t *testing.T) { + m := newModel(context.Background(), Options{}) + m = typeRunes(t, m, "/s") + if !m.suggestionsActive() || len(m.suggestions) < 2 { + t.Fatalf("setup: need multiple suggestions, got %d", len(m.suggestions)) + } + updated, _ := m.Update(testKeyCtrl('n')) + m = updated.(model) + if m.suggestionIdx != 1 { + t.Fatalf("Ctrl+N should select index 1, got %d", m.suggestionIdx) + } + if got := m.composerValue(); got != "/s" { + t.Fatalf("Ctrl+N must not insert text, composer = %q", got) + } + updated, _ = m.Update(testKeyCtrl('p')) + m = updated.(model) + if m.suggestionIdx != 0 { + t.Fatalf("Ctrl+P should return to index 0, got %d", m.suggestionIdx) + } +} + +func TestCtrlPNMovesPermissionOptions(t *testing.T) { + m := pendingPermissionModel(t, func(agent.PermissionDecision) {}) + if m.pendingPermission == nil { + t.Fatal("setup: expected permission prompt") + } + start := m.pendingPermission.cursor + updated, _ := m.Update(testKeyCtrl('n')) + m = updated.(model) + if m.pendingPermission == nil { + t.Fatal("permission prompt should stay open") + } + if m.pendingPermission.cursor == start { + // Wrap or multi-option: try another N if single step no-op is impossible. + if len(permissionOptions(m.pendingPermission.request)) > 1 { + t.Fatalf("Ctrl+N should move permission cursor from %d", start) + } + } + mid := m.pendingPermission.cursor + updated, _ = m.Update(testKeyCtrl('p')) + m = updated.(model) + if m.pendingPermission.cursor != start && mid != start { + if m.pendingPermission.cursor == mid { + t.Fatalf("Ctrl+P should move permission cursor from %d", mid) + } + } +} + +func TestCtrlPTogglesPlanWhenNoModal(t *testing.T) { + m := newModel(context.Background(), Options{ModelName: "gpt-4o"}) + m.plan = planPanelState{ + steps: []planStep{{content: "step one", status: "pending"}}, + } + if m.plan.isEmpty() { + t.Fatal("setup: plan should be non-empty") + } + initial := m.plan.expanded + updated, _ := m.Update(testKeyCtrl('p')) + next := updated.(model) + if next.plan.expanded == initial { + t.Fatal("Ctrl+P with no modal should toggle plan.expanded") + } +} + +func TestCtrlPDoesNotTogglePlanInPicker(t *testing.T) { + m := newModel(context.Background(), Options{ + ModelName: "gpt-4o", + ProviderName: "openai", + Provider: &fakeProvider{}, + }) + m.plan = planPanelState{ + steps: []planStep{{content: "step one", status: "pending"}}, + expanded: true, + } + m.input.SetValue("/model") + updated, _ := m.handleSubmit() + m = updated.(model) + if m.picker == nil { + t.Fatal("setup: expected picker") + } + updated, _ = m.Update(testKeyCtrl('p')) + next := updated.(model) + if !next.plan.expanded { + t.Fatal("Ctrl+P in picker must not collapse the plan panel") + } + if next.picker == nil { + t.Fatal("picker should remain open") + } +} + +func TestCtrlNNoOpWithoutModal(t *testing.T) { + m := newModel(context.Background(), Options{ModelName: "gpt-4o"}) + m.plan = planPanelState{ + steps: []planStep{{content: "step one", status: "pending"}}, + expanded: false, + } + before := m.plan.expanded + updated, _ := m.Update(testKeyCtrl('n')) + next := updated.(model) + if next.picker != nil || next.suggestionsActive() { + t.Fatal("Ctrl+N idle must not open a modal") + } + if next.plan.expanded != before { + t.Fatal("Ctrl+N idle must not toggle the plan panel") + } + if next.composerValue() != "" { + t.Fatalf("Ctrl+N idle must not type into composer, got %q", next.composerValue()) + } +} + +func TestCtrlPNThemePickerPreview(t *testing.T) { + m := newModel(context.Background(), Options{ModelName: "gpt-4o"}) + m.input.SetValue("/theme") + updated, _ := m.handleSubmit() + m = updated.(model) + if m.picker == nil || m.picker.kind != pickerTheme { + t.Fatalf("setup: expected theme picker, got %#v", m.picker) + } + // Move with Ctrl+N — should not error; preview path is inside pickerMoved. + start := m.picker.selected + updated, _ = m.Update(testKeyCtrl('n')) + m = updated.(model) + if m.picker == nil { + t.Fatal("theme picker should stay open") + } + if len(m.picker.items) > 1 && m.picker.selected == start { + t.Fatalf("Ctrl+N should advance theme selection from %d", start) + } +} + +func TestMoveModalSelectionMatchesArrowsOnSuggestions(t *testing.T) { + m := newModel(context.Background(), Options{}) + m = typeRunes(t, m, "/") + if len(m.suggestions) < 2 { + t.Fatalf("need suggestions, got %d", len(m.suggestions)) + } + m.suggestionIdx = 0 + viaArrow, _ := m.Update(testKey(tea.KeyDown)) + m.suggestionIdx = 0 + viaCtrl, _ := m.Update(testKeyCtrl('n')) + if viaArrow.(model).suggestionIdx != viaCtrl.(model).suggestionIdx { + t.Fatalf("arrow idx=%d ctrl idx=%d", viaArrow.(model).suggestionIdx, viaCtrl.(model).suggestionIdx) + } +} diff --git a/internal/tui/model.go b/internal/tui/model.go index eceebfc2d..51c319b74 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -145,13 +145,19 @@ type model struct { // turnLatencySum / turnLatencyCount accumulate completed-run wall time so // /context can show a rolling average turn latency (the "is it slow?" signal). // Reset by /new. - turnLatencySum time.Duration - turnLatencyCount int - turnTTFTSum time.Duration - turnTTFTCount int - transcript []transcriptRow - transcriptDetailed bool - helpOverlay bool // the `?` keyboard-shortcut overlay is open + turnLatencySum time.Duration + turnLatencyCount int + turnTTFTSum time.Duration + turnTTFTCount int + transcript []transcriptRow + transcriptDetailed bool + helpOverlay bool // the `?` keyboard-shortcut overlay is open + // leaderHelpOverlay is the Ctrl+X ? modal listing every leader slash chord. + leaderHelpOverlay bool + // leaderPending is true after Ctrl+X until a second key, Esc, or timeout + // resolves the chord (see leader.go). leaderSeq invalidates a stale tick. + leaderPending bool + leaderSeq int transcriptBodyHeights *transcriptBodyHeightCache input textinput.Model composer composerState @@ -1118,6 +1124,11 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { m.cancelConfirmActive = false } return m, nil + case leaderExpiredMsg: + if msg.seq == m.leaderSeq { + m.leaderPending = false + } + return m, nil case dragEdgeScrollTickMsg: if msg.seq != m.edgeScrollSeq || m.edgeScrollDelta == 0 || !m.transcriptSelection.active { return m, nil // stale, or the chain was stopped since this tick was scheduled @@ -1233,9 +1244,41 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { m.burstCount = 0 return m, nil } - switch { - case keyCtrl(msg, 'c'): + // Ctrl+X ? leader-chord map: same dismiss keys as the general help overlay. + if m.leaderHelpOverlay { + if keyText(msg) == "?" || keyText(msg) == "q" || keyIs(msg, tea.KeyEsc) || keyIs(msg, tea.KeyEnter) || keyCtrl(msg, 'c') { + m.leaderHelpOverlay = false + } + m.burstCount = 0 + return m, nil + } + if keyCtrl(msg, 'c') { + if m.leaderPending { + m = m.clearLeader() + } return m.handleCtrlC() + } + if m.leaderPending { + // Leader owns every keystroke until resolved; never type into the composer. + return m.handleLeaderKey(msg) + } + if keyCtrl(msg, 'x') && m.canArmLeader() { + return m.armLeader() + } + // Emacs Ctrl+P / Ctrl+N move selection in open menus. Runs before the + // switch so menus win over global Ctrl+P (plan toggle) and remapped + // Ctrl+N bindings (e.g. toggleSidebar), while idle Ctrl+N still reaches + // those remapped cases below. + if !m.transcriptDetailed && (keyCtrl(msg, 'p') || keyCtrl(msg, 'n')) { + delta := 1 + if keyCtrl(msg, 'p') { + delta = -1 + } + if next, cmd, ok := m.moveModalSelection(delta); ok { + return next, cmd + } + } + switch { case m.keyMatch(m.keyBindings.toggleDetailed, msg, func(tea.KeyMsg) bool { return keyCtrl(msg, 'o') }): return m.toggleDetailedTranscript(), nil case m.fileView.active && m.noBlockingModal() && m.composerValue() == "" && (keyText(msg) == "d" || keyText(msg) == "f"): @@ -1497,6 +1540,7 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { } case m.keyMatch(m.keyBindings.togglePlan, msg, func(tea.KeyMsg) bool { return keyCtrl(msg, 'p') }): // Ctrl+P toggles the plan panel expansion (collapse/expand step list). + // Modal selection is handled before this switch so menus win over toggle. if m.noBlockingModal() && !m.plan.isEmpty() { m.plan.expanded = !m.plan.expanded return m, nil @@ -1594,34 +1638,14 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { if m.transcriptDetailed { return m, nil } - if m.pendingPermission != nil { - return m.movePermissionCursor(-1), nil - } - if m.pendingAskUser != nil { - return m.moveAskUserCursor(-1), nil - } - if m.providerWizard != nil { - m.burstCount = 0 - return m.handleProviderWizardKey(msg) - } - if m.mcpAddWizard != nil { - m.burstCount = 0 - return m.handleMCPAddWizardKey(msg) - } - if m.mcpManager != nil { - m.burstCount = 0 - return m.handleMCPManagerKey(msg) - } - if m.picker != nil { - if m.modelPickerIsLoading() { - return m, nil - } - m.pickerMoved(-1) - return m, nil - } + // Suggestions keep Shift+arrows for the composer path (unchanged); plain + // ↑/↓ and Ctrl+P/N still move the palette via moveModalSelection. if m.suggestionsActive() { break } + if next, cmd, ok := m.moveModalSelection(-1); ok { + return next, cmd + } if m.composerValue() != "" { break // let the input handle multiline navigation } @@ -1633,34 +1657,12 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { if m.transcriptDetailed { return m, nil } - if m.pendingPermission != nil { - return m.movePermissionCursor(1), nil - } - if m.pendingAskUser != nil { - return m.moveAskUserCursor(1), nil - } - if m.providerWizard != nil { - m.burstCount = 0 - return m.handleProviderWizardKey(msg) - } - if m.mcpAddWizard != nil { - m.burstCount = 0 - return m.handleMCPAddWizardKey(msg) - } - if m.mcpManager != nil { - m.burstCount = 0 - return m.handleMCPManagerKey(msg) - } - if m.picker != nil { - if m.modelPickerIsLoading() { - return m, nil - } - m.pickerMoved(1) - return m, nil - } if m.suggestionsActive() { break } + if next, cmd, ok := m.moveModalSelection(1); ok { + return next, cmd + } if m.composerValue() != "" { break // let the input handle multiline navigation } @@ -1671,34 +1673,8 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { m = m.clearHover() return m.scrollChat(-1), nil } - if m.pendingPermission != nil { - return m.movePermissionCursor(1), nil - } - if m.pendingAskUser != nil { - return m.moveAskUserCursor(1), nil - } - if m.providerWizard != nil { - m.burstCount = 0 - return m.handleProviderWizardKey(msg) - } - if m.mcpAddWizard != nil { - m.burstCount = 0 - return m.handleMCPAddWizardKey(msg) - } - if m.mcpManager != nil { - m.burstCount = 0 - return m.handleMCPManagerKey(msg) - } - if m.picker != nil { - if m.modelPickerIsLoading() { - return m, nil - } - m.pickerMoved(1) - return m, nil - } - if m.suggestionsActive() { - m.moveSuggestion(1) - return m, nil + if next, cmd, ok := m.moveModalSelection(1); ok { + return next, cmd } if next, ok := m.moveComposerVisualCursor(1); ok { return next, nil @@ -1717,34 +1693,8 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { m = m.clearHover() return m.scrollChat(1), nil } - if m.pendingPermission != nil { - return m.movePermissionCursor(-1), nil - } - if m.pendingAskUser != nil { - return m.moveAskUserCursor(-1), nil - } - if m.providerWizard != nil { - m.burstCount = 0 - return m.handleProviderWizardKey(msg) - } - if m.mcpAddWizard != nil { - m.burstCount = 0 - return m.handleMCPAddWizardKey(msg) - } - if m.mcpManager != nil { - m.burstCount = 0 - return m.handleMCPManagerKey(msg) - } - if m.picker != nil { - if m.modelPickerIsLoading() { - return m, nil - } - m.pickerMoved(-1) - return m, nil - } - if m.suggestionsActive() { - m.moveSuggestion(-1) - return m, nil + if next, cmd, ok := m.moveModalSelection(-1); ok { + return next, cmd } if next, ok := m.moveComposerVisualCursor(-1); ok { return next, nil @@ -2519,9 +2469,9 @@ func (m model) View() tea.View { var content string if m.setup.visible { content = m.setupView(chatWidth(m.width)) - } else if m.helpOverlay || !m.transcriptDetailed { - // When helpOverlay is active the help panel is composited into the normal - // transcript view as a true overlay (scrim + vertical centering), matching + } else if m.helpOverlay || m.leaderHelpOverlay || !m.transcriptDetailed { + // When helpOverlay / leaderHelpOverlay is active the panel is composited into + // the normal transcript view as a true overlay (scrim + vertical centering), matching // how the suggestion picker / provider wizard / pickers are drawn. content = m.transcriptView() } else { @@ -2613,6 +2563,10 @@ func (m model) transcriptView() string { if m.helpOverlay { helpOverlayContent = m.renderKeybindingHelpOverlay(width) } + leaderHelpOverlayContent := "" + if m.leaderHelpOverlay { + leaderHelpOverlayContent = m.renderLeaderHelpOverlay(width) + } suggestionOverlay := m.suggestionOverlay(width) providerOverlay := m.providerWizardOverlay(width) @@ -2626,6 +2580,8 @@ func (m model) transcriptView() string { viewportOverlay = sttKeyOverlay case helpOverlayContent != "": viewportOverlay = helpOverlayContent + case leaderHelpOverlayContent != "": + viewportOverlay = leaderHelpOverlayContent case providerOverlay != "": viewportOverlay = providerOverlay case mcpAddOverlay != "": @@ -2770,6 +2726,11 @@ func (m model) footerView(width int) string { // full-screen transcript, or under any modal/overlay so it never competes for // attention. Width-tiered so a narrow terminal only shows the essential pointer. func (m model) composerIdleHint() string { + // Leader-pending is always shown (even mid-type) so the user knows the next + // key is a chord, not composer input. + if m.leaderPending { + return zeroTheme.faint.Render("Ctrl+X — await shortcut (m model · p provider · ? list · Esc cancel)") + } // Managed (alt-screen) mode only: inline mode prints to native scrollback where // this footer row isn't a stable surface. Hidden while typing, during a run, in // the full-screen transcript, or under any modal/overlay. @@ -2788,9 +2749,9 @@ func (m model) composerIdleHint() string { case tierNarrow: hint = "? shortcuts" case tierMedium: - hint = fmt.Sprintf("? shortcuts · %s sidebar · %s copy", sidebarKey, mouseKey) + hint = fmt.Sprintf("? shortcuts · Ctrl+X cmds · %s sidebar", sidebarKey) default: - hint = fmt.Sprintf("? shortcuts · %s sidebar · %s detail · %s copy · Shift+Tab mode", sidebarKey, detailKey, mouseKey) + hint = fmt.Sprintf("? shortcuts · Ctrl+X cmds · %s sidebar · %s detail · %s copy · Shift+Tab mode", sidebarKey, detailKey, mouseKey) } return zeroTheme.faint.Render(hint) } @@ -4069,6 +4030,12 @@ func (m model) handleSubmit() (tea.Model, tea.Cmd) { m.chatScrollOffset = 0 } + return m.dispatchCommand(command) +} + +// dispatchCommand runs a parsed slash/prompt command after submit/leader +// preamble (history, composer clear, leave-prompt disarm) has already run. +func (m model) dispatchCommand(command parsedCommand) (tea.Model, tea.Cmd) { switch command.kind { case commandEmpty: return m, nil @@ -4443,6 +4410,23 @@ func (m model) handleSubmit() (tea.Model, tea.Cmd) { } } +// executeSlash runs a builtin slash command as if the user typed it and pressed +// Enter, without clearing the composer draft. Used by Ctrl+X leader chords so a +// mid-type prompt is preserved while /model (etc.) still opens. +func (m model) executeSlash(input string) (tea.Model, tea.Cmd) { + command := parseCommand(input) + if command.kind == commandEmpty || command.kind == commandPrompt { + return m, nil + } + if m.loopLeavePrompt != commandEmpty && command.kind != m.loopLeavePrompt { + m.loopLeavePrompt = commandEmpty + } + m.rememberInput(input) + m.clearSuggestions() + m.chatScrollOffset = 0 + return m.dispatchCommand(command) +} + // launchPrompt starts a normal agent turn from text already accepted by the // composer. Queued prompts use this path too, so session and image behavior // stays identical to immediate submissions. diff --git a/internal/tui/onboarding.go b/internal/tui/onboarding.go index fdc98535c..306240c7a 100644 --- a/internal/tui/onboarding.go +++ b/internal/tui/onboarding.go @@ -183,7 +183,7 @@ func (m model) handleSetupKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m.advanceSetup() } return m, nil - case keyIs(msg, tea.KeyUp): + case keyIs(msg, tea.KeyUp) || keyCtrl(msg, 'p'): if m.setup.stage == setupStageMethod { m.moveSetupMethod(-1) } else if m.setup.stage == setupStageProvider { @@ -192,7 +192,7 @@ func (m model) handleSetupKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.moveSetupModel(-1) } return m, nil - case keyIs(msg, tea.KeyDown): + case keyIs(msg, tea.KeyDown) || keyCtrl(msg, 'n'): if m.setup.stage == setupStageMethod { m.moveSetupMethod(1) } else if m.setup.stage == setupStageProvider { diff --git a/internal/tui/sidebar.go b/internal/tui/sidebar.go index a4528145d..966debe35 100644 --- a/internal/tui/sidebar.go +++ b/internal/tui/sidebar.go @@ -98,7 +98,7 @@ func (m model) sidebarAvailable() bool { // list) take over the chat column and render at full width; suppress the // second column while any is active so their geometry and mouse hit-testing // stay full-width as before. - if m.setup.visible || m.helpOverlay || m.providerWizard != nil || m.mcpAddWizard != nil || + if m.setup.visible || m.helpOverlay || m.leaderHelpOverlay || m.providerWizard != nil || m.mcpAddWizard != nil || m.mcpManager != nil || m.picker != nil || m.suggestionsActive() { return false } From 797b67c14a0bd7544221a77f1d2d1fb28137c550 Mon Sep 17 00:00:00 2001 From: Leonardo Faoro Date: Thu, 16 Jul 2026 01:14:31 +0300 Subject: [PATCH 2/3] fix(tui): drop Ctrl+X e /edit leader chord /edit replaces the composer and would discard an in-progress draft; leave it unbound so leader shortcuts stay draft-preserving. --- CHANGELOG.md | 9 +++++---- internal/tui/leader.go | 2 -- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 10fd34e7a..c65fa2937 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -129,10 +129,11 @@ tagged. Until then, source builds report the version `dev`. ### Added - **TUI keyboard shortcuts for common slash commands:** `Ctrl+X` is a leader key; a follow-up letter runs the matching builtin command without clearing the composer draft (e.g. `Ctrl+X` `m` → `/model`, - `p` → `/provider`, `r` → `/resume`, `c` → `/clear`, `t` → `/tools`, `e` → `/edit`, plus plan, voice, - STT model, context, stop, image, rewind, and retry). `Ctrl+X` `?` opens a bordered chord-map modal - (same chrome as the `?` keyboard-shortcut overlay) listing every wired mapping; Esc / second - `Ctrl+X` / timeout cancel a pending leader chord. + `p` → `/provider`, `r` → `/resume`, `c` → `/clear`, `t` → `/tools`, plus plan, voice, STT model, + context, stop, image, rewind, and retry). `/edit` is intentionally not bound (it would replace the + composer and discard an in-progress draft). `Ctrl+X` `?` opens a bordered chord-map modal (same + chrome as the `?` keyboard-shortcut overlay) listing every wired mapping; Esc / second `Ctrl+X` / + timeout cancel a pending leader chord. - **Emacs-style menu navigation:** `Ctrl+P` / `Ctrl+N` move previous / next in pickers, slash/file suggestions, permission prompts, ask-user option lists, and provider/MCP wizard lists (same as ↑/↓). Idle `Ctrl+P` still toggles the plan panel when no menu is open. diff --git a/internal/tui/leader.go b/internal/tui/leader.go index 0c5a9d063..b3420f3ae 100644 --- a/internal/tui/leader.go +++ b/internal/tui/leader.go @@ -28,7 +28,6 @@ var leaderCommandByKey = map[rune]string{ 'r': "/resume", 'u': "/rewind", 't': "/tools", - 'e': "/edit", 'R': "/retry", } @@ -105,7 +104,6 @@ func leaderHelpBindings() []keybinding { {"Ctrl+X r", "open /resume"}, {"Ctrl+X u", "run /rewind"}, {"Ctrl+X t", "run /tools"}, - {"Ctrl+X e", "run /edit"}, {"Ctrl+X R", "run /retry"}, {"Ctrl+X ?", "show this list"}, } From b2cd45e7f9db9bd7bddc7d22b69f694b08b32beb Mon Sep 17 00:00:00 2001 From: Leonardo Faoro Date: Thu, 16 Jul 2026 01:16:38 +0300 Subject: [PATCH 3/3] fix(tui): consume idle Ctrl+N as a no-op Reserve Ctrl+N for emacs menu navigation so it never falls through to remapped global bindings when no selection surface is open. --- CHANGELOG.md | 3 ++- internal/tui/keybinding_help_test.go | 8 +++++--- internal/tui/model.go | 9 ++++++--- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c65fa2937..55abbdc26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -136,7 +136,8 @@ tagged. Until then, source builds report the version `dev`. timeout cancel a pending leader chord. - **Emacs-style menu navigation:** `Ctrl+P` / `Ctrl+N` move previous / next in pickers, slash/file suggestions, permission prompts, ask-user option lists, and provider/MCP wizard lists (same as ↑/↓). - Idle `Ctrl+P` still toggles the plan panel when no menu is open. + Idle `Ctrl+P` still toggles the plan panel when no menu is open; idle `Ctrl+N` is a reserved no-op + (does not fire remapped global bindings). - `SECURITY.md` with a private vulnerability-reporting path, `CODE_OF_CONDUCT.md`, this changelog, and GitHub issue/PR templates. - Interactive `/theme` picker: bare `/theme` opens a popup that live-previews each palette as you move diff --git a/internal/tui/keybinding_help_test.go b/internal/tui/keybinding_help_test.go index 2e5d4ec10..e6b056f25 100644 --- a/internal/tui/keybinding_help_test.go +++ b/internal/tui/keybinding_help_test.go @@ -225,7 +225,9 @@ func TestRemappedToggleBindingsIgnoreComposerGuard(t *testing.T) { m.height = 40 m.transcript = append(m.transcript, transcriptRow{kind: rowToolCall, tool: "read_file", detail: "main.go"}) m.keyBindings.toggleMouse = parseBinding("ctrl+m") - m.keyBindings.toggleSidebar = parseBinding("ctrl+n") + // Avoid Ctrl+N: idle Ctrl+N is reserved as an emacs menu no-op and never + // reaches configurable global bindings. + m.keyBindings.toggleSidebar = parseBinding("ctrl+y") if !m.sidebarToggleAllowed() { t.Fatal("sidebar toggle should be allowed") } @@ -246,10 +248,10 @@ func TestRemappedToggleBindingsIgnoreComposerGuard(t *testing.T) { } initialSidebar := next.sidebarHidden - updated, _ = next.Update(testKeyCtrl('n')) + updated, _ = next.Update(testKeyCtrl('y')) next = updated.(model) if next.sidebarHidden == initialSidebar { - t.Fatal("remapped Ctrl+N toggleSidebar binding should still fire with a non-empty composer") + t.Fatal("remapped Ctrl+Y toggleSidebar binding should still fire with a non-empty composer") } if next.composerValue() != "hello" { t.Fatalf("remapped toggleSidebar binding should not fall through to composer input, got %q", next.composerValue()) diff --git a/internal/tui/model.go b/internal/tui/model.go index 51c319b74..d60fd4727 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -1266,9 +1266,9 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { return m.armLeader() } // Emacs Ctrl+P / Ctrl+N move selection in open menus. Runs before the - // switch so menus win over global Ctrl+P (plan toggle) and remapped - // Ctrl+N bindings (e.g. toggleSidebar), while idle Ctrl+N still reaches - // those remapped cases below. + // switch so menus win over global Ctrl+P (plan toggle). Idle Ctrl+P + // falls through to that binding; idle Ctrl+N is a reserved no-op so it + // never reaches remapped configurable bindings (e.g. toggleSidebar). if !m.transcriptDetailed && (keyCtrl(msg, 'p') || keyCtrl(msg, 'n')) { delta := 1 if keyCtrl(msg, 'p') { @@ -1277,6 +1277,9 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { if next, cmd, ok := m.moveModalSelection(delta); ok { return next, cmd } + if keyCtrl(msg, 'n') { + return m, nil + } } switch { case m.keyMatch(m.keyBindings.toggleDetailed, msg, func(tea.KeyMsg) bool { return keyCtrl(msg, 'o') }):