summaryrefslogtreecommitdiff
path: root/internal/hexaiaction
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2025-09-14 23:40:26 +0300
committerPaul Buetow <paul@buetow.org>2025-09-14 23:40:26 +0300
commitf4470bbcfbe3b14c99baeef475fe872825a13a39 (patch)
treee12fc6168d21119dfff3a0fef5b6c5b54149f3ab /internal/hexaiaction
parent68438c98d23545ff791768e3e219cd21d3814e0c (diff)
release: v0.10.0v0.10.0
Diffstat (limited to 'internal/hexaiaction')
-rw-r--r--internal/hexaiaction/custom_exec_more_test.go48
-rw-r--r--internal/hexaiaction/custom_exec_test.go29
-rw-r--r--internal/hexaiaction/prompts.go15
-rw-r--r--internal/hexaiaction/run.go20
-rw-r--r--internal/hexaiaction/tui_custom.go76
-rw-r--r--internal/hexaiaction/tui_custom_test.go77
6 files changed, 264 insertions, 1 deletions
diff --git a/internal/hexaiaction/custom_exec_more_test.go b/internal/hexaiaction/custom_exec_more_test.go
new file mode 100644
index 0000000..657d0d8
--- /dev/null
+++ b/internal/hexaiaction/custom_exec_more_test.go
@@ -0,0 +1,48 @@
+package hexaiaction
+
+import (
+ "context"
+ "strings"
+ "testing"
+
+ "codeberg.org/snonux/hexai/internal/appconfig"
+ "codeberg.org/snonux/hexai/internal/llm"
+)
+
+// capDoer captures last LLM messages for assertions.
+type capDoer struct{ last []llm.Message }
+func (c *capDoer) Chat(_ context.Context, msgs []llm.Message, _ ...llm.RequestOption) (string, error) { c.last = append([]llm.Message{}, msgs...); return "OK", nil }
+func (*capDoer) DefaultModel() string { return "m" }
+
+func TestExecuteAction_Custom_ClearsSelection(t *testing.T) {
+ cfg := appconfig.Load(nil)
+ parts := InputParts{Selection: "code"}
+ selectedCustom = &appconfig.CustomAction{ID: "x", Title: "X", Instruction: "Do it"}
+ _, _ = executeAction(context.Background(), ActionCustom, parts, cfg, fakeDoer{"OK"}, nil)
+ if selectedCustom != nil {
+ t.Fatalf("expected selectedCustom cleared after execution")
+ }
+}
+
+func TestRunCustom_UserTemplate_InjectsDiagnostics(t *testing.T) {
+ cfg := appconfig.Load(nil)
+ parts := InputParts{Selection: "code", Diagnostics: []string{"L1", "L2"}}
+ ca := appconfig.CustomAction{ID: "y", Title: "Y", User: "{{diagnostics}}\n{{selection}}"}
+ cap := &capDoer{}
+ _, err := runCustom(context.Background(), cfg, cap, ca, parts)
+ if err != nil { t.Fatalf("runCustom error: %v", err) }
+ if len(cap.last) == 0 {
+ t.Fatalf("expected messages captured")
+ }
+ // user message should contain diagnostics and selection
+ found := false
+ for _, m := range cap.last {
+ if m.Role == "user" && strings.Contains(m.Content, "L1") && strings.Contains(m.Content, "code") {
+ found = true
+ }
+ }
+ if !found {
+ t.Fatalf("expected diagnostics and selection in user message: %+v", cap.last)
+ }
+}
+
diff --git a/internal/hexaiaction/custom_exec_test.go b/internal/hexaiaction/custom_exec_test.go
new file mode 100644
index 0000000..4b7b09d
--- /dev/null
+++ b/internal/hexaiaction/custom_exec_test.go
@@ -0,0 +1,29 @@
+package hexaiaction
+
+import (
+ "context"
+ "strings"
+ "testing"
+
+ "codeberg.org/snonux/hexai/internal/appconfig"
+)
+
+func TestExecuteAction_CustomConfigured_Instruction(t *testing.T) {
+ cfg := appconfig.Load(nil)
+ parts := InputParts{Selection: "code"}
+ selectedCustom = &appconfig.CustomAction{ID: "x", Title: "X", Instruction: "Do it"}
+ out, err := executeAction(context.Background(), ActionCustom, parts, cfg, fakeDoer{"OK"}, nil)
+ if err != nil || strings.TrimSpace(out) != "OK" {
+ t.Fatalf("custom-instruction failed: %q %v", out, err)
+ }
+}
+
+func TestExecuteAction_CustomConfigured_User(t *testing.T) {
+ cfg := appconfig.Load(nil)
+ parts := InputParts{Selection: "sel"}
+ selectedCustom = &appconfig.CustomAction{ID: "y", Title: "Y", User: "Apply to: {{selection}}"}
+ out, err := executeAction(context.Background(), ActionCustom, parts, cfg, fakeDoer{"OK2"}, nil)
+ if err != nil || strings.TrimSpace(out) != "OK2" {
+ t.Fatalf("custom-user failed: %q %v", out, err)
+ }
+}
diff --git a/internal/hexaiaction/prompts.go b/internal/hexaiaction/prompts.go
index e9d7fc6..97af32f 100644
--- a/internal/hexaiaction/prompts.go
+++ b/internal/hexaiaction/prompts.go
@@ -71,6 +71,21 @@ func runGoTest(ctx context.Context, cfg appconfig.App, client chatDoer, funcCode
return runOnceWithOpts(ctx, client, sys, user, reqOptsFrom(cfg))
}
+func runCustom(ctx context.Context, cfg appconfig.App, client chatDoer, ca appconfig.CustomAction, parts InputParts) (string, error) {
+ // If user template is provided, prefer it and optional system
+ if strings.TrimSpace(ca.User) != "" {
+ sys := cfg.PromptCodeActionRewriteSystem
+ if strings.TrimSpace(ca.System) != "" {
+ sys = ca.System
+ }
+ // Currently only selection is available in tmux path; diagnostics list not wired
+ user := Render(ca.User, map[string]string{"selection": parts.Selection, "diagnostics": strings.Join(parts.Diagnostics, "\n")})
+ return runOnceWithOpts(ctx, client, sys, user, reqOptsFrom(cfg))
+ }
+ // Else, use fixed instruction through rewrite template
+ return runRewrite(ctx, cfg, client, ca.Instruction, parts.Selection)
+}
+
func runOnce(ctx context.Context, client chatDoer, sys, user string) (string, error) {
msgs := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}}
start := time.Now()
diff --git a/internal/hexaiaction/run.go b/internal/hexaiaction/run.go
index 4958642..d32edbf 100644
--- a/internal/hexaiaction/run.go
+++ b/internal/hexaiaction/run.go
@@ -21,9 +21,21 @@ var (
newClientFromApp = llmutils.NewClientFromApp
)
+// selectedCustom carries the chosen custom action (if any) from the TUI submenu
+// to the executor. Cleared after use.
+var selectedCustom *appconfig.CustomAction
+
func Run(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer) error {
logger := log.New(stderr, "hexai-tmux-action ", log.LstdFlags|log.Lmsgprefix)
cfg := appconfig.Load(logger)
+ if err := cfg.Validate(); err != nil {
+ fmt.Fprintf(stderr, logging.AnsiBase+"hexai-tmux-action: %v"+logging.AnsiReset+"\n", err)
+ return err
+ }
+ // Enable custom action submenu with configurable hotkey
+ if len(cfg.CustomActions) > 0 {
+ chooseActionFn = func() (ActionKind, error) { return RunTUIWithCustom(cfg.CustomActions, cfg.TmuxCustomMenuHotkey) }
+ }
cli, err := newClientFromApp(cfg)
if err != nil {
fmt.Fprintf(stderr, logging.AnsiBase+"hexai-tmux-action: LLM disabled: %v"+logging.AnsiReset+"\n", err)
@@ -83,7 +95,13 @@ func executeAction(ctx context.Context, kind ActionKind, parts InputParts, cfg a
case ActionCustom:
cctx, cancel := timeout10s(ctx)
defer cancel()
- // Open editor for free-form instruction
+ if selectedCustom != nil {
+ // Run configured custom action
+ out, err := runCustom(cctx, cfg, client, *selectedCustom, parts)
+ selectedCustom = nil // clear after use
+ return out, err
+ }
+ // Fallback: open editor for free-form instruction
prompt, err := editor.OpenTempAndEdit(nil)
if err != nil || strings.TrimSpace(prompt) == "" {
fmt.Fprintln(stderr, logging.AnsiBase+"hexai-tmux-action: custom prompt canceled or empty; echoing input"+logging.AnsiReset)
diff --git a/internal/hexaiaction/tui_custom.go b/internal/hexaiaction/tui_custom.go
new file mode 100644
index 0000000..91d4b81
--- /dev/null
+++ b/internal/hexaiaction/tui_custom.go
@@ -0,0 +1,76 @@
+package hexaiaction
+
+import (
+ "unicode/utf8"
+
+ "github.com/charmbracelet/bubbles/list"
+ tea "github.com/charmbracelet/bubbletea"
+
+ "codeberg.org/snonux/hexai/internal/appconfig"
+)
+
+// RunTUIWithCustom shows the main menu plus a configurable "Custom actions…" item.
+// If the user selects that item, it shows a submenu listing user-defined custom actions.
+// On picking one, it sets selectedCustom and returns ActionCustom.
+func RunTUIWithCustom(customs []appconfig.CustomAction, menuHotkey string) (ActionKind, error) {
+ // When no customs, fall back to default menu
+ if len(customs) == 0 {
+ return RunTUI()
+ }
+ // Build main menu with an extra entry
+ hk := 'a'
+ if r, _ := utf8.DecodeRuneInString(menuHotkey); r != utf8.RuneError && r != 0 {
+ hk = r
+ }
+ // Create a model with default items plus Custom actions…
+ m := newModel()
+ items := m.list.Items()
+ items = append(items, item{title: "Custom actions…", desc: "", kind: ActionCustom, hotkey: hk})
+ m.list.SetItems(items)
+ // Run main menu
+ p := teaNewProgram(m)
+ md, err := p.Run()
+ if err != nil {
+ return ActionSkip, err
+ }
+ if mm, ok := md.(model); ok {
+ if mm.chosen != ActionCustom {
+ return mm.chosen, nil
+ }
+ }
+ // Custom submenu: list each action; select one maps to ActionCustom and sets global
+ sub := newModel()
+ subItems := make([]list.Item, 0, len(customs))
+ for _, ca := range customs {
+ r := rune(0)
+ if rr, _ := utf8.DecodeRuneInString(ca.Hotkey); rr != utf8.RuneError && rr != 0 {
+ r = rr
+ }
+ subItems = append(subItems, item{title: ca.Title, desc: "", kind: ActionCustom, hotkey: r})
+ }
+ sub.list.SetItems(subItems)
+ sp := teaNewProgram(sub)
+ smd, err := sp.Run()
+ if err != nil {
+ return ActionSkip, err
+ }
+ if sm, ok := smd.(model); ok {
+ if it, ok := sm.list.SelectedItem().(item); ok {
+ // Map by title
+ for i := range customs {
+ if customs[i].Title == it.title {
+ c := customs[i]
+ selectedCustom = &c
+ return ActionCustom, nil
+ }
+ }
+ }
+ }
+ return ActionSkip, nil
+}
+
+// teaNewProgram is a tiny seam for tests to stub bubbletea program creation.
+var teaNewProgram = func(m model) teaProgram { return tea.NewProgram(m) }
+
+// teaProgram is the subset of bubbletea.Program we need; enables testing seam.
+type teaProgram interface{ Run() (tea.Model, error) }
diff --git a/internal/hexaiaction/tui_custom_test.go b/internal/hexaiaction/tui_custom_test.go
new file mode 100644
index 0000000..f5995af
--- /dev/null
+++ b/internal/hexaiaction/tui_custom_test.go
@@ -0,0 +1,77 @@
+package hexaiaction
+
+import (
+ "testing"
+
+ tea "github.com/charmbracelet/bubbletea"
+
+ "codeberg.org/snonux/hexai/internal/appconfig"
+)
+
+type fakeProg struct {
+ m model
+ onRun func(*model)
+}
+
+func (f fakeProg) Run() (tea.Model, error) {
+ if f.onRun != nil {
+ f.onRun(&f.m)
+ }
+ return f.m, nil
+}
+
+func TestRunTUIWithCustom_SubmenuAndHotkeys(t *testing.T) {
+ old := teaNewProgram
+ t.Cleanup(func() { teaNewProgram = old })
+
+ calls := 0
+ teaNewProgram = func(m model) teaProgram {
+ calls++
+ if calls == 1 {
+ // Main menu should have "Custom actions…" with configured hotkey
+ items := m.list.Items()
+ if len(items) == 0 {
+ t.Fatalf("main menu items empty")
+ }
+ last := items[len(items)-1].(item)
+ if last.title != "Custom actions…" || string(last.hotkey) != "z" {
+ t.Fatalf("custom menu entry wrong: title=%q hotkey=%q", last.title, string(last.hotkey))
+ }
+ return fakeProg{m: m, onRun: func(mm *model) { mm.chosen = ActionCustom }}
+ }
+ if calls == 2 {
+ // Submenu should list our custom actions with mapped hotkeys
+ items := m.list.Items()
+ if len(items) != 2 {
+ t.Fatalf("submenu expected 2 items, got %d", len(items))
+ }
+ a := items[0].(item)
+ b := items[1].(item)
+ if a.title != "A" || string(a.hotkey) != "x" {
+ t.Fatalf("first submenu item wrong: %q/%q", a.title, string(a.hotkey))
+ }
+ if b.title != "B" || string(b.hotkey) != "y" {
+ t.Fatalf("second submenu item wrong: %q/%q", b.title, string(b.hotkey))
+ }
+ // Simulate selecting first item
+ return fakeProg{m: m, onRun: func(mm *model) { mm.list.Select(0) }}
+ }
+ return fakeProg{m: m}
+ }
+
+ customs := []appconfig.CustomAction{
+ {ID: "a", Title: "A", Hotkey: "x", Instruction: "do"},
+ {ID: "b", Title: "B", Hotkey: "y", Instruction: "do2"},
+ }
+ kind, err := RunTUIWithCustom(customs, "z")
+ if err != nil {
+ t.Fatalf("RunTUIWithCustom error: %v", err)
+ }
+ if kind != ActionCustom {
+ t.Fatalf("expected ActionCustom, got %s", kind)
+ }
+ if selectedCustom == nil || selectedCustom.ID != "a" {
+ t.Fatalf("expected selectedCustom a, got %+v", selectedCustom)
+ }
+ selectedCustom = nil
+}