From f80462176d1ad0daf20b05d6410074369148c245 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Mon, 2 Mar 2026 14:54:08 +0200 Subject: hexaiaction: replace global action state with Runner struct (task 406) --- internal/hexaiaction/custom_action_test.go | 14 ++-- internal/hexaiaction/custom_exec_more_test.go | 10 +-- internal/hexaiaction/custom_exec_test.go | 8 +-- internal/hexaiaction/run.go | 97 ++++++++++++++++++--------- internal/hexaiaction/run_more_test.go | 8 +-- internal/hexaiaction/run_seam_test.go | 19 +++--- internal/hexaiaction/run_test.go | 8 +-- internal/hexaiaction/tui_custom.go | 19 +++--- internal/hexaiaction/tui_custom_test.go | 7 +- 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 } -- cgit v1.2.3