Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,17 @@ 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`, 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; 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
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
5 changes: 4 additions & 1 deletion internal/tui/keybinding_help.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)"},
},
},
Expand All @@ -47,14 +49,15 @@ 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)"},
},
},
{
title: "Navigation & scrollback",
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"},
Expand Down
9 changes: 6 additions & 3 deletions internal/tui/keybinding_help_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -224,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")
}
Expand All @@ -245,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())
Expand Down
170 changes: 170 additions & 0 deletions internal/tui/leader.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
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",
'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 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
}
Loading
Loading