summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-03-02 14:54:08 +0200
committerPaul Buetow <paul@buetow.org>2026-03-02 14:54:08 +0200
commitf80462176d1ad0daf20b05d6410074369148c245 (patch)
tree5cbe412797c923a881333bd89d59d238be646ad7 /internal
parent5f8e008fc2e5a9abb8bc2c8cfe66ec49cce3a19d (diff)
hexaiaction: replace global action state with Runner struct (task 406)
Diffstat (limited to 'internal')
-rw-r--r--internal/hexaiaction/custom_action_test.go14
-rw-r--r--internal/hexaiaction/custom_exec_more_test.go10
-rw-r--r--internal/hexaiaction/custom_exec_test.go8
-rw-r--r--internal/hexaiaction/run.go97
-rw-r--r--internal/hexaiaction/run_more_test.go8
-rw-r--r--internal/hexaiaction/run_seam_test.go19
-rw-r--r--internal/hexaiaction/run_test.go8
-rw-r--r--internal/hexaiaction/tui_custom.go19
-rw-r--r--internal/hexaiaction/tui_custom_test.go7
9 files changed, 109 insertions, 81 deletions
diff --git a/internal/hexaiaction/custom_action_test.go b/internal/hexaiaction/custom_action_test.go
index 4808079..74ac350 100644
--- a/internal/hexaiaction/custom_action_test.go
+++ b/internal/hexaiaction/custom_action_test.go
@@ -22,12 +22,12 @@ func (llmFake2) DefaultModel() string { return "m" }
func TestActionCustom_UsesEditorPrompt(t *testing.T) {
// Isolate from user config that might enable custom menu/TUI.
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
- // Seam: choose custom, fake client, and fake editor
- oldChoose := chooseActionFn
- oldNew := newClientFromApp
- chooseActionFn = func() (ActionKind, error) { return ActionCustomPrompt, nil }
- newClientFromApp = func(_ appconfig.App) (llm.Client, error) { return llmFake2{}, nil }
- t.Cleanup(func() { chooseActionFn = oldChoose; newClientFromApp = oldNew })
+ // Seam: choose custom, fake client, and fake editor.
+ runner := NewRunner()
+ runner.chooseAction = func(_ appconfig.App) (actionChoice, error) {
+ return actionChoice{kind: ActionCustomPrompt}, nil
+ }
+ runner.newClient = func(_ appconfig.App) (actionClient, error) { return llmFake2{}, nil }
oldRunEd := editor.RunEditor
editor.RunEditor = func(_ string, path string) error {
@@ -39,7 +39,7 @@ func TestActionCustom_UsesEditorPrompt(t *testing.T) {
in := bytes.NewBufferString("some code")
var out bytes.Buffer
var errb bytes.Buffer
- if err := Run(context.Background(), in, &out, &errb); err != nil {
+ if err := runner.Run(context.Background(), in, &out, &errb); err != nil {
t.Fatalf("Run: %v", err)
}
if out.String() == "" {
diff --git a/internal/hexaiaction/custom_exec_more_test.go b/internal/hexaiaction/custom_exec_more_test.go
index de45d26..28bbb97 100644
--- a/internal/hexaiaction/custom_exec_more_test.go
+++ b/internal/hexaiaction/custom_exec_more_test.go
@@ -18,13 +18,13 @@ func (c *capDoer) Chat(_ context.Context, msgs []llm.Message, _ ...llm.RequestOp
}
func (*capDoer) DefaultModel() string { return "m" }
-func TestExecuteAction_Custom_ClearsSelection(t *testing.T) {
+func TestExecuteAction_Custom_DoesNotMutateProvidedSelection(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")
+ selectedCustom := &appconfig.CustomAction{ID: "x", Title: "X", Instruction: "Do it"}
+ _, _ = executeAction(context.Background(), ActionCustom, parts, cfg, fakeDoer{"OK"}, nil, selectedCustom)
+ if selectedCustom == nil {
+ t.Fatalf("expected provided selectedCustom to remain local state")
}
}
diff --git a/internal/hexaiaction/custom_exec_test.go b/internal/hexaiaction/custom_exec_test.go
index 4b7b09d..24f549e 100644
--- a/internal/hexaiaction/custom_exec_test.go
+++ b/internal/hexaiaction/custom_exec_test.go
@@ -11,8 +11,8 @@ import (
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)
+ selectedCustom := &appconfig.CustomAction{ID: "x", Title: "X", Instruction: "Do it"}
+ out, err := executeAction(context.Background(), ActionCustom, parts, cfg, fakeDoer{"OK"}, nil, selectedCustom)
if err != nil || strings.TrimSpace(out) != "OK" {
t.Fatalf("custom-instruction failed: %q %v", out, err)
}
@@ -21,8 +21,8 @@ func TestExecuteAction_CustomConfigured_Instruction(t *testing.T) {
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)
+ selectedCustom := &appconfig.CustomAction{ID: "y", Title: "Y", User: "Apply to: {{selection}}"}
+ out, err := executeAction(context.Background(), ActionCustom, parts, cfg, fakeDoer{"OK2"}, nil, selectedCustom)
if err != nil || strings.TrimSpace(out) != "OK2" {
t.Fatalf("custom-user failed: %q %v", out, err)
}
diff --git a/internal/hexaiaction/run.go b/internal/hexaiaction/run.go
index 625f40a..bf36f2f 100644
--- a/internal/hexaiaction/run.go
+++ b/internal/hexaiaction/run.go
@@ -16,18 +16,48 @@ import (
"codeberg.org/snonux/hexai/internal/tmux"
)
-// Run executes the hexai-tmux-action command flow.
-// seams for testability
-var (
- chooseActionFn = RunTUI
- newClientFromApp = llmutils.NewClientFromApp
-)
-
type configPathKey struct{}
-// selectedCustom carries the chosen custom action (if any) from the TUI submenu
-// to the executor. Cleared after use.
-var selectedCustom *appconfig.CustomAction
+type actionChoice struct {
+ kind ActionKind
+ custom *appconfig.CustomAction
+}
+
+type actionChooser func(cfg appconfig.App) (actionChoice, error)
+
+type actionClient interface {
+ chatDoer
+ Name() string
+}
+
+type actionClientFactory func(cfg appconfig.App) (actionClient, error)
+
+// Runner executes action requests with injectable dependencies for testability.
+type Runner struct {
+ chooseAction actionChooser
+ newClient actionClientFactory
+}
+
+// NewRunner builds a Runner with production dependencies.
+func NewRunner() *Runner {
+ return &Runner{
+ chooseAction: chooseActionFromConfig,
+ newClient: defaultActionClientFactory,
+ }
+}
+
+func chooseActionFromConfig(cfg appconfig.App) (actionChoice, error) {
+ if len(cfg.CustomActions) == 0 {
+ kind, err := RunTUI()
+ return actionChoice{kind: kind}, err
+ }
+ kind, custom, err := RunTUIWithCustom(cfg.CustomActions, cfg.TmuxCustomMenuHotkey)
+ return actionChoice{kind: kind, custom: custom}, err
+}
+
+func defaultActionClientFactory(cfg appconfig.App) (actionClient, error) {
+ return llmutils.NewClientFromApp(cfg)
+}
type actionPlan struct {
fallback string
@@ -59,6 +89,21 @@ func (h codeActionHandler) Resolve(ctx context.Context, plan actionPlan) (string
}
func Run(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer) error {
+ return NewRunner().Run(ctx, stdin, stdout, stderr)
+}
+
+func (r *Runner) Run(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer) error {
+ chooser := chooseActionFromConfig
+ newClient := defaultActionClientFactory
+ if r != nil {
+ if r.chooseAction != nil {
+ chooser = r.chooseAction
+ }
+ if r.newClient != nil {
+ newClient = r.newClient
+ }
+ }
+
logger := log.New(stderr, "hexai-tmux-action ", log.LstdFlags|log.Lmsgprefix)
cfg := appconfig.LoadWithOptions(logger, appconfig.LoadOptions{ConfigPath: configPathFromContext(ctx)})
if cfg.StatsWindowMinutes > 0 {
@@ -68,16 +113,12 @@ func Run(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer) error {
_, _ = 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) }
- }
if len(cfg.CodeActionConfigs) > 0 {
if provider := strings.TrimSpace(cfg.CodeActionConfigs[0].Provider); provider != "" {
cfg.Provider = provider
}
}
- cli, err := newClientFromApp(cfg)
+ cli, err := newClient(cfg)
if err != nil {
_, _ = fmt.Fprintf(stderr, logging.AnsiBase+"hexai-tmux-action: LLM disabled: %v"+logging.AnsiReset+"\n", err)
return err
@@ -96,11 +137,11 @@ func Run(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer) error {
if strings.TrimSpace(parts.Selection) == "" {
return fmt.Errorf("hexai-tmux-action: no input provided on stdin")
}
- kind, err := chooseActionFn()
+ choice, err := chooser(cfg)
if err != nil {
return err
}
- out, err := executeAction(ctx, kind, parts, cfg, client, stderr)
+ out, err := executeAction(ctx, choice.kind, parts, cfg, client, stderr, choice.custom)
if err != nil {
return err
}
@@ -126,7 +167,10 @@ func configPathFromContext(ctx context.Context) string {
return ""
}
-func executeAction(ctx context.Context, kind ActionKind, parts InputParts, cfg actionConfig, client chatDoer, stderr io.Writer) (string, error) {
+func executeAction(ctx context.Context, kind ActionKind, parts InputParts, cfg actionConfig, client chatDoer, stderr io.Writer, selectedCustom *appconfig.CustomAction) (string, error) {
+ if kind == ActionCustom {
+ return handleCustomAction(ctx, parts, cfg, client, selectedCustom)
+ }
handler, ok := codeActionHandlers()[kind]
if !ok {
return parts.Selection, nil
@@ -146,7 +190,6 @@ func codeActionHandlers() map[ActionKind]CodeActionHandler {
ActionDocument: codeActionHandler{build: buildDocumentPlan},
ActionGoTest: codeActionHandler{build: buildGoTestPlan},
ActionSimplify: codeActionHandler{build: buildSimplifyPlan},
- ActionCustom: codeActionHandler{build: buildCustomPlan},
ActionCustomPrompt: codeActionHandler{build: buildCustomPromptPlan},
}
}
@@ -200,15 +243,6 @@ func buildSimplifyPlan(parts InputParts, cfg actionConfig, client chatDoer, _ io
}, true
}
-func buildCustomPlan(parts InputParts, cfg actionConfig, client chatDoer, _ io.Writer) (actionPlan, bool) {
- return actionPlan{
- fallback: parts.Selection,
- run: func(ctx context.Context) (string, error) {
- return handleCustomAction(ctx, parts, cfg, client)
- },
- }, true
-}
-
func buildCustomPromptPlan(parts InputParts, cfg actionConfig, client chatDoer, stderr io.Writer) (actionPlan, bool) {
return actionPlan{
fallback: parts.Selection,
@@ -253,14 +287,13 @@ func handleSimplifyAction(ctx context.Context, parts InputParts, cfg actionConfi
})
}
-func handleCustomAction(ctx context.Context, parts InputParts, cfg actionConfig, client chatDoer) (string, error) {
+func handleCustomAction(ctx context.Context, parts InputParts, cfg actionConfig, client chatDoer, selectedCustom *appconfig.CustomAction) (string, error) {
if selectedCustom == nil {
return parts.Selection, nil
}
+ custom := *selectedCustom
return runWithTimeout(ctx, timeout10s, func(cctx context.Context) (string, error) {
- out, err := runCustom(cctx, cfg, client, *selectedCustom, parts)
- selectedCustom = nil
- return out, err
+ return runCustom(cctx, cfg, client, custom, parts)
})
}
diff --git a/internal/hexaiaction/run_more_test.go b/internal/hexaiaction/run_more_test.go
index 57bd933..0e391ed 100644
--- a/internal/hexaiaction/run_more_test.go
+++ b/internal/hexaiaction/run_more_test.go
@@ -85,20 +85,16 @@ func TestHandleSimplifyActionPassesSelection(t *testing.T) {
}
}
-func TestHandleCustomActionUsesSelectedCustom(t *testing.T) {
+func TestHandleCustomActionUsesProvidedCustom(t *testing.T) {
t.Setenv("HEXAI_TMUX_STATUS", "0")
sel := appconfig.CustomAction{ID: "custom", Title: "Do", Instruction: "do it"}
- selectedCustom = &sel
parts := InputParts{Selection: "text"}
client := &stubChatDoer{}
cfg := appconfig.Load(nil)
- if _, err := handleCustomAction(context.Background(), parts, cfg, client); err != nil {
+ if _, err := handleCustomAction(context.Background(), parts, cfg, client, &sel); err != nil {
t.Fatalf("handleCustomAction: %v", err)
}
if client.calls != 1 {
t.Fatalf("expected custom action to invoke chat, got %d calls", client.calls)
}
- if selectedCustom != nil {
- t.Fatal("expected selectedCustom to be cleared")
- }
}
diff --git a/internal/hexaiaction/run_seam_test.go b/internal/hexaiaction/run_seam_test.go
index 9aa92bf..affd68e 100644
--- a/internal/hexaiaction/run_seam_test.go
+++ b/internal/hexaiaction/run_seam_test.go
@@ -20,27 +20,28 @@ func (llmFake) DefaultModel() string { return "model" }
func TestRun_WithSeams_SkipAndRewrite(t *testing.T) {
// Isolate from user config to avoid environment-dependent behavior/logging.
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
- // Seam: choose action to Skip first, then Rewrite
- oldChoose := chooseActionFn
- oldNew := newClientFromApp
- t.Cleanup(func() { chooseActionFn = oldChoose; newClientFromApp = oldNew })
+ runner := NewRunner()
+ runner.newClient = func(_ appconfig.App) (actionClient, error) { return llmFake{}, nil }
// 1) Skip -> echoes selection
- chooseActionFn = func() (ActionKind, error) { return ActionSkip, nil }
- newClientFromApp = func(_ appconfig.App) (llm.Client, error) { return llmFake{}, nil }
+ runner.chooseAction = func(_ appconfig.App) (actionChoice, error) {
+ return actionChoice{kind: ActionSkip}, nil
+ }
var out bytes.Buffer
var errBuf bytes.Buffer
in := bytes.NewBufferString("some code")
- if err := Run(context.Background(), in, &out, &errBuf); err != nil {
+ if err := runner.Run(context.Background(), in, &out, &errBuf); err != nil {
t.Fatalf("Run skip: %v", err)
}
if out.String() != "some code" {
t.Fatalf("skip out: %q", out.String())
}
// 2) Rewrite -> requires inline instruction
- chooseActionFn = func() (ActionKind, error) { return ActionRewrite, nil }
+ runner.chooseAction = func(_ appconfig.App) (actionChoice, error) {
+ return actionChoice{kind: ActionRewrite}, nil
+ }
out.Reset()
in = bytes.NewBufferString(";upper;\nhello")
- if err := Run(context.Background(), in, &out, &errBuf); err != nil {
+ if err := runner.Run(context.Background(), in, &out, &errBuf); err != nil {
t.Fatalf("Run rewrite: %v", err)
}
if out.String() == "" {
diff --git a/internal/hexaiaction/run_test.go b/internal/hexaiaction/run_test.go
index e28bceb..adc3159 100644
--- a/internal/hexaiaction/run_test.go
+++ b/internal/hexaiaction/run_test.go
@@ -19,7 +19,7 @@ func (f fakeDoer) DefaultModel() string { return "m" }
func TestExecuteAction_Skip(t *testing.T) {
cfg := appconfig.App{}
parts := InputParts{Selection: "data"}
- out, err := executeAction(context.Background(), ActionSkip, parts, cfg, fakeDoer{"IGN"}, nil)
+ out, err := executeAction(context.Background(), ActionSkip, parts, cfg, fakeDoer{"IGN"}, nil, nil)
if err != nil || out != "data" {
t.Fatalf("skip failed: %q %v", out, err)
}
@@ -32,19 +32,19 @@ func TestExecuteAction_Rewrite_Document_GoTest(t *testing.T) {
// rewrite with inline instruction
sel := ";change;\ncode"
- out, err := executeAction(context.Background(), ActionRewrite, InputParts{Selection: sel}, cfg, client, nil)
+ out, err := executeAction(context.Background(), ActionRewrite, InputParts{Selection: sel}, cfg, client, nil, nil)
if err != nil || strings.TrimSpace(out) != "DONE" {
t.Fatalf("rewrite failed: %q %v", out, err)
}
// document
- out, err = executeAction(context.Background(), ActionDocument, InputParts{Selection: "code"}, cfg, client, nil)
+ out, err = executeAction(context.Background(), ActionDocument, InputParts{Selection: "code"}, cfg, client, nil, nil)
if err != nil || strings.TrimSpace(out) != "DONE" {
t.Fatalf("document failed: %q %v", out, err)
}
// go test
- out, err = executeAction(context.Background(), ActionGoTest, InputParts{Selection: "func A(){}"}, cfg, client, nil)
+ out, err = executeAction(context.Background(), ActionGoTest, InputParts{Selection: "func A(){}"}, cfg, client, nil, nil)
if err != nil || strings.TrimSpace(out) != "DONE" {
t.Fatalf("gotest failed: %q %v", out, err)
}
diff --git a/internal/hexaiaction/tui_custom.go b/internal/hexaiaction/tui_custom.go
index fe32588..2e6561b 100644
--- a/internal/hexaiaction/tui_custom.go
+++ b/internal/hexaiaction/tui_custom.go
@@ -11,11 +11,11 @@ import (
// 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) {
+func RunTUIWithCustom(customs []appconfig.CustomAction, menuHotkey string) (ActionKind, *appconfig.CustomAction, error) {
// When no customs, fall back to default menu
if len(customs) == 0 {
- return RunTUI()
+ kind, err := RunTUI()
+ return kind, nil, err
}
// Build main menu with an extra entry
hk := 'a'
@@ -31,15 +31,15 @@ func RunTUIWithCustom(customs []appconfig.CustomAction, menuHotkey string) (Acti
p := teaNewProgram(m)
md, err := p.Run()
if err != nil {
- return ActionSkip, err
+ return ActionSkip, nil, err
}
if mm, ok := md.(model); ok {
// If user chose built-in items (including Custom prompt), return immediately.
if mm.chosen != ActionCustom {
- return mm.chosen, nil
+ return mm.chosen, nil, nil
}
}
- // Custom submenu: list each action; select one maps to ActionCustom and sets global
+ // Custom submenu: list each action; selection maps to ActionCustom and returns that action.
sub := newModel()
subItems := make([]list.Item, 0, len(customs))
for _, ca := range customs {
@@ -53,7 +53,7 @@ func RunTUIWithCustom(customs []appconfig.CustomAction, menuHotkey string) (Acti
sp := teaNewProgram(sub)
smd, err := sp.Run()
if err != nil {
- return ActionSkip, err
+ return ActionSkip, nil, err
}
if sm, ok := smd.(model); ok {
if it, ok := sm.list.SelectedItem().(item); ok {
@@ -61,13 +61,12 @@ func RunTUIWithCustom(customs []appconfig.CustomAction, menuHotkey string) (Acti
for i := range customs {
if customs[i].Title == it.title {
c := customs[i]
- selectedCustom = &c
- return ActionCustom, nil
+ return ActionCustom, &c, nil
}
}
}
}
- return ActionSkip, nil
+ return ActionSkip, nil, nil
}
// teaNewProgram is a tiny seam for tests to stub bubbletea program creation.
diff --git a/internal/hexaiaction/tui_custom_test.go b/internal/hexaiaction/tui_custom_test.go
index f5995af..5ded806 100644
--- a/internal/hexaiaction/tui_custom_test.go
+++ b/internal/hexaiaction/tui_custom_test.go
@@ -63,15 +63,14 @@ func TestRunTUIWithCustom_SubmenuAndHotkeys(t *testing.T) {
{ID: "a", Title: "A", Hotkey: "x", Instruction: "do"},
{ID: "b", Title: "B", Hotkey: "y", Instruction: "do2"},
}
- kind, err := RunTUIWithCustom(customs, "z")
+ kind, selected, 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)
+ if selected == nil || selected.ID != "a" {
+ t.Fatalf("expected selected custom a, got %+v", selected)
}
- selectedCustom = nil
}