summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/appconfig/app_sections.go4
-rw-r--r--internal/appconfig/config_load.go18
-rw-r--r--internal/appconfig/config_merge.go8
-rw-r--r--internal/appconfig/config_types.go25
-rw-r--r--internal/hexaiaction/run.go6
-rw-r--r--internal/hexaiaction/tui.go156
-rw-r--r--internal/hexaiaction/tui_config_test.go176
7 files changed, 379 insertions, 14 deletions
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)
+ }
+}