From a1031b761ff0c5cd79f3f0906e5c3b33fa849f37 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Sat, 25 Apr 2026 16:12:23 +0300 Subject: feat: configurable hexai-tmux-action menu via [[tmux_action.menu]] When [[tmux_action.menu]] is defined in config it fully replaces the built-in menu. Each entry takes a kind (built-in or "custom"), optional title/hotkey overrides, and optional custom_id for embedding custom actions directly in the main menu. Hotkey dispatch is now dynamic so any single-character hotkey works without code changes. Co-Authored-By: Claude Sonnet 4.6 --- internal/appconfig/app_sections.go | 4 + internal/appconfig/config_load.go | 18 ++++ internal/appconfig/config_merge.go | 8 ++ internal/appconfig/config_types.go | 25 +++++ internal/hexaiaction/run.go | 6 ++ internal/hexaiaction/tui.go | 156 +++++++++++++++++++++++++--- internal/hexaiaction/tui_config_test.go | 176 ++++++++++++++++++++++++++++++++ 7 files changed, 379 insertions(+), 14 deletions(-) create mode 100644 internal/hexaiaction/tui_config_test.go (limited to 'internal') diff --git a/internal/appconfig/app_sections.go b/internal/appconfig/app_sections.go index 5426f2a..6dd60c6 100644 --- a/internal/appconfig/app_sections.go +++ b/internal/appconfig/app_sections.go @@ -114,6 +114,8 @@ type FeatureConfig struct { TmuxEditPopupHeight string `json:"-"` TmuxEditDefaultAgent string `json:"-"` TmuxEditAgents []TmuxEditAgentCfg `json:"-"` + // TmuxAction: configurable main menu for hexai-tmux-action + TmuxActionMenu []TmuxActionMenuEntry `json:"-"` // MCP: Model Context Protocol server settings MCPPromptsDir string `json:"-"` // Directory for prompt storage MCPSlashCommandSync bool `json:"-"` // Enable slash command sync @@ -205,6 +207,7 @@ func (a *App) FeatureSection() FeatureConfig { f := a.FeatureConfig f.IgnoreExtraPatterns = slices.Clone(a.IgnoreExtraPatterns) f.TmuxEditAgents = append([]TmuxEditAgentCfg{}, a.TmuxEditAgents...) + f.TmuxActionMenu = append([]TmuxActionMenuEntry{}, a.TmuxActionMenu...) return f } @@ -214,4 +217,5 @@ func (a *App) ApplyFeatureSection(features FeatureConfig) { a.FeatureConfig = features a.IgnoreExtraPatterns = slices.Clone(features.IgnoreExtraPatterns) a.TmuxEditAgents = append([]TmuxEditAgentCfg{}, features.TmuxEditAgents...) + a.TmuxActionMenu = append([]TmuxActionMenuEntry{}, features.TmuxActionMenu...) } diff --git a/internal/appconfig/config_load.go b/internal/appconfig/config_load.go index 88750fd..aa169bc 100644 --- a/internal/appconfig/config_load.go +++ b/internal/appconfig/config_load.go @@ -284,6 +284,24 @@ func applyFeatureSections(fc *fileConfig, out *App) { applyStatsSection(fc, out) fc.applyTmuxEdit(out) applyMCPSection(fc, out) + applyTmuxActionSection(fc, out) +} + +// applyTmuxActionSection converts [[tmux_action.menu]] entries into App fields. +func applyTmuxActionSection(fc *fileConfig, out *App) { + if len(fc.TmuxAction.Menu) == 0 { + return + } + entries := make([]TmuxActionMenuEntry, 0, len(fc.TmuxAction.Menu)) + for _, e := range fc.TmuxAction.Menu { + entries = append(entries, TmuxActionMenuEntry{ + Kind: strings.TrimSpace(e.Kind), + CustomID: strings.TrimSpace(e.CustomID), + Title: strings.TrimSpace(e.Title), + Hotkey: strings.TrimSpace(e.Hotkey), + }) + } + out.TmuxActionMenu = entries } func applyGeneralSection(fc *fileConfig, out *App) { diff --git a/internal/appconfig/config_merge.go b/internal/appconfig/config_merge.go index e31e0dc..7a99c94 100644 --- a/internal/appconfig/config_merge.go +++ b/internal/appconfig/config_merge.go @@ -11,6 +11,7 @@ func (a *App) mergeWith(other *App) { a.mergeSurfaceModels(other) a.mergePrompts(other) a.mergeTmuxEdit(other) + a.mergeTmuxAction(other) } // mergeBasics merges general (non-provider) fields. @@ -227,6 +228,13 @@ func mergeStringField(dst *string, src string) { } } +// mergeTmuxAction replaces the action menu when the incoming config defines one. +func (a *App) mergeTmuxAction(other *App) { + if len(other.TmuxActionMenu) > 0 { + a.TmuxActionMenu = append([]TmuxActionMenuEntry{}, other.TmuxActionMenu...) + } +} + // mergeTmuxEdit copies non-empty tmux edit settings from other. func (a *App) mergeTmuxEdit(other *App) { if s := strings.TrimSpace(other.TmuxEditPopupWidth); s != "" { diff --git a/internal/appconfig/config_types.go b/internal/appconfig/config_types.go index 54f9bcf..5c6cf0c 100644 --- a/internal/appconfig/config_types.go +++ b/internal/appconfig/config_types.go @@ -35,6 +35,19 @@ type CustomAction struct { User string // optional; if set, render with available vars } +// TmuxActionMenuEntry configures a single entry in the hexai-tmux-action menu. +// Set Kind to a built-in action kind (rewrite, simplify, document, gotest, +// fix_typos, custom_prompt, skip) or to "custom" to embed a custom action +// directly in the main menu (requires CustomID referencing a +// [[prompts.code_action.custom]] entry). Title and Hotkey are optional +// overrides; built-in defaults are used when left empty. +type TmuxActionMenuEntry struct { + Kind string // built-in kind or "custom" + CustomID string // used when Kind == "custom", references a custom action by id + Title string // optional title override + Hotkey string // optional single-character hotkey override +} + // TmuxEditAgentCfg describes an AI agent's detection and interaction patterns // for the tmux popup editor (hexai-tmux-edit). type TmuxEditAgentCfg struct { @@ -155,6 +168,7 @@ type fileConfig struct { Stats sectionStats `toml:"stats"` Ignore sectionIgnore `toml:"ignore"` TmuxEdit sectionTmuxEdit `toml:"tmux_edit"` + TmuxAction sectionTmuxAction `toml:"tmux_action"` MCP sectionMCP `toml:"mcp"` } @@ -352,3 +366,14 @@ type sectionCustomAction struct { type sectionTmux struct { CustomMenuHotkey string `toml:"custom_menu_hotkey"` } + +type sectionTmuxAction struct { + Menu []sectionTmuxActionMenuEntry `toml:"menu"` +} + +type sectionTmuxActionMenuEntry struct { + Kind string `toml:"kind"` + CustomID string `toml:"custom_id"` + Title string `toml:"title"` + Hotkey string `toml:"hotkey"` +} diff --git a/internal/hexaiaction/run.go b/internal/hexaiaction/run.go index 0330354..92aa72d 100644 --- a/internal/hexaiaction/run.go +++ b/internal/hexaiaction/run.go @@ -89,6 +89,12 @@ func NewRunner() *Runner { } func chooseActionFromConfig(cfg appconfig.App) (actionChoice, error) { + // Config-driven menu takes priority when defined. + if len(cfg.TmuxActionMenu) > 0 { + kind, custom, err := RunTUIFromConfig(cfg.TmuxActionMenu, cfg.CustomActions) + return actionChoice{kind: kind, custom: custom}, err + } + // Default path: built-in menu, with optional custom-actions submenu. if len(cfg.CustomActions) == 0 { kind, err := RunTUI() return actionChoice{kind: kind}, err diff --git a/internal/hexaiaction/tui.go b/internal/hexaiaction/tui.go index 749b30c..9155cfe 100644 --- a/internal/hexaiaction/tui.go +++ b/internal/hexaiaction/tui.go @@ -3,16 +3,21 @@ package hexaiaction import ( "fmt" "strings" + "unicode/utf8" "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" + + "codeberg.org/snonux/hexai/internal/appconfig" ) -// item implements list.Item +// item implements list.Item; custom is non-nil when the entry is a custom +// action placed directly in the main menu (not in the submenu). type item struct { title, desc string kind ActionKind hotkey rune + custom *appconfig.CustomAction } func (i item) Title() string { return i.title } @@ -20,9 +25,10 @@ func (i item) Description() string { return i.desc } func (i item) FilterValue() string { return i.title } type model struct { - list list.Model - chosen ActionKind - done bool + list list.Model + chosen ActionKind + chosenCustom *appconfig.CustomAction + done bool } func newModel() model { @@ -35,6 +41,10 @@ func newModel() model { item{title: "Custom prompt", desc: "", kind: ActionCustomPrompt, hotkey: 'p'}, item{title: "Skip", desc: "", kind: ActionSkip, hotkey: 's'}, } + return newListModel(items) +} + +func newListModel(items []list.Item) model { l := list.New(items, oneLineDelegate{}, 0, 0) l.SetShowTitle(false) l.SetShowHelp(false) @@ -62,13 +72,13 @@ func handleKey(m model, msg tea.KeyMsg) (tea.Model, tea.Cmd) { low := strings.ToLower(raw) switch low { case "esc", "q": - // Treat ESC and q as Skip/quit m.chosen = ActionSkip m.done = true return m, tea.Quit case "enter": if it, ok := m.list.SelectedItem().(item); ok { m.chosen = it.kind + m.chosenCustom = it.custom m.done = true return m, tea.Quit } @@ -82,14 +92,18 @@ func handleKey(m model, msg tea.KeyMsg) (tea.Model, tea.Cmd) { if n := len(m.list.Items()); n > 0 { m.list.Select(n - 1) } - case "s", "r", "c", "t", "i", "f", "p": - items := m.list.Items() - for i := 0; i < len(items); i++ { - if it, ok := items[i].(item); ok && strings.ToLower(string(it.hotkey)) == low { - m.list.Select(i) - m.chosen = it.kind - m.done = true - return m, tea.Quit + default: + // Dynamic hotkey dispatch: any single-rune key matching an item's hotkey. + if rr := []rune(low); len(rr) == 1 { + items := m.list.Items() + for i, li := range items { + if it, ok := li.(item); ok && it.hotkey != 0 && string(it.hotkey) == low { + m.list.Select(i) + m.chosen = it.kind + m.chosenCustom = it.custom + m.done = true + return m, tea.Quit + } } } } @@ -108,7 +122,7 @@ func (m model) View() string { return m.list.View() } -// RunTUI returns the chosen ActionKind. +// RunTUI returns the chosen ActionKind from the default hardcoded menu. func RunTUI() (ActionKind, error) { p := tea.NewProgram(newModel()) md, err := p.Run() @@ -123,3 +137,117 @@ func RunTUI() (ActionKind, error) { } return ActionSkip, fmt.Errorf("unexpected model type") } + +// RunTUIFromConfig builds the menu from entries and returns the chosen action. +// Custom entries are resolved by ID against customs. Falls back to ActionSkip +// if the program returns an unexpected model type. +func RunTUIFromConfig(entries []appconfig.TmuxActionMenuEntry, customs []appconfig.CustomAction) (ActionKind, *appconfig.CustomAction, error) { + m := newModelFromMenuEntries(entries, customs) + p := teaNewProgram(m) + md, err := p.Run() + if err != nil { + return ActionSkip, nil, err + } + if mm, ok := md.(model); ok { + if mm.chosen == "" { + return ActionSkip, nil, nil + } + return mm.chosen, mm.chosenCustom, nil + } + return ActionSkip, nil, fmt.Errorf("unexpected model type") +} + +// newModelFromMenuEntries builds a model from config-driven menu entries. +// Custom entries are resolved by ID against the provided customs slice; +// entries with unknown IDs are silently skipped. +func newModelFromMenuEntries(entries []appconfig.TmuxActionMenuEntry, customs []appconfig.CustomAction) model { + byID := make(map[string]*appconfig.CustomAction, len(customs)) + for i := range customs { + id := strings.ToLower(strings.TrimSpace(customs[i].ID)) + if id != "" { + byID[id] = &customs[i] + } + } + items := make([]list.Item, 0, len(entries)) + for _, e := range entries { + kind := ActionKind(strings.TrimSpace(e.Kind)) + var ca *appconfig.CustomAction + if kind == ActionCustom { + ca = byID[strings.ToLower(strings.TrimSpace(e.CustomID))] + if ca == nil { + continue + } + } + title := strings.TrimSpace(e.Title) + if title == "" { + title = defaultTitleForKind(kind, ca) + } + hotkey := hotkeyFromString(e.Hotkey) + if hotkey == 0 { + hotkey = defaultHotkeyForKind(kind, ca) + } + items = append(items, item{title: title, kind: kind, hotkey: hotkey, custom: ca}) + } + return newListModel(items) +} + +// defaultTitleForKind returns the built-in display title for a given action kind. +func defaultTitleForKind(kind ActionKind, ca *appconfig.CustomAction) string { + if kind == ActionCustom && ca != nil { + return ca.Title + } + switch kind { + case ActionRewrite: + return "Rewrite selection" + case ActionSimplify: + return "Simplify and improve" + case ActionDocument: + return "Document code" + case ActionGoTest: + return "Generate Go unit test(s)" + case ActionFixTypos: + return "Fix typos and improve grammar and clarity" + case ActionCustomPrompt: + return "Custom prompt" + case ActionSkip: + return "Skip" + } + return string(kind) +} + +// defaultHotkeyForKind returns the built-in hotkey rune for a given action kind. +func defaultHotkeyForKind(kind ActionKind, ca *appconfig.CustomAction) rune { + if kind == ActionCustom && ca != nil { + return hotkeyFromString(ca.Hotkey) + } + switch kind { + case ActionRewrite: + return 'r' + case ActionSimplify: + return 'i' + case ActionDocument: + return 'c' + case ActionGoTest: + return 't' + case ActionFixTypos: + return 'f' + case ActionCustomPrompt: + return 'p' + case ActionSkip: + return 's' + } + return 0 +} + +// hotkeyFromString decodes the first rune from s, returning 0 on empty or error. +func hotkeyFromString(s string) rune { + s = strings.TrimSpace(s) + if s == "" { + return 0 + } + r, _ := utf8.DecodeRuneInString(s) + if r == utf8.RuneError { + return 0 + } + return r +} diff --git a/internal/hexaiaction/tui_config_test.go b/internal/hexaiaction/tui_config_test.go new file mode 100644 index 0000000..e8e178f --- /dev/null +++ b/internal/hexaiaction/tui_config_test.go @@ -0,0 +1,176 @@ +package hexaiaction + +import ( + "testing" + + tea "github.com/charmbracelet/bubbletea" + + "codeberg.org/snonux/hexai/internal/appconfig" +) + +func TestDefaultTitleForKind(t *testing.T) { + cases := []struct { + kind ActionKind + want string + }{ + {ActionRewrite, "Rewrite selection"}, + {ActionSimplify, "Simplify and improve"}, + {ActionDocument, "Document code"}, + {ActionGoTest, "Generate Go unit test(s)"}, + {ActionFixTypos, "Fix typos and improve grammar and clarity"}, + {ActionCustomPrompt, "Custom prompt"}, + {ActionSkip, "Skip"}, + {"unknown", "unknown"}, + } + for _, tc := range cases { + if got := defaultTitleForKind(tc.kind, nil); got != tc.want { + t.Errorf("defaultTitleForKind(%q) = %q, want %q", tc.kind, got, tc.want) + } + } + ca := appconfig.CustomAction{Title: "My Action"} + if got := defaultTitleForKind(ActionCustom, &ca); got != "My Action" { + t.Errorf("custom title: got %q", got) + } +} + +func TestDefaultHotkeyForKind(t *testing.T) { + cases := []struct { + kind ActionKind + want rune + }{ + {ActionRewrite, 'r'}, + {ActionSimplify, 'i'}, + {ActionDocument, 'c'}, + {ActionGoTest, 't'}, + {ActionFixTypos, 'f'}, + {ActionCustomPrompt, 'p'}, + {ActionSkip, 's'}, + {"unknown", 0}, + } + for _, tc := range cases { + if got := defaultHotkeyForKind(tc.kind, nil); got != tc.want { + t.Errorf("defaultHotkeyForKind(%q) = %q, want %q", tc.kind, got, tc.want) + } + } + ca := appconfig.CustomAction{Hotkey: "x"} + if got := defaultHotkeyForKind(ActionCustom, &ca); got != 'x' { + t.Errorf("custom hotkey: got %q", got) + } +} + +func TestHotkeyFromString(t *testing.T) { + if hotkeyFromString("") != 0 { + t.Fatal("empty should be 0") + } + if hotkeyFromString(" ") != 0 { + t.Fatal("blank should be 0") + } + if hotkeyFromString("r") != 'r' { + t.Fatal("r should be r") + } + if hotkeyFromString("rX") != 'r' { + t.Fatal("first rune of rX should be r") + } +} + +func TestNewModelFromMenuEntries_BasicOrder(t *testing.T) { + entries := []appconfig.TmuxActionMenuEntry{ + {Kind: "skip", Hotkey: "s"}, + {Kind: "rewrite", Title: "Redo", Hotkey: "w"}, + } + m := newModelFromMenuEntries(entries, nil) + items := m.list.Items() + if len(items) != 2 { + t.Fatalf("expected 2 items, got %d", len(items)) + } + first := items[0].(item) + if first.kind != ActionSkip || first.hotkey != 's' { + t.Errorf("first item wrong: %+v", first) + } + second := items[1].(item) + if second.kind != ActionRewrite || second.title != "Redo" || second.hotkey != 'w' { + t.Errorf("second item wrong: %+v", second) + } +} + +func TestNewModelFromMenuEntries_CustomAction(t *testing.T) { + customs := []appconfig.CustomAction{ + {ID: "my-action", Title: "My Action", Hotkey: "m", Instruction: "do it"}, + } + entries := []appconfig.TmuxActionMenuEntry{ + {Kind: "custom", CustomID: "my-action"}, + {Kind: "custom", CustomID: "missing-id"}, // should be skipped + } + m := newModelFromMenuEntries(entries, customs) + items := m.list.Items() + if len(items) != 1 { + t.Fatalf("expected 1 item (missing ID skipped), got %d", len(items)) + } + it := items[0].(item) + if it.title != "My Action" || it.hotkey != 'm' || it.custom == nil { + t.Errorf("custom item wrong: %+v", it) + } +} + +func TestNewModelFromMenuEntries_DefaultsApplied(t *testing.T) { + entries := []appconfig.TmuxActionMenuEntry{ + {Kind: "simplify"}, // no title/hotkey → use defaults + } + m := newModelFromMenuEntries(entries, nil) + it := m.list.Items()[0].(item) + if it.title != "Simplify and improve" || it.hotkey != 'i' { + t.Errorf("defaults not applied: %+v", it) + } +} + +func TestHandleKey_DynamicHotkey(t *testing.T) { + entries := []appconfig.TmuxActionMenuEntry{ + {Kind: "simplify", Hotkey: "z"}, // non-default hotkey + {Kind: "skip", Hotkey: "s"}, + } + m := newModelFromMenuEntries(entries, nil) + nm, _ := handleKey(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'z'}}) + got := nm.(model) + if !got.done || got.chosen != ActionSimplify { + t.Fatalf("z should choose simplify: done=%v chosen=%v", got.done, got.chosen) + } +} + +func TestHandleKey_ChosenCustomIsSet(t *testing.T) { + ca := appconfig.CustomAction{ID: "a", Title: "A", Hotkey: "a", Instruction: "x"} + customs := []appconfig.CustomAction{ca} + entries := []appconfig.TmuxActionMenuEntry{ + {Kind: "custom", CustomID: "a"}, + } + m := newModelFromMenuEntries(entries, customs) + nm, _ := handleKey(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'a'}}) + got := nm.(model) + if !got.done || got.chosen != ActionCustom || got.chosenCustom == nil { + t.Fatalf("chosenCustom should be set: done=%v chosen=%v custom=%v", got.done, got.chosen, got.chosenCustom) + } + if got.chosenCustom.ID != "a" { + t.Fatalf("wrong custom: %+v", got.chosenCustom) + } +} + +func TestRunTUIFromConfig_ViaTmuxActionSeam(t *testing.T) { + old := teaNewProgram + t.Cleanup(func() { teaNewProgram = old }) + + teaNewProgram = func(m model) teaProgram { + return fakeProg{m: m, onRun: func(mm *model) { + mm.chosen = ActionSkip + }} + } + + entries := []appconfig.TmuxActionMenuEntry{ + {Kind: "skip", Hotkey: "s"}, + } + kind, custom, err := RunTUIFromConfig(entries, nil) + if err != nil { + t.Fatalf("RunTUIFromConfig: %v", err) + } + if kind != ActionSkip || custom != nil { + t.Fatalf("expected ActionSkip/nil, got %v/%v", kind, custom) + } +} -- cgit v1.2.3