diff options
| author | Paul Buetow <paul@buetow.org> | 2025-09-14 23:40:26 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2025-09-14 23:40:26 +0300 |
| commit | f4470bbcfbe3b14c99baeef475fe872825a13a39 (patch) | |
| tree | e12fc6168d21119dfff3a0fef5b6c5b54149f3ab /internal/hexaiaction | |
| parent | 68438c98d23545ff791768e3e219cd21d3814e0c (diff) | |
release: v0.10.0v0.10.0
Diffstat (limited to 'internal/hexaiaction')
| -rw-r--r-- | internal/hexaiaction/custom_exec_more_test.go | 48 | ||||
| -rw-r--r-- | internal/hexaiaction/custom_exec_test.go | 29 | ||||
| -rw-r--r-- | internal/hexaiaction/prompts.go | 15 | ||||
| -rw-r--r-- | internal/hexaiaction/run.go | 20 | ||||
| -rw-r--r-- | internal/hexaiaction/tui_custom.go | 76 | ||||
| -rw-r--r-- | internal/hexaiaction/tui_custom_test.go | 77 |
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 +} |
