From 4ffb20f8c86d535c79352c55b14f676a82d8efa1 Mon Sep 17 00:00:00 2001 From: Jon Gallant <2163001+jongio@users.noreply.github.com> Date: Fri, 3 Jul 2026 11:16:58 -0700 Subject: [PATCH 1/3] feat(tui): add configurable keybindings via config Add a "keybindings" config map that remaps keyboard shortcuts. Keys are action names (search, quit, cmd_palette, etc.) and values are comma-separated key lists. Listed actions replace their default keys; unlisted actions keep their defaults. Unknown action names and keys that collide with another action are skipped with a logged warning, so a bad entry never leaves an action unreachable. Refactor keys.go so the package key map is built by defaultKeyMap() and add a registry of remappable actions plus a pure applyKeybindingOverrides that resolves conflicts deterministically. NewModel applies user overrides to the global key map before the UI reads it. Closes #210 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 38 ++++- docs/keybindings.md | 6 + internal/config/config.go | 7 + internal/tui/keys.go | 332 +++++++++++++++++++++++++++++++------- internal/tui/keys_test.go | 225 ++++++++++++++++++++++++++ internal/tui/model.go | 10 ++ 6 files changed, 559 insertions(+), 59 deletions(-) create mode 100644 internal/tui/keys_test.go diff --git a/README.md b/README.md index 371aa80..f40b83b 100644 --- a/README.md +++ b/README.md @@ -310,6 +310,7 @@ Configuration is stored in the platform-specific config directory: | `ai_search` | bool | `false` | Enable Copilot SDK-powered AI semantic search | | `hiddenSessions` | array | `[]` | Session IDs hidden from the main list | | `favoriteSessions` | array | `[]` | Session IDs starred as favorites | +| `keybindings` | object | `{}` | Remap keyboard shortcuts. Keys are action names, values are comma-separated key lists (see [Customizing Keybindings](#customizing-keybindings)) | #### Pane Direction Semantics @@ -349,7 +350,8 @@ When `launch_mode` is `"pane"`, the `pane_direction` value maps to Windows Termi "notify_on_waiting": false, "ai_search": false, "hiddenSessions": [], - "favoriteSessions": [] + "favoriteSessions": [], + "keybindings": {} } ``` @@ -361,6 +363,40 @@ Set `custom_command` to replace the default Copilot CLI launch entirely. Use `{s "custom_command": "my-tool resume {sessionId}" ``` +### Customizing Keybindings + +Set `keybindings` in `config.json` to remap keyboard shortcuts. Each key is an +action name and each value is a comma-separated list of keys that trigger it. +Listed actions replace their default keys; any action you do not list keeps its +default. Unknown action names are ignored, and if a remap collides with a key +another action already uses, that remap is dropped and the default is kept. + +```json +"keybindings": { + "search": "/,ctrl+f", + "cmd_palette": "ctrl+k", + "quit": "q" +} +``` + +Key names follow Bubble Tea conventions: single characters (`a`, `/`, `?`), +named keys (`up`, `down`, `left`, `right`, `enter`, `esc`, `tab`, `space`, +`pgup`, `pgdown`), and modifier combinations (`ctrl+f`, `alt+left`, `shift+tab`). + +Available action names: + +`up`, `down`, `left`, `right`, `enter`, `space`, `quit`, `force_quit`, +`search`, `escape`, `filter`, `sort`, `sort_order`, `pivot`, `pivot_order`, +`preview`, `reindex`, `help`, `config`, `time_range_1`, `time_range_2`, +`time_range_3`, `time_range_4`, `hide`, `toggle_hidden`, `star`, +`launch_window`, `launch_tab`, `launch_pane`, `preview_scroll_up`, +`preview_scroll_down`, `jump_next_attention`, `filter_attention`, `launch_all`, +`select_all`, `deselect_all`, `conversation_sort`, `preview_position`, +`resume_interrupted`, `view_plan`, `copy_id`, `copy_path`, +`copy_resume_command`, `copy_preview`, `expand_collapse_all`, +`scan_work_status`, `export`, `note`, `shift_up`, `shift_down`, `view_switch`, +`open_file`, `open_dir`, `timeline`, `compare`, `cmd_palette`. + ## Themes Five built-in color schemes: diff --git a/docs/keybindings.md b/docs/keybindings.md index a64e378..81c1ae6 100644 --- a/docs/keybindings.md +++ b/docs/keybindings.md @@ -2,6 +2,12 @@ # Dispatch TUI (Bubble Tea v2 Go Application) # Directory: internal\tui +> **Customizing keybindings:** The keys below are the defaults. You can remap any +> of them with the `keybindings` object in `config.json`. Each entry maps an +> action name to a comma-separated key list, for example `"search": "/,ctrl+f"`. +> See the "Customizing Keybindings" section of the README for the full list of +> action names and the rules for conflict handling. + ## KEYBOARD SHORTCUTS - GLOBAL (Always Available) ### Force Quit (Works in All States) diff --git a/internal/config/config.go b/internal/config/config.go index e1ebfb1..a72c61e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -218,6 +218,13 @@ type Config struct { // every scan and not for sessions already waiting when dispatch starts. NotifyOnWaiting bool `json:"notify_on_waiting,omitempty"` + // Keybindings remaps keyboard shortcuts. Keys are action names (see the + // README for the full list, e.g. "search", "quit", "preview") and values + // are comma-separated key lists (e.g. "/,ctrl+f"). Listed actions replace + // their default keys; unlisted actions keep their defaults. Unknown action + // names and keys that collide with another action are ignored. + Keybindings map[string]string `json:"keybindings,omitempty"` + // Theme is the active color scheme name. "auto" (or empty) means // detect from the terminal; any other value is looked up in Schemes // and then the built-in scheme list. diff --git a/internal/tui/keys.go b/internal/tui/keys.go index 7e3e4eb..50af351 100644 --- a/internal/tui/keys.go +++ b/internal/tui/keys.go @@ -1,6 +1,12 @@ package tui -import "charm.land/bubbles/v2/key" +import ( + "fmt" + "sort" + "strings" + + "charm.land/bubbles/v2/key" +) // keyMap holds all key bindings used by the root model. // It implements help.KeyMap for automatic help text generation. @@ -82,61 +88,271 @@ func (k keyMap) FullHelp() [][]key.Binding { } } -var keys = keyMap{ - Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")), - Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")), - Left: key.NewBinding(key.WithKeys("left"), key.WithHelp("←", "collapse")), - Right: key.NewBinding(key.WithKeys("right"), key.WithHelp("→", "expand")), - Enter: key.NewBinding(key.WithKeys("enter"), key.WithHelp("⏎", "launch/toggle")), - Space: key.NewBinding(key.WithKeys("space"), key.WithHelp("space", "toggle select")), - Quit: key.NewBinding(key.WithKeys("q"), key.WithHelp("q", "quit")), - ForceQuit: key.NewBinding(key.WithKeys("ctrl+c")), - Search: key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "search")), - Escape: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "back / clear")), - Filter: key.NewBinding(key.WithKeys("f"), key.WithHelp("f", "filter panel")), - Sort: key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "cycle sort")), - SortOrder: key.NewBinding(key.WithKeys("S"), key.WithHelp("S", "toggle sort order")), - Pivot: key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "cycle pivot")), - PivotOrder: key.NewBinding(key.WithKeys("shift+tab"), key.WithHelp("shift+tab", "reverse pivot order")), - Preview: key.NewBinding(key.WithKeys("p"), key.WithHelp("p", "toggle preview")), - Reindex: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "rebuild index")), - Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")), - Config: key.NewBinding(key.WithKeys(","), key.WithHelp(",", "settings")), - TimeRange1: key.NewBinding(key.WithKeys("1"), key.WithHelp("1", "1 hour")), - TimeRange2: key.NewBinding(key.WithKeys("2"), key.WithHelp("2", "1 day")), - TimeRange3: key.NewBinding(key.WithKeys("3"), key.WithHelp("3", "7 days")), - TimeRange4: key.NewBinding(key.WithKeys("4"), key.WithHelp("4", "all time")), - Hide: key.NewBinding(key.WithKeys("h"), key.WithHelp("h", "hide session")), - ToggleHidden: key.NewBinding(key.WithKeys("H"), key.WithHelp("H", "show hidden")), - Star: key.NewBinding(key.WithKeys("*"), key.WithHelp("*", "toggle favorite")), - LaunchWindow: key.NewBinding(key.WithKeys("w"), key.WithHelp("w", "open in window")), - LaunchTab: key.NewBinding(key.WithKeys("t"), key.WithHelp("t", "open in tab")), - LaunchPane: key.NewBinding(key.WithKeys("e"), key.WithHelp("e", "open in pane")), - PreviewScrollUp: key.NewBinding(key.WithKeys("pgup"), key.WithHelp("PgUp", "preview ↑")), - PreviewScrollDown: key.NewBinding(key.WithKeys("pgdown"), key.WithHelp("PgDn", "preview ↓")), - JumpNextAttention: key.NewBinding(key.WithKeys("n"), key.WithHelp("n", "next waiting")), - FilterAttention: key.NewBinding(key.WithKeys("!"), key.WithHelp("!", "filter by status")), - LaunchAll: key.NewBinding(key.WithKeys("L"), key.WithHelp("L", "launch selected")), - SelectAll: key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "select all")), - DeselectAll: key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "deselect all")), - ConversationSort: key.NewBinding(key.WithKeys("o"), key.WithHelp("o", "conversation order")), - PreviewPosition: key.NewBinding(key.WithKeys("P"), key.WithHelp("P", "cycle preview position")), - ResumeInterrupted: key.NewBinding(key.WithKeys("N"), key.WithHelp("N", "resume interrupted")), - ViewPlan: key.NewBinding(key.WithKeys("v"), key.WithHelp("v", "view plan")), - CopyID: key.NewBinding(key.WithKeys("c"), key.WithHelp("c", "copy session ID")), - CopyPath: key.NewBinding(key.WithKeys("C"), key.WithHelp("C", "copy path")), - CopyResumeCommand: key.NewBinding(key.WithKeys("Y"), key.WithHelp("Y", "copy resume cmd")), - CopyPreview: key.NewBinding(key.WithKeys("y"), key.WithHelp("y", "copy preview")), - ExpandCollapseAll: key.NewBinding(key.WithKeys("x"), key.WithHelp("x", "expand/collapse all")), - ScanWorkStatus: key.NewBinding(key.WithKeys("R"), key.WithHelp("R", "scan work status")), - Export: key.NewBinding(key.WithKeys("X"), key.WithHelp("X", "export markdown")), - Note: key.NewBinding(key.WithKeys("m"), key.WithHelp("m", "edit note")), - ShiftUp: key.NewBinding(key.WithKeys("shift+up"), key.WithHelp("shift+\u2191", "extend select up")), - ShiftDown: key.NewBinding(key.WithKeys("shift+down"), key.WithHelp("shift+\u2193", "extend select down")), - ViewSwitch: key.NewBinding(key.WithKeys("V"), key.WithHelp("V", "switch view")), - OpenFile: key.NewBinding(key.WithKeys("F"), key.WithHelp("F", "open file")), - OpenDir: key.NewBinding(key.WithKeys("O"), key.WithHelp("O", "open directory")), - Timeline: key.NewBinding(key.WithKeys("T"), key.WithHelp("T", "activity timeline")), - Compare: key.NewBinding(key.WithKeys("D"), key.WithHelp("D", "compare selected")), - CmdPalette: key.NewBinding(key.WithKeys(":"), key.WithHelp(":", "command palette")), +var keys = defaultKeyMap() + +// defaultKeyMap returns the built-in key bindings. It is the starting point +// before any user overrides from config are applied. +func defaultKeyMap() keyMap { + return keyMap{ + Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")), + Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")), + Left: key.NewBinding(key.WithKeys("left"), key.WithHelp("←", "collapse")), + Right: key.NewBinding(key.WithKeys("right"), key.WithHelp("→", "expand")), + Enter: key.NewBinding(key.WithKeys("enter"), key.WithHelp("⏎", "launch/toggle")), + Space: key.NewBinding(key.WithKeys("space"), key.WithHelp("space", "toggle select")), + Quit: key.NewBinding(key.WithKeys("q"), key.WithHelp("q", "quit")), + ForceQuit: key.NewBinding(key.WithKeys("ctrl+c")), + Search: key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "search")), + Escape: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "back / clear")), + Filter: key.NewBinding(key.WithKeys("f"), key.WithHelp("f", "filter panel")), + Sort: key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "cycle sort")), + SortOrder: key.NewBinding(key.WithKeys("S"), key.WithHelp("S", "toggle sort order")), + Pivot: key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "cycle pivot")), + PivotOrder: key.NewBinding(key.WithKeys("shift+tab"), key.WithHelp("shift+tab", "reverse pivot order")), + Preview: key.NewBinding(key.WithKeys("p"), key.WithHelp("p", "toggle preview")), + Reindex: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "rebuild index")), + Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")), + Config: key.NewBinding(key.WithKeys(","), key.WithHelp(",", "settings")), + TimeRange1: key.NewBinding(key.WithKeys("1"), key.WithHelp("1", "1 hour")), + TimeRange2: key.NewBinding(key.WithKeys("2"), key.WithHelp("2", "1 day")), + TimeRange3: key.NewBinding(key.WithKeys("3"), key.WithHelp("3", "7 days")), + TimeRange4: key.NewBinding(key.WithKeys("4"), key.WithHelp("4", "all time")), + Hide: key.NewBinding(key.WithKeys("h"), key.WithHelp("h", "hide session")), + ToggleHidden: key.NewBinding(key.WithKeys("H"), key.WithHelp("H", "show hidden")), + Star: key.NewBinding(key.WithKeys("*"), key.WithHelp("*", "toggle favorite")), + LaunchWindow: key.NewBinding(key.WithKeys("w"), key.WithHelp("w", "open in window")), + LaunchTab: key.NewBinding(key.WithKeys("t"), key.WithHelp("t", "open in tab")), + LaunchPane: key.NewBinding(key.WithKeys("e"), key.WithHelp("e", "open in pane")), + PreviewScrollUp: key.NewBinding(key.WithKeys("pgup"), key.WithHelp("PgUp", "preview ↑")), + PreviewScrollDown: key.NewBinding(key.WithKeys("pgdown"), key.WithHelp("PgDn", "preview ↓")), + JumpNextAttention: key.NewBinding(key.WithKeys("n"), key.WithHelp("n", "next waiting")), + FilterAttention: key.NewBinding(key.WithKeys("!"), key.WithHelp("!", "filter by status")), + LaunchAll: key.NewBinding(key.WithKeys("L"), key.WithHelp("L", "launch selected")), + SelectAll: key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "select all")), + DeselectAll: key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "deselect all")), + ConversationSort: key.NewBinding(key.WithKeys("o"), key.WithHelp("o", "conversation order")), + PreviewPosition: key.NewBinding(key.WithKeys("P"), key.WithHelp("P", "cycle preview position")), + ResumeInterrupted: key.NewBinding(key.WithKeys("N"), key.WithHelp("N", "resume interrupted")), + ViewPlan: key.NewBinding(key.WithKeys("v"), key.WithHelp("v", "view plan")), + CopyID: key.NewBinding(key.WithKeys("c"), key.WithHelp("c", "copy session ID")), + CopyPath: key.NewBinding(key.WithKeys("C"), key.WithHelp("C", "copy path")), + CopyResumeCommand: key.NewBinding(key.WithKeys("Y"), key.WithHelp("Y", "copy resume cmd")), + CopyPreview: key.NewBinding(key.WithKeys("y"), key.WithHelp("y", "copy preview")), + ExpandCollapseAll: key.NewBinding(key.WithKeys("x"), key.WithHelp("x", "expand/collapse all")), + ScanWorkStatus: key.NewBinding(key.WithKeys("R"), key.WithHelp("R", "scan work status")), + Export: key.NewBinding(key.WithKeys("X"), key.WithHelp("X", "export markdown")), + Note: key.NewBinding(key.WithKeys("m"), key.WithHelp("m", "edit note")), + ShiftUp: key.NewBinding(key.WithKeys("shift+up"), key.WithHelp("shift+\u2191", "extend select up")), + ShiftDown: key.NewBinding(key.WithKeys("shift+down"), key.WithHelp("shift+\u2193", "extend select down")), + ViewSwitch: key.NewBinding(key.WithKeys("V"), key.WithHelp("V", "switch view")), + OpenFile: key.NewBinding(key.WithKeys("F"), key.WithHelp("F", "open file")), + OpenDir: key.NewBinding(key.WithKeys("O"), key.WithHelp("O", "open directory")), + Timeline: key.NewBinding(key.WithKeys("T"), key.WithHelp("T", "activity timeline")), + Compare: key.NewBinding(key.WithKeys("D"), key.WithHelp("D", "compare selected")), + CmdPalette: key.NewBinding(key.WithKeys(":"), key.WithHelp(":", "command palette")), + } +} + +// keybindingEntry pairs a config action name with a pointer to the binding it +// controls on a keyMap. +type keybindingEntry struct { + name string + binding *key.Binding +} + +// keybindingEntries returns every remappable action in display order, each +// paired with a pointer to its binding on km. The names are the identifiers +// users reference in the "keybindings" config map. +func keybindingEntries(km *keyMap) []keybindingEntry { + return []keybindingEntry{ + {"up", &km.Up}, + {"down", &km.Down}, + {"left", &km.Left}, + {"right", &km.Right}, + {"enter", &km.Enter}, + {"space", &km.Space}, + {"quit", &km.Quit}, + {"force_quit", &km.ForceQuit}, + {"search", &km.Search}, + {"escape", &km.Escape}, + {"filter", &km.Filter}, + {"sort", &km.Sort}, + {"sort_order", &km.SortOrder}, + {"pivot", &km.Pivot}, + {"pivot_order", &km.PivotOrder}, + {"preview", &km.Preview}, + {"reindex", &km.Reindex}, + {"help", &km.Help}, + {"config", &km.Config}, + {"time_range_1", &km.TimeRange1}, + {"time_range_2", &km.TimeRange2}, + {"time_range_3", &km.TimeRange3}, + {"time_range_4", &km.TimeRange4}, + {"hide", &km.Hide}, + {"toggle_hidden", &km.ToggleHidden}, + {"star", &km.Star}, + {"launch_window", &km.LaunchWindow}, + {"launch_tab", &km.LaunchTab}, + {"launch_pane", &km.LaunchPane}, + {"preview_scroll_up", &km.PreviewScrollUp}, + {"preview_scroll_down", &km.PreviewScrollDown}, + {"jump_next_attention", &km.JumpNextAttention}, + {"filter_attention", &km.FilterAttention}, + {"launch_all", &km.LaunchAll}, + {"select_all", &km.SelectAll}, + {"deselect_all", &km.DeselectAll}, + {"conversation_sort", &km.ConversationSort}, + {"preview_position", &km.PreviewPosition}, + {"resume_interrupted", &km.ResumeInterrupted}, + {"view_plan", &km.ViewPlan}, + {"copy_id", &km.CopyID}, + {"copy_path", &km.CopyPath}, + {"copy_resume_command", &km.CopyResumeCommand}, + {"copy_preview", &km.CopyPreview}, + {"expand_collapse_all", &km.ExpandCollapseAll}, + {"scan_work_status", &km.ScanWorkStatus}, + {"export", &km.Export}, + {"note", &km.Note}, + {"shift_up", &km.ShiftUp}, + {"shift_down", &km.ShiftDown}, + {"view_switch", &km.ViewSwitch}, + {"open_file", &km.OpenFile}, + {"open_dir", &km.OpenDir}, + {"timeline", &km.Timeline}, + {"compare", &km.Compare}, + {"cmd_palette", &km.CmdPalette}, + } +} + +// keybindingActionNames returns the remappable action names in display order. +func keybindingActionNames() []string { + km := defaultKeyMap() + entries := keybindingEntries(&km) + names := make([]string, len(entries)) + for i, e := range entries { + names[i] = e.name + } + return names +} + +// parseKeyList splits a comma-separated key list into trimmed, de-duplicated +// key strings, dropping empty entries while preserving order. +func parseKeyList(csv string) []string { + seen := make(map[string]struct{}) + var out []string + for _, part := range strings.Split(csv, ",") { + k := strings.TrimSpace(part) + if k == "" { + continue + } + if _, dup := seen[k]; dup { + continue + } + seen[k] = struct{}{} + out = append(out, k) + } + return out +} + +// applyKeybindingOverrides returns a copy of base with the requested overrides +// applied, plus a list of human-readable warnings. Unknown action names, +// entries with no valid keys, and entries whose keys collide with another +// action are skipped and left at their defaults. base is not modified. +func applyKeybindingOverrides(base keyMap, overrides map[string]string) (keyMap, []string) { + km := base // value copy; individual bindings are replaced, never mutated in place + entries := keybindingEntries(&km) + + byName := make(map[string]*key.Binding, len(entries)) + defaults := make(map[string][]string, len(entries)) + desc := make(map[string]string, len(entries)) + order := make([]string, 0, len(entries)) + for _, e := range entries { + byName[e.name] = e.binding + defaults[e.name] = append([]string(nil), e.binding.Keys()...) + desc[e.name] = e.binding.Help().Desc + order = append(order, e.name) + } + + var warnings []string + + // Parse requested overrides in sorted order for deterministic warnings. + requested := make([]string, 0, len(overrides)) + for name := range overrides { + requested = append(requested, name) + } + sort.Strings(requested) + + parsed := make(map[string][]string) + for _, name := range requested { + if _, ok := byName[name]; !ok { + warnings = append(warnings, fmt.Sprintf("keybindings: unknown action %q (ignored)", name)) + continue + } + ks := parseKeyList(overrides[name]) + if len(ks) == 0 { + warnings = append(warnings, fmt.Sprintf("keybindings: action %q has no valid keys (ignored)", name)) + continue + } + parsed[name] = ks + } + + // Resolve conflicts. An override is invalid when any of its keys is also + // bound by another action in the effective set. Reverting an override to + // its default is safe because the built-in bindings never collide, so the + // loop terminates once no override remains in conflict. + for { + effective := make(map[string][]string, len(order)) + for _, name := range order { + if ks, ok := parsed[name]; ok { + effective[name] = ks + } else { + effective[name] = defaults[name] + } + } + + owners := make(map[string]map[string]struct{}) + for _, name := range order { + for _, k := range effective[name] { + if owners[k] == nil { + owners[k] = make(map[string]struct{}) + } + owners[k][name] = struct{}{} + } + } + + offending := make(map[string]string) // action -> conflicting key + for _, name := range order { + if _, ok := parsed[name]; !ok { + continue // only overrides can be reverted + } + for _, k := range parsed[name] { + if len(owners[k]) > 1 { + offending[name] = k + break + } + } + } + if len(offending) == 0 { + break + } + + revert := make([]string, 0, len(offending)) + for name := range offending { + revert = append(revert, name) + } + sort.Strings(revert) + for _, name := range revert { + warnings = append(warnings, fmt.Sprintf("keybindings: action %q key %q conflicts with another action (using default)", name, offending[name])) + delete(parsed, name) + } + } + + // Apply the surviving overrides, keeping each action's help description. + for name, ks := range parsed { + b := byName[name] + *b = key.NewBinding(key.WithKeys(ks...), key.WithHelp(strings.Join(ks, "/"), desc[name])) + } + + return km, warnings } + diff --git a/internal/tui/keys_test.go b/internal/tui/keys_test.go new file mode 100644 index 0000000..288d1eb --- /dev/null +++ b/internal/tui/keys_test.go @@ -0,0 +1,225 @@ +package tui + +import ( + "strings" + "testing" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" +) + +func keysEqual(b key.Binding, want ...string) bool { + got := b.Keys() + if len(got) != len(want) { + return false + } + for i := range got { + if got[i] != want[i] { + return false + } + } + return true +} + +func warningsContain(warnings []string, substr string) bool { + for _, w := range warnings { + if strings.Contains(w, substr) { + return true + } + } + return false +} + +func TestApplyKeybindingOverrides_NoOverrides(t *testing.T) { + km, warnings := applyKeybindingOverrides(defaultKeyMap(), nil) + if len(warnings) != 0 { + t.Errorf("warnings = %v, want none", warnings) + } + if !keysEqual(km.Search, "/") { + t.Errorf("Search keys = %v, want [/]", km.Search.Keys()) + } + if !keysEqual(km.Quit, "q") { + t.Errorf("Quit keys = %v, want [q]", km.Quit.Keys()) + } +} + +func TestApplyKeybindingOverrides_SimpleRemap(t *testing.T) { + km, warnings := applyKeybindingOverrides(defaultKeyMap(), map[string]string{ + "search": "/,ctrl+f", + }) + if len(warnings) != 0 { + t.Errorf("warnings = %v, want none", warnings) + } + if !keysEqual(km.Search, "/", "ctrl+f") { + t.Errorf("Search keys = %v, want [/ ctrl+f]", km.Search.Keys()) + } + // The default binding must not leak into the returned map's other fields. + if !keysEqual(km.Quit, "q") { + t.Errorf("Quit keys = %v, want [q]", km.Quit.Keys()) + } +} + +func TestApplyKeybindingOverrides_PreservesDescription(t *testing.T) { + km, _ := applyKeybindingOverrides(defaultKeyMap(), map[string]string{ + "search": "ctrl+f", + }) + if got := km.Search.Help().Desc; got != "search" { + t.Errorf("Search help desc = %q, want %q", got, "search") + } + if got := km.Search.Help().Key; got != "ctrl+f" { + t.Errorf("Search help key = %q, want %q", got, "ctrl+f") + } +} + +func TestApplyKeybindingOverrides_UnknownAction(t *testing.T) { + km, warnings := applyKeybindingOverrides(defaultKeyMap(), map[string]string{ + "bogus_action": "z", + }) + if !warningsContain(warnings, "unknown action") { + t.Errorf("warnings = %v, want an unknown action warning", warnings) + } + // Nothing should change. + if !keysEqual(km.Search, "/") { + t.Errorf("Search keys = %v, want unchanged [/]", km.Search.Keys()) + } +} + +func TestApplyKeybindingOverrides_EmptyValue(t *testing.T) { + km, warnings := applyKeybindingOverrides(defaultKeyMap(), map[string]string{ + "search": " ", + }) + if !warningsContain(warnings, "no valid keys") { + t.Errorf("warnings = %v, want a no-valid-keys warning", warnings) + } + if !keysEqual(km.Search, "/") { + t.Errorf("Search keys = %v, want unchanged [/]", km.Search.Keys()) + } +} + +func TestApplyKeybindingOverrides_ConflictWithDefaultReverts(t *testing.T) { + // "q" is quit's default; remapping search onto it must revert search. + km, warnings := applyKeybindingOverrides(defaultKeyMap(), map[string]string{ + "search": "q", + }) + if !warningsContain(warnings, "conflicts") { + t.Errorf("warnings = %v, want a conflict warning", warnings) + } + if !keysEqual(km.Search, "/") { + t.Errorf("Search keys = %v, want reverted [/]", km.Search.Keys()) + } + if !keysEqual(km.Quit, "q") { + t.Errorf("Quit keys = %v, want [q]", km.Quit.Keys()) + } +} + +func TestApplyKeybindingOverrides_SwapWithoutConflict(t *testing.T) { + // Moving quit off "q" frees it for search in the same batch. + km, warnings := applyKeybindingOverrides(defaultKeyMap(), map[string]string{ + "quit": "Q", + "search": "q", + }) + if len(warnings) != 0 { + t.Errorf("warnings = %v, want none", warnings) + } + if !keysEqual(km.Quit, "Q") { + t.Errorf("Quit keys = %v, want [Q]", km.Quit.Keys()) + } + if !keysEqual(km.Search, "q") { + t.Errorf("Search keys = %v, want [q]", km.Search.Keys()) + } +} + +func TestApplyKeybindingOverrides_TwoOverridesConflict(t *testing.T) { + // Two overrides claiming the same free key both revert to defaults. + km, warnings := applyKeybindingOverrides(defaultKeyMap(), map[string]string{ + "search": "z", + "filter": "z", + }) + if !warningsContain(warnings, "conflicts") { + t.Errorf("warnings = %v, want conflict warnings", warnings) + } + if !keysEqual(km.Search, "/") { + t.Errorf("Search keys = %v, want reverted [/]", km.Search.Keys()) + } + if !keysEqual(km.Filter, "f") { + t.Errorf("Filter keys = %v, want reverted [f]", km.Filter.Keys()) + } +} + +func TestApplyKeybindingOverrides_DeduplicatesKeys(t *testing.T) { + km, warnings := applyKeybindingOverrides(defaultKeyMap(), map[string]string{ + "search": "ctrl+f, ctrl+f", + }) + if len(warnings) != 0 { + t.Errorf("warnings = %v, want none", warnings) + } + if !keysEqual(km.Search, "ctrl+f") { + t.Errorf("Search keys = %v, want deduped [ctrl+f]", km.Search.Keys()) + } +} + +func TestApplyKeybindingOverrides_DoesNotMutateBase(t *testing.T) { + base := defaultKeyMap() + _, _ = applyKeybindingOverrides(base, map[string]string{"search": "ctrl+f"}) + if !keysEqual(base.Search, "/") { + t.Errorf("base Search keys = %v, want unchanged [/]", base.Search.Keys()) + } +} + +func TestKeybindingActionNames(t *testing.T) { + names := keybindingActionNames() + km := defaultKeyMap() + want := len(keybindingEntries(&km)) + if len(names) != want { + t.Errorf("action name count = %d, want %d", len(names), want) + } + seen := make(map[string]struct{}) + for _, n := range names { + if _, dup := seen[n]; dup { + t.Errorf("duplicate action name %q", n) + } + seen[n] = struct{}{} + } + for _, required := range []string{"search", "quit", "preview", "filter"} { + if _, ok := seen[required]; !ok { + t.Errorf("action names missing %q", required) + } + } +} + +func TestApplyKeybindingOverrides_MatchesRemappedKey(t *testing.T) { + km, _ := applyKeybindingOverrides(defaultKeyMap(), map[string]string{"search": "z"}) + if !key.Matches(tea.KeyPressMsg{Code: 'z'}, km.Search) { + t.Error("remapped search binding should match 'z'") + } + if key.Matches(tea.KeyPressMsg{Code: '/'}, km.Search) { + t.Error("search should no longer match its old '/' key") + } +} + +func TestParseKeyList(t *testing.T) { + tests := []struct { + in string + want []string + }{ + {"", nil}, + {" ", nil}, + {"/", []string{"/"}}, + {"/,ctrl+f", []string{"/", "ctrl+f"}}, + {" a , b ,, c ", []string{"a", "b", "c"}}, + {"x,x", []string{"x"}}, + } + for _, tt := range tests { + got := parseKeyList(tt.in) + if len(got) != len(tt.want) { + t.Errorf("parseKeyList(%q) = %v, want %v", tt.in, got, tt.want) + continue + } + for i := range got { + if got[i] != tt.want[i] { + t.Errorf("parseKeyList(%q) = %v, want %v", tt.in, got, tt.want) + break + } + } + } +} diff --git a/internal/tui/model.go b/internal/tui/model.go index 15f595e..d8e2ee4 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -401,6 +401,16 @@ func NewModel() Model { // ── Theme resolution ──────────────────────────────────────────── resolveTheme(cfg) + // ── Keybinding overrides ──────────────────────────────────────── + // Apply user remaps to the global key map before the UI reads it. + if len(cfg.Keybindings) > 0 { + remapped, warnings := applyKeybindingOverrides(defaultKeyMap(), cfg.Keybindings) + keys = remapped + for _, w := range warnings { + slog.Warn(w) + } + } + s := spinner.New() s.Spinner = spinner.Dot s.Style = styles.SpinnerStyle From c9a6cdda68b34da222cbfd6554c9e948c6c747e0 Mon Sep 17 00:00:00 2001 From: Jon Gallant <2163001+jongio@users.noreply.github.com> Date: Fri, 3 Jul 2026 11:20:17 -0700 Subject: [PATCH 2/3] style(tui): gofmt keys.go Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- internal/tui/keys.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/tui/keys.go b/internal/tui/keys.go index 50af351..fd687cd 100644 --- a/internal/tui/keys.go +++ b/internal/tui/keys.go @@ -355,4 +355,3 @@ func applyKeybindingOverrides(base keyMap, overrides map[string]string) (keyMap, return km, warnings } - From 063ab65345f5c8586c43efbf48391e1cfcb37aff Mon Sep 17 00:00:00 2001 From: Jon Gallant <2163001+jongio@users.noreply.github.com> Date: Fri, 3 Jul 2026 11:25:37 -0700 Subject: [PATCH 3/3] refactor(tui): drop test-only keybindingActionNames helper The deadcode check flags helpers used only by tests. Derive the action name set directly from keybindingEntries in the test instead. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- internal/tui/keys.go | 11 ----------- internal/tui/keys_test.go | 14 +++++--------- 2 files changed, 5 insertions(+), 20 deletions(-) diff --git a/internal/tui/keys.go b/internal/tui/keys.go index fd687cd..f884a6c 100644 --- a/internal/tui/keys.go +++ b/internal/tui/keys.go @@ -224,17 +224,6 @@ func keybindingEntries(km *keyMap) []keybindingEntry { } } -// keybindingActionNames returns the remappable action names in display order. -func keybindingActionNames() []string { - km := defaultKeyMap() - entries := keybindingEntries(&km) - names := make([]string, len(entries)) - for i, e := range entries { - names[i] = e.name - } - return names -} - // parseKeyList splits a comma-separated key list into trimmed, de-duplicated // key strings, dropping empty entries while preserving order. func parseKeyList(csv string) []string { diff --git a/internal/tui/keys_test.go b/internal/tui/keys_test.go index 288d1eb..158804d 100644 --- a/internal/tui/keys_test.go +++ b/internal/tui/keys_test.go @@ -167,18 +167,14 @@ func TestApplyKeybindingOverrides_DoesNotMutateBase(t *testing.T) { } func TestKeybindingActionNames(t *testing.T) { - names := keybindingActionNames() km := defaultKeyMap() - want := len(keybindingEntries(&km)) - if len(names) != want { - t.Errorf("action name count = %d, want %d", len(names), want) - } + entries := keybindingEntries(&km) seen := make(map[string]struct{}) - for _, n := range names { - if _, dup := seen[n]; dup { - t.Errorf("duplicate action name %q", n) + for _, e := range entries { + if _, dup := seen[e.name]; dup { + t.Errorf("duplicate action name %q", e.name) } - seen[n] = struct{}{} + seen[e.name] = struct{}{} } for _, required := range []string{"search", "quit", "preview", "filter"} { if _, ok := seen[required]; !ok {