feat: custom keybindings (#1185)

Drew Smirnoff , Lea , and Andriy Chernov created

## What?


Adds customization to keybinds, as well as a safeguard saying if you
have keybinds binded to the same action

## Why?


Closes #399

---------

Signed-off-by: drew <me@andrinoff.com>
Co-authored-by: Lea <lea@floatpane.com>
Co-authored-by: Andriy Chernov <andriy@floatpane.com>

Change summary

config/config.go               |   6 +
config/default_keybinds.json   |  44 ++++++++
config/keybinds.go             | 178 ++++++++++++++++++++++++++++++++++++
config/keybinds_test.go        |  86 +++++++++++++++++
docs/docs/Configuration.md     |   1 
docs/docs/Features/Keybinds.md | 143 ++++++++++++++++++++++++++++
tui/choice.go                  |  18 +++
tui/composer.go                |  15 +-
tui/drafts.go                  |   9 +
tui/email_view.go              |  32 +++---
tui/folder_inbox.go            |  23 ++--
tui/inbox.go                   |  21 ++--
tui/mailing_list.go            |   6 
tui/marketplace.go             |  12 +-
14 files changed, 538 insertions(+), 56 deletions(-)

Detailed changes

config/config.go 🔗

@@ -475,6 +475,12 @@ func LoadConfig() (*Config, error) {
 	if err != nil {
 		return nil, err
 	}
+
+	if dir, err := configDir(); err == nil {
+		if err := LoadKeybindsFromDir(dir); err != nil {
+			log.Printf("matcha: keybinds load error (using defaults): %v", err)
+		}
+	}
 	data, err := SecureReadFile(path)
 	if err != nil {
 		return nil, err

config/default_keybinds.json 🔗

@@ -0,0 +1,44 @@
+{
+  "global": {
+    "quit": "ctrl+c",
+    "cancel": "esc",
+    "nav_up": "k",
+    "nav_down": "j"
+  },
+  "inbox": {
+    "visual_mode": "v",
+    "delete": "d",
+    "archive": "a",
+    "refresh": "r",
+    "open": "enter",
+    "next_tab": "l",
+    "prev_tab": "h"
+  },
+  "email": {
+    "reply": "r",
+    "forward": "f",
+    "delete": "d",
+    "archive": "a",
+    "toggle_images": "i",
+    "rsvp_accept": "1",
+    "rsvp_decline": "2",
+    "rsvp_tentative": "3",
+    "focus_attachments": "tab"
+  },
+  "composer": {
+    "external_editor": "ctrl+e",
+    "next_field": "tab",
+    "prev_field": "shift+tab"
+  },
+  "folder": {
+    "next_folder": "tab",
+    "prev_folder": "shift+tab",
+    "move": "m",
+    "focus_preview": "]",
+    "focus_inbox": "["
+  },
+  "drafts": {
+    "open": "enter",
+    "delete": "d"
+  }
+}

config/keybinds.go 🔗

@@ -0,0 +1,178 @@
+package config
+
+import (
+	_ "embed"
+	"encoding/json"
+	"fmt"
+	"os"
+	"path/filepath"
+)
+
+//go:embed default_keybinds.json
+var defaultKeybindsJSON []byte
+
+// Keybinds is the active keybind configuration. Initialized to defaults at
+// package init; overwritten by LoadKeybindsFromDir when config is loaded.
+var Keybinds = defaultKeybinds()
+
+// KeybindsConfig holds all configurable key bindings organized by area.
+type KeybindsConfig struct {
+	Global   GlobalKeys   `json:"global"`
+	Inbox    InboxKeys    `json:"inbox"`
+	Email    EmailKeys    `json:"email"`
+	Composer ComposerKeys `json:"composer"`
+	Folder   FolderKeys   `json:"folder"`
+	Drafts   DraftsKeys   `json:"drafts"`
+}
+
+type GlobalKeys struct {
+	Quit    string `json:"quit"`
+	Cancel  string `json:"cancel"`
+	NavUp   string `json:"nav_up"`
+	NavDown string `json:"nav_down"`
+}
+
+type InboxKeys struct {
+	VisualMode string `json:"visual_mode"`
+	Delete     string `json:"delete"`
+	Archive    string `json:"archive"`
+	Refresh    string `json:"refresh"`
+	Open       string `json:"open"`
+	NextTab    string `json:"next_tab"`
+	PrevTab    string `json:"prev_tab"`
+}
+
+type EmailKeys struct {
+	Reply            string `json:"reply"`
+	Forward          string `json:"forward"`
+	Delete           string `json:"delete"`
+	Archive          string `json:"archive"`
+	ToggleImages     string `json:"toggle_images"`
+	RsvpAccept       string `json:"rsvp_accept"`
+	RsvpDecline      string `json:"rsvp_decline"`
+	RsvpTentative    string `json:"rsvp_tentative"`
+	FocusAttachments string `json:"focus_attachments"`
+}
+
+type ComposerKeys struct {
+	ExternalEditor string `json:"external_editor"`
+	NextField      string `json:"next_field"`
+	PrevField      string `json:"prev_field"`
+}
+
+type FolderKeys struct {
+	NextFolder   string `json:"next_folder"`
+	PrevFolder   string `json:"prev_folder"`
+	Move         string `json:"move"`
+	FocusPreview string `json:"focus_preview"`
+	FocusInbox   string `json:"focus_inbox"`
+}
+
+type DraftsKeys struct {
+	Open   string `json:"open"`
+	Delete string `json:"delete"`
+}
+
+func defaultKeybinds() KeybindsConfig {
+	var kb KeybindsConfig
+	if err := json.Unmarshal(defaultKeybindsJSON, &kb); err != nil {
+		panic("matcha: malformed default_keybinds.json: " + err.Error())
+	}
+	return kb
+}
+
+// LoadKeybindsFromDir reads keybinds.json from cfgDir, writing defaults if
+// the file does not exist, then updates the package-level Keybinds var.
+func LoadKeybindsFromDir(cfgDir string) error {
+	path := filepath.Join(cfgDir, "keybinds.json")
+
+	data, err := os.ReadFile(path)
+	if err != nil {
+		if !os.IsNotExist(err) {
+			return fmt.Errorf("keybinds: read %s: %w", path, err)
+		}
+		// File missing — write defaults.
+		if err := os.MkdirAll(cfgDir, 0700); err != nil {
+			return fmt.Errorf("keybinds: mkdir %s: %w", cfgDir, err)
+		}
+		if err := os.WriteFile(path, defaultKeybindsJSON, 0600); err != nil {
+			return fmt.Errorf("keybinds: write defaults to %s: %w", path, err)
+		}
+		Keybinds = defaultKeybinds()
+		return nil
+	}
+
+	var kb KeybindsConfig
+	if err := json.Unmarshal(data, &kb); err != nil {
+		return fmt.Errorf("keybinds: parse %s: %w", path, err)
+	}
+	Keybinds = kb
+	return nil
+}
+
+// ValidateKeybinds returns a list of conflict descriptions where two different
+// actions within the same area are mapped to the same key. Cross-area
+// duplicates are intentional (e.g. "d" = delete in both inbox and email view).
+func ValidateKeybinds(kb KeybindsConfig) []string {
+	var conflicts []string
+
+	check := func(area string, bindings map[string]string) {
+		seen := make(map[string]string) // key → action name
+		for action, key := range bindings {
+			if key == "" {
+				continue
+			}
+			if prev, ok := seen[key]; ok {
+				conflicts = append(conflicts,
+					fmt.Sprintf("conflict in %s: key %q used for both %q and %q", area, key, prev, action))
+			} else {
+				seen[key] = action
+			}
+		}
+	}
+
+	check("global", map[string]string{
+		"quit":     kb.Global.Quit,
+		"cancel":   kb.Global.Cancel,
+		"nav_up":   kb.Global.NavUp,
+		"nav_down": kb.Global.NavDown,
+	})
+	check("inbox", map[string]string{
+		"visual_mode": kb.Inbox.VisualMode,
+		"delete":      kb.Inbox.Delete,
+		"archive":     kb.Inbox.Archive,
+		"refresh":     kb.Inbox.Refresh,
+		"open":        kb.Inbox.Open,
+		"next_tab":    kb.Inbox.NextTab,
+		"prev_tab":    kb.Inbox.PrevTab,
+	})
+	check("email", map[string]string{
+		"reply":             kb.Email.Reply,
+		"forward":           kb.Email.Forward,
+		"delete":            kb.Email.Delete,
+		"archive":           kb.Email.Archive,
+		"toggle_images":     kb.Email.ToggleImages,
+		"rsvp_accept":       kb.Email.RsvpAccept,
+		"rsvp_decline":      kb.Email.RsvpDecline,
+		"rsvp_tentative":    kb.Email.RsvpTentative,
+		"focus_attachments": kb.Email.FocusAttachments,
+	})
+	check("composer", map[string]string{
+		"external_editor": kb.Composer.ExternalEditor,
+		"next_field":      kb.Composer.NextField,
+		"prev_field":      kb.Composer.PrevField,
+	})
+	check("folder", map[string]string{
+		"next_folder":   kb.Folder.NextFolder,
+		"prev_folder":   kb.Folder.PrevFolder,
+		"move":          kb.Folder.Move,
+		"focus_preview": kb.Folder.FocusPreview,
+		"focus_inbox":   kb.Folder.FocusInbox,
+	})
+	check("drafts", map[string]string{
+		"open":   kb.Drafts.Open,
+		"delete": kb.Drafts.Delete,
+	})
+
+	return conflicts
+}

config/keybinds_test.go 🔗

@@ -0,0 +1,86 @@
+package config
+
+import (
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+)
+
+func TestValidateKeybinds_NoConflicts(t *testing.T) {
+	kb := defaultKeybinds()
+	conflicts := ValidateKeybinds(kb)
+	if len(conflicts) != 0 {
+		t.Errorf("default keybinds have conflicts: %v", conflicts)
+	}
+}
+
+func TestValidateKeybinds_InboxConflict(t *testing.T) {
+	kb := defaultKeybinds()
+	kb.Inbox.Archive = kb.Inbox.Delete // same key as delete
+	conflicts := ValidateKeybinds(kb)
+	if len(conflicts) == 0 {
+		t.Fatal("expected conflict, got none")
+	}
+	found := false
+	for _, c := range conflicts {
+		if strings.Contains(c, "inbox") {
+			found = true
+			break
+		}
+	}
+	if !found {
+		t.Errorf("expected inbox conflict, got: %v", conflicts)
+	}
+}
+
+func TestValidateKeybinds_CrossAreaNotConflict(t *testing.T) {
+	kb := defaultKeybinds()
+	// "d" is delete in both inbox and email — intentional, not a conflict
+	kb.Inbox.Delete = "d"
+	kb.Email.Delete = "d"
+	conflicts := ValidateKeybinds(kb)
+	if len(conflicts) != 0 {
+		t.Errorf("cross-area duplicates should not be conflicts: %v", conflicts)
+	}
+}
+
+func TestValidateKeybinds_EmptyKeySkipped(t *testing.T) {
+	kb := defaultKeybinds()
+	kb.Drafts.Delete = ""
+	kb.Drafts.Open = ""
+	conflicts := ValidateKeybinds(kb)
+	if len(conflicts) != 0 {
+		t.Errorf("empty keys should not produce conflicts: %v", conflicts)
+	}
+}
+
+func TestLoadKeybindsFromDir_WritesDefault(t *testing.T) {
+	dir := t.TempDir()
+	if err := LoadKeybindsFromDir(dir); err != nil {
+		t.Fatalf("LoadKeybindsFromDir: %v", err)
+	}
+	if Keybinds.Inbox.Delete == "" {
+		t.Error("expected inbox.delete to be set after loading defaults")
+	}
+}
+
+func TestLoadKeybindsFromDir_ParsesCustom(t *testing.T) {
+	dir := t.TempDir()
+	// Write defaults first
+	if err := LoadKeybindsFromDir(dir); err != nil {
+		t.Fatalf("write defaults: %v", err)
+	}
+
+	// Override inbox delete key
+	custom := `{"inbox":{"delete":"x","archive":"a","refresh":"r","open":"enter","next_tab":"l","prev_tab":"h","visual_mode":"v"},"global":{"quit":"ctrl+c","cancel":"esc","nav_up":"k","nav_down":"j"},"email":{"reply":"r","forward":"f","delete":"d","archive":"a","toggle_images":"i","rsvp_accept":"1","rsvp_decline":"2","rsvp_tentative":"3","focus_attachments":"tab"},"composer":{"external_editor":"ctrl+e","next_field":"tab","prev_field":"shift+tab"},"folder":{"next_folder":"tab","prev_folder":"shift+tab","move":"m","focus_preview":"]","focus_inbox":"["},"drafts":{"open":"enter","delete":"d"}}`
+	if err := os.WriteFile(filepath.Join(dir, "keybinds.json"), []byte(custom), 0600); err != nil {
+		t.Fatalf("write custom: %v", err)
+	}
+	if err := LoadKeybindsFromDir(dir); err != nil {
+		t.Fatalf("LoadKeybindsFromDir custom: %v", err)
+	}
+	if Keybinds.Inbox.Delete != "x" {
+		t.Errorf("expected inbox.delete=x, got %q", Keybinds.Inbox.Delete)
+	}
+}

docs/docs/Configuration.md 🔗

@@ -60,6 +60,7 @@ Configuration and persistent data are stored in `~/.config/matcha/`:
 | File | Description |
 |------|-------------|
 | `config.json` | Account settings, preferences |
+| `keybinds.json` | Custom keyboard shortcuts (see [Keybinds](/docs/Features/Keybinds)) |
 | `signatures/` | Email signatures |
 | `pgp/` | PGP keys |
 | `plugins/` | Installed Lua plugins |

docs/docs/Features/Keybinds.md 🔗

@@ -0,0 +1,143 @@
+# Keybinds
+
+Matcha lets you remap every keyboard shortcut. Bindings live in plain JSON at `~/.config/matcha/keybinds.json` and are written automatically the first time you launch the app.
+
+## File location
+
+```
+~/.config/matcha/keybinds.json
+```
+
+Plain text, not encrypted. Edit with any text editor. Restart matcha to apply changes.
+
+## Default bindings
+
+```json
+{
+  "global": {
+    "quit": "ctrl+c",
+    "cancel": "esc",
+    "nav_up": "k",
+    "nav_down": "j"
+  },
+  "inbox": {
+    "visual_mode": "v",
+    "delete": "d",
+    "archive": "a",
+    "refresh": "r",
+    "open": "enter",
+    "next_tab": "l",
+    "prev_tab": "h"
+  },
+  "email": {
+    "reply": "r",
+    "forward": "f",
+    "delete": "d",
+    "archive": "a",
+    "toggle_images": "i",
+    "rsvp_accept": "1",
+    "rsvp_decline": "2",
+    "rsvp_tentative": "3",
+    "focus_attachments": "tab"
+  },
+  "composer": {
+    "external_editor": "ctrl+e",
+    "next_field": "tab",
+    "prev_field": "shift+tab"
+  },
+  "folder": {
+    "next_folder": "tab",
+    "prev_folder": "shift+tab",
+    "move": "m",
+    "focus_preview": "]",
+    "focus_inbox": "["
+  },
+  "drafts": {
+    "open": "enter",
+    "delete": "d"
+  }
+}
+```
+
+## Areas
+
+| Area       | Where it applies                                         |
+| ---------- | -------------------------------------------------------- |
+| `global`   | Quit, cancel, vertical navigation — everywhere           |
+| `inbox`    | Email list view (visual select, delete, archive, tabs)   |
+| `email`    | Single-email view (reply, forward, RSVP, attachments)    |
+| `composer` | New email / reply / forward editor                       |
+| `folder`   | Folder sidebar + split-pane preview                      |
+| `drafts`   | Draft list                                               |
+
+The same key can appear in different areas without conflict — `d` is delete in both `inbox` and `email`, that's intentional. Conflicts only matter within one area.
+
+## Key syntax
+
+Standard [bubbletea](https://charm.land/bubbletea) key strings:
+
+| Form              | Examples                          |
+| ----------------- | --------------------------------- |
+| Single character  | `a`, `1`, `?`                     |
+| Modifier + key    | `ctrl+c`, `ctrl+e`, `shift+tab`   |
+| Named key         | `enter`, `esc`, `tab`, `space`    |
+| Arrow             | `up`, `down`, `left`, `right`     |
+
+## Conflict warning
+
+If two actions inside the same area share a key, matcha shows a yellow warning at the top of the start menu:
+
+```
+⚠ keybind conflict in inbox: "d" used for both "delete" and "archive"
+```
+
+The warning stays until you fix the binding. Both actions still fire on the shared key, but only the first one wins.
+
+## What stays hardcoded
+
+A few keys are never read from config — they exist as universal fallbacks:
+
+- Arrow keys (`up`, `down`, `left`, `right`) — always navigate
+- `y` / `n` on confirmation prompts
+- `enter` inside modal pickers (file picker, account picker, move-to-folder)
+
+This means even an empty or broken `keybinds.json` still leaves the app navigable.
+
+## Reset to defaults
+
+Delete the file:
+
+```bash
+rm ~/.config/matcha/keybinds.json
+```
+
+Next launch writes a fresh default file.
+
+## Example: Emacs-style nav
+
+```json
+{
+  "global": {
+    "quit": "ctrl+x",
+    "cancel": "esc",
+    "nav_up": "ctrl+p",
+    "nav_down": "ctrl+n"
+  }
+}
+```
+
+## Example: Single-key actions
+
+```json
+{
+  "inbox": {
+    "delete": "x",
+    "archive": "e",
+    "refresh": "g",
+    "open": "enter",
+    "visual_mode": "V",
+    "next_tab": "n",
+    "prev_tab": "p"
+  }
+}
+```

tui/choice.go 🔗

@@ -39,6 +39,7 @@ type Choice struct {
 	CurrentVersion  string
 	width           int
 	height          int
+	keybindWarnings []string
 }
 
 func NewChoice() Choice {
@@ -58,8 +59,8 @@ func NewChoice() Choice {
 		UpdateAvailable: false,
 		LatestVersion:   "",
 		CurrentVersion:  "",
+		keybindWarnings: config.ValidateKeybinds(config.Keybinds),
 	}
-
 }
 
 func (m Choice) Init() tea.Cmd {
@@ -73,12 +74,13 @@ func (m Choice) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		m.height = msg.Height
 		return m, nil
 	case tea.KeyPressMsg:
+		kb := config.Keybinds
 		switch msg.String() {
-		case "up", "k":
+		case "up", kb.Global.NavUp:
 			if m.cursor > 0 {
 				m.cursor--
 			}
-		case "down", "j":
+		case "down", kb.Global.NavDown:
 			if m.cursor < len(m.choices)-1 {
 				m.cursor++
 			}
@@ -134,6 +136,16 @@ func (m Choice) View() tea.View {
 
 	b.WriteString(logoStyle.Render(choiceLogo))
 	b.WriteString("\n")
+
+	if len(m.keybindWarnings) > 0 {
+		warnStyle := lipgloss.NewStyle().Foreground(theme.ActiveTheme.Warning).Padding(0, 1)
+		for _, w := range m.keybindWarnings {
+			b.WriteString(warnStyle.Render("⚠ keybind " + w))
+			b.WriteString("\n")
+		}
+		b.WriteString("\n")
+	}
+
 	b.WriteString(listHeader.Render(t("choice.what_to_do")))
 	b.WriteString("\n\n")
 

tui/composer.go 🔗

@@ -310,8 +310,8 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				m.suggestions = nil
 				return m, nil
 			}
-			// For shift+tab, close suggestions and let it fall through to normal handling
-			if msg.String() == "shift+tab" {
+			// For prev-field key, close suggestions and let it fall through to normal handling
+			if msg.String() == config.Keybinds.Composer.PrevField {
 				m.showSuggestions = false
 				m.suggestions = nil
 			}
@@ -366,17 +366,18 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			}
 		}
 
+		kb := config.Keybinds
 		switch msg.String() {
-		case "ctrl+c":
+		case kb.Global.Quit:
 			return m, tea.Quit
-		case "ctrl+e":
+		case kb.Composer.ExternalEditor:
 			return m, func() tea.Msg { return OpenEditorMsg{} }
-		case "esc":
+		case kb.Global.Cancel:
 			m.confirmingExit = true
 			return m, nil
 
-		case "tab", "shift+tab":
-			if msg.String() == "shift+tab" {
+		case kb.Composer.NextField, kb.Composer.PrevField:
+			if msg.String() == kb.Composer.PrevField {
 				m.focusIndex--
 			} else {
 				m.focusIndex++

tui/drafts.go 🔗

@@ -130,7 +130,7 @@ func (m *Drafts) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 						return DeleteSavedDraftMsg{DraftID: draftID}
 					}
 				}
-			case "n", "N", "esc":
+			case "n", "N", config.Keybinds.Global.Cancel:
 				m.confirmDelete = false
 				m.selectedDraft = nil
 			}
@@ -142,16 +142,17 @@ func (m *Drafts) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			break
 		}
 
+		kb := config.Keybinds
 		switch msg.String() {
-		case "esc":
+		case kb.Global.Cancel:
 			return m, func() tea.Msg { return GoToChoiceMenuMsg{} }
-		case "enter":
+		case kb.Drafts.Open:
 			if item, ok := m.list.SelectedItem().(draftItem); ok {
 				return m, func() tea.Msg {
 					return OpenDraftMsg{Draft: item.draft}
 				}
 			}
-		case "d":
+		case kb.Drafts.Delete:
 			if item, ok := m.list.SelectedItem().(draftItem); ok {
 				m.confirmDelete = true
 				m.selectedDraft = &item.draft

tui/email_view.go 🔗

@@ -11,6 +11,7 @@ import (
 	tea "charm.land/bubbletea/v2"
 	"charm.land/lipgloss/v2"
 	"github.com/floatpane/matcha/calendar"
+	"github.com/floatpane/matcha/config"
 	"github.com/floatpane/matcha/fetcher"
 	"github.com/floatpane/matcha/theme"
 	"github.com/floatpane/matcha/view"
@@ -176,8 +177,9 @@ func (m *EmailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 
 	switch msg := msg.(type) {
 	case tea.KeyPressMsg:
-		// Handle 'esc' key locally
-		if msg.String() == "esc" {
+		kb := config.Keybinds
+		// Handle cancel key locally
+		if msg.String() == kb.Global.Cancel {
 			if m.focusOnAttachments {
 				m.focusOnAttachments = false
 				return m, nil
@@ -189,11 +191,11 @@ func (m *EmailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 
 		if m.focusOnAttachments {
 			switch msg.String() {
-			case "up", "k":
+			case "up", kb.Global.NavUp:
 				if m.attachmentCursor > 0 {
 					m.attachmentCursor--
 				}
-			case "down", "j":
+			case "down", kb.Global.NavDown:
 				if m.attachmentCursor < len(m.email.Attachments)-1 {
 					m.attachmentCursor++
 				}
@@ -213,12 +215,12 @@ func (m *EmailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 						}
 					}
 				}
-			case "tab":
+			case kb.Email.FocusAttachments:
 				m.focusOnAttachments = false
 			}
 		} else {
 			switch msg.String() {
-			case "i":
+			case kb.Email.ToggleImages:
 				if view.ImageProtocolSupported() {
 					m.showImages = !m.showImages
 					ClearKittyGraphics()
@@ -233,15 +235,15 @@ func (m *EmailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 					m.viewport.SetContent(wrapped + "\n")
 					return m, nil
 				}
-			case "r":
+			case kb.Email.Reply:
 				// Clear Kitty graphics before opening composer
 				ClearKittyGraphics()
 				return m, func() tea.Msg { return ReplyToEmailMsg{Email: m.email} }
-			case "f":
+			case kb.Email.Forward:
 				// Clear Kitty graphics before opening composer
 				ClearKittyGraphics()
 				return m, func() tea.Msg { return ForwardEmailMsg{Email: m.email} }
-			case "d":
+			case kb.Email.Delete:
 				accountID := m.accountID
 				uid := m.email.UID
 				// Clear Kitty graphics before transitioning
@@ -249,7 +251,7 @@ func (m *EmailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				return m, func() tea.Msg {
 					return DeleteEmailMsg{UID: uid, AccountID: accountID, Mailbox: m.mailbox}
 				}
-			case "a":
+			case kb.Email.Archive:
 				accountID := m.accountID
 				uid := m.email.UID
 				// Clear Kitty graphics before transitioning
@@ -257,15 +259,15 @@ func (m *EmailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				return m, func() tea.Msg {
 					return ArchiveEmailMsg{UID: uid, AccountID: accountID, Mailbox: m.mailbox}
 				}
-			case "1", "2", "3":
+			case kb.Email.RsvpAccept, kb.Email.RsvpDecline, kb.Email.RsvpTentative:
 				if m.hasCalendarInvite && m.calendarEvent != nil {
 					var response string
 					switch msg.String() {
-					case "1":
+					case kb.Email.RsvpAccept:
 						response = "ACCEPTED"
-					case "2":
+					case kb.Email.RsvpDecline:
 						response = "DECLINED"
-					case "3":
+					case kb.Email.RsvpTentative:
 						response = "TENTATIVE"
 					}
 
@@ -280,7 +282,7 @@ func (m *EmailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 						}
 					}
 				}
-			case "tab":
+			case kb.Email.FocusAttachments:
 				if len(m.email.Attachments) > 0 {
 					m.focusOnAttachments = true
 				}

tui/folder_inbox.go 🔗

@@ -168,10 +168,12 @@ func (m *FolderInbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			break
 		}
 
+		kb := config.Keybinds
+
 		// Route input to preview pane when focused
 		if m.previewPane != nil && m.focusedPane == FocusPreview {
 			s := msg.String()
-			if s != "[" && s != "]" && s != "esc" && s != "q" {
+			if s != kb.Folder.FocusInbox && s != kb.Folder.FocusPreview && s != kb.Global.Cancel && s != "q" {
 				var cmd tea.Cmd
 				_, cmd = m.previewPane.Update(msg)
 				return m, cmd
@@ -179,38 +181,38 @@ func (m *FolderInbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		}
 
 		switch msg.String() {
-		case "]":
+		case kb.Folder.FocusPreview:
 			// Switch focus to preview pane
 			if m.previewPane != nil && m.focusedPane == FocusInbox {
 				m.focusedPane = FocusPreview
 				return m, nil
 			}
-		case "[":
+		case kb.Folder.FocusInbox:
 			// Switch focus to inbox pane
 			if m.previewPane != nil && m.focusedPane == FocusPreview {
 				m.focusedPane = FocusInbox
 				return m, nil
 			}
-		case "tab":
+		case kb.Folder.NextFolder:
 			m.activeFolderIdx++
 			if m.activeFolderIdx >= len(m.folders) {
 				m.activeFolderIdx = 0
 			}
 			return m, m.switchFolder()
-		case "shift+tab":
+		case kb.Folder.PrevFolder:
 			m.activeFolderIdx--
 			if m.activeFolderIdx < 0 {
 				m.activeFolderIdx = len(m.folders) - 1
 			}
 			return m, m.switchFolder()
-		case "esc":
+		case kb.Global.Cancel:
 			// Close split preview if open
 			if m.previewPane != nil {
 				m.closeSplitPreview()
 				return m, nil
 			}
 			// Otherwise let inbox handle (or parent)
-		case "m":
+		case kb.Folder.Move:
 			// Start move-to-folder flow
 			if m.inbox.visualMode && len(m.inbox.selectedUIDs) > 0 {
 				// Batch move
@@ -377,19 +379,20 @@ func (m *FolderInbox) wrapInboxCmd(cmd tea.Cmd) tea.Cmd {
 }
 
 func (m *FolderInbox) updateMoveOverlay(msg tea.Msg) (tea.Model, tea.Cmd) {
+	kb := config.Keybinds
 	switch msg := msg.(type) {
 	case tea.KeyPressMsg:
 		switch msg.String() {
-		case "esc":
+		case kb.Global.Cancel:
 			m.movingEmail = false
 			return m, nil
-		case "up", "k":
+		case "up", kb.Global.NavUp:
 			m.moveTargetIdx--
 			if m.moveTargetIdx < 0 {
 				m.moveTargetIdx = len(m.moveFolderChoices()) - 1
 			}
 			return m, nil
-		case "down", "j":
+		case "down", kb.Global.NavDown:
 			m.moveTargetIdx++
 			choices := m.moveFolderChoices()
 			if m.moveTargetIdx >= len(choices) {

tui/inbox.go 🔗

@@ -529,8 +529,9 @@ func (m *Inbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			}
 			break
 		}
+		kb := config.Keybinds
 		switch keypress := msg.String(); keypress {
-		case "v":
+		case kb.Inbox.VisualMode:
 			if !m.visualMode {
 				// Enter visual mode
 				m.visualMode = true
@@ -551,16 +552,16 @@ func (m *Inbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				m.updateListTitle()
 			}
 			return m, nil
-		case "esc":
+		case kb.Global.Cancel:
 			if m.visualMode {
-				// Exit visual mode on ESC
+				// Exit visual mode on cancel key
 				m.visualMode = false
 				m.selectedUIDs = make(map[uint32]string)
 				m.selectionOrder = []uint32{}
 				m.updateListTitle()
 				return m, nil
 			}
-		case "j", "down", "k", "up":
+		case kb.Global.NavDown, "down", kb.Global.NavUp, "up":
 			if m.visualMode {
 				// Let the list handle navigation first
 				var cmd tea.Cmd
@@ -569,7 +570,7 @@ func (m *Inbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				m.updateVisualSelection()
 				return m, cmd
 			}
-		case "left", "h":
+		case "left", kb.Inbox.PrevTab:
 			if len(m.tabs) > 1 {
 				m.activeTabIndex--
 				if m.activeTabIndex < 0 {
@@ -583,7 +584,7 @@ func (m *Inbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				m.updateList()
 				return m, nil
 			}
-		case "right", "l":
+		case "right", kb.Inbox.NextTab:
 			if len(m.tabs) > 1 {
 				m.activeTabIndex++
 				if m.activeTabIndex >= len(m.tabs) {
@@ -597,7 +598,7 @@ func (m *Inbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				m.updateList()
 				return m, nil
 			}
-		case "d":
+		case kb.Inbox.Delete:
 			if m.visualMode && len(m.selectedUIDs) > 0 {
 				// Batch delete
 				uids := make([]uint32, len(m.selectionOrder))
@@ -626,7 +627,7 @@ func (m *Inbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 					}
 				}
 			}
-		case "a":
+		case kb.Inbox.Archive:
 			if m.visualMode && len(m.selectedUIDs) > 0 {
 				// Batch archive
 				uids := make([]uint32, len(m.selectionOrder))
@@ -655,7 +656,7 @@ func (m *Inbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 					}
 				}
 			}
-		case "r":
+		case kb.Inbox.Refresh:
 			m.isRefreshing = true
 			m.list.Title = m.getTitle()
 			// Copy counts to avoid race conditions if used elsewhere (though here it's just passing data)
@@ -666,7 +667,7 @@ func (m *Inbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			return m, func() tea.Msg {
 				return RequestRefreshMsg{Mailbox: m.mailbox, Counts: counts}
 			}
-		case "enter":
+		case kb.Inbox.Open:
 			selectedItem, ok := m.list.SelectedItem().(item)
 			if ok {
 				idx := selectedItem.originalIndex

tui/mailing_list.go 🔗

@@ -6,6 +6,7 @@ import (
 	"charm.land/bubbles/v2/textinput"
 	tea "charm.land/bubbletea/v2"
 	"charm.land/lipgloss/v2"
+	"github.com/floatpane/matcha/config"
 )
 
 // MailingListEditor displays the screen to add or edit a mailing list.
@@ -66,10 +67,11 @@ func (m *MailingListEditor) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		return m, nil
 
 	case tea.KeyPressMsg:
+		kb := config.Keybinds
 		switch msg.String() {
-		case "ctrl+c":
+		case kb.Global.Quit:
 			return m, tea.Quit
-		case "esc":
+		case kb.Global.Cancel:
 			return m, func() tea.Msg { return GoToSettingsMsg{} }
 		case "tab", "shift+tab", "up", "down":
 			if m.focus == 0 {

tui/marketplace.go 🔗

@@ -8,6 +8,7 @@ import (
 
 	tea "charm.land/bubbletea/v2"
 	"charm.land/lipgloss/v2"
+	"github.com/floatpane/matcha/config"
 	"github.com/floatpane/matcha/plugins"
 	"github.com/floatpane/matcha/theme"
 )
@@ -115,8 +116,9 @@ func (m Marketplace) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		return m, nil
 
 	case tea.KeyPressMsg:
+		kb := config.Keybinds
 		if m.state != marketplaceReady {
-			if msg.String() == "q" || msg.String() == "esc" || msg.String() == "ctrl+c" {
+			if msg.String() == "q" || msg.String() == kb.Global.Cancel || msg.String() == kb.Global.Quit {
 				if m.standalone {
 					return m, tea.Quit
 				}
@@ -126,21 +128,21 @@ func (m Marketplace) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		}
 
 		switch msg.String() {
-		case "q", "esc":
+		case "q", kb.Global.Cancel:
 			if m.standalone {
 				return m, tea.Quit
 			}
 			return m, func() tea.Msg { return GoToChoiceMenuMsg{} }
-		case "ctrl+c":
+		case kb.Global.Quit:
 			return m, tea.Quit
-		case "up", "k":
+		case "up", kb.Global.NavUp:
 			if m.cursor > 0 {
 				m.cursor--
 				if m.cursor < m.offset {
 					m.offset = m.cursor
 				}
 			}
-		case "down", "j":
+		case "down", kb.Global.NavDown:
 			if m.cursor < len(m.entries)-1 {
 				m.cursor++
 				visible := m.visibleRows()