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 | |
| parent | 68438c98d23545ff791768e3e219cd21d3814e0c (diff) | |
release: v0.10.0v0.10.0
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/appconfig/config.go | 148 | ||||
| -rw-r--r-- | internal/appconfig/config_test.go | 128 | ||||
| -rw-r--r-- | internal/appconfig/custom_validation_more_test.go | 48 | ||||
| -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 | ||||
| -rw-r--r-- | internal/hexailsp/run.go | 23 | ||||
| -rw-r--r-- | internal/lsp/codeaction_custom_errors_test.go | 92 | ||||
| -rw-r--r-- | internal/lsp/codeaction_custom_test.go | 110 | ||||
| -rw-r--r-- | internal/lsp/handlers_codeaction.go | 108 | ||||
| -rw-r--r-- | internal/lsp/server.go | 21 | ||||
| -rw-r--r-- | internal/version.go | 2 |
15 files changed, 931 insertions, 14 deletions
diff --git a/internal/appconfig/config.go b/internal/appconfig/config.go index 2c4cee3..b912271 100644 --- a/internal/appconfig/config.go +++ b/internal/appconfig/config.go @@ -85,6 +85,22 @@ type App struct { // CLI PromptCLIDefaultSystem string `json:"-" toml:"-"` PromptCLIExplainSystem string `json:"-" toml:"-"` + + // Custom code actions and tmux integration + CustomActions []CustomAction `json:"-" toml:"-"` + TmuxCustomMenuHotkey string `json:"-" toml:"-"` +} + +// CustomAction describes a user-defined code action. +type CustomAction struct { + ID string + Title string + Kind string // optional; default "refactor" + Scope string // "selection" (default) | "diagnostics" + Hotkey string // optional, used by tmux submenu + Instruction string // optional; if set and User is empty, use global rewrite templates + System string // optional; used only when User is set + User string // optional; if set, render with available vars } // Constructor: defaults for App (kept first among functions) @@ -181,6 +197,7 @@ type fileConfig struct { Copilot sectionCopilot `toml:"copilot"` Ollama sectionOllama `toml:"ollama"` Prompts sectionPrompts `toml:"prompts"` + Tmux sectionTmux `toml:"tmux"` } type sectionGeneral struct { @@ -260,16 +277,17 @@ type sectionPromptsChat struct { } type sectionPromptsCodeAction struct { - RewriteSystem string `toml:"rewrite_system"` - DiagnosticsSystem string `toml:"diagnostics_system"` - DocumentSystem string `toml:"document_system"` - RewriteUser string `toml:"rewrite_user"` - DiagnosticsUser string `toml:"diagnostics_user"` - DocumentUser string `toml:"document_user"` - GoTestSystem string `toml:"go_test_system"` - GoTestUser string `toml:"go_test_user"` - SimplifySystem string `toml:"simplify_system"` - SimplifyUser string `toml:"simplify_user"` + RewriteSystem string `toml:"rewrite_system"` + DiagnosticsSystem string `toml:"diagnostics_system"` + DocumentSystem string `toml:"document_system"` + RewriteUser string `toml:"rewrite_user"` + DiagnosticsUser string `toml:"diagnostics_user"` + DocumentUser string `toml:"document_user"` + GoTestSystem string `toml:"go_test_system"` + GoTestUser string `toml:"go_test_user"` + SimplifySystem string `toml:"simplify_system"` + SimplifyUser string `toml:"simplify_user"` + Custom []sectionCustomAction `toml:"custom"` } type sectionPromptsCLI struct { @@ -281,6 +299,21 @@ type sectionPromptsProviderNative struct { Completion string `toml:"completion"` } +type sectionCustomAction struct { + ID string `toml:"id"` + Title string `toml:"title"` + Kind string `toml:"kind"` + Scope string `toml:"scope"` + Hotkey string `toml:"hotkey"` + Instruction string `toml:"instruction"` + System string `toml:"system"` + User string `toml:"user"` +} + +type sectionTmux struct { + CustomMenuHotkey string `toml:"custom_menu_hotkey"` +} + func (fc *fileConfig) toApp() App { out := App{} @@ -393,7 +426,17 @@ func (fc *fileConfig) toApp() App { out.PromptChatSystem = fc.Prompts.Chat.System } // code action - if (fc.Prompts.CodeAction != sectionPromptsCodeAction{}) { + if strings.TrimSpace(fc.Prompts.CodeAction.RewriteSystem) != "" || + strings.TrimSpace(fc.Prompts.CodeAction.DiagnosticsSystem) != "" || + strings.TrimSpace(fc.Prompts.CodeAction.DocumentSystem) != "" || + strings.TrimSpace(fc.Prompts.CodeAction.RewriteUser) != "" || + strings.TrimSpace(fc.Prompts.CodeAction.DiagnosticsUser) != "" || + strings.TrimSpace(fc.Prompts.CodeAction.DocumentUser) != "" || + strings.TrimSpace(fc.Prompts.CodeAction.GoTestSystem) != "" || + strings.TrimSpace(fc.Prompts.CodeAction.GoTestUser) != "" || + strings.TrimSpace(fc.Prompts.CodeAction.SimplifySystem) != "" || + strings.TrimSpace(fc.Prompts.CodeAction.SimplifyUser) != "" || + len(fc.Prompts.CodeAction.Custom) > 0 { if strings.TrimSpace(fc.Prompts.CodeAction.RewriteSystem) != "" { out.PromptCodeActionRewriteSystem = fc.Prompts.CodeAction.RewriteSystem } @@ -424,6 +467,20 @@ func (fc *fileConfig) toApp() App { if strings.TrimSpace(fc.Prompts.CodeAction.SimplifyUser) != "" { out.PromptCodeActionSimplifyUser = fc.Prompts.CodeAction.SimplifyUser } + if len(fc.Prompts.CodeAction.Custom) > 0 { + for _, ca := range fc.Prompts.CodeAction.Custom { + out.CustomActions = append(out.CustomActions, CustomAction{ + ID: strings.TrimSpace(ca.ID), + Title: strings.TrimSpace(ca.Title), + Kind: strings.TrimSpace(ca.Kind), + Scope: strings.ToLower(strings.TrimSpace(ca.Scope)), + Hotkey: strings.TrimSpace(ca.Hotkey), + Instruction: ca.Instruction, + System: ca.System, + User: ca.User, + }) + } + } } // cli if (fc.Prompts.CLI != sectionPromptsCLI{}) { @@ -439,6 +496,11 @@ func (fc *fileConfig) toApp() App { out.PromptNativeCompletion = fc.Prompts.ProviderNative.Completion } + // tmux + if (fc.Tmux != sectionTmux{}) { + out.TmuxCustomMenuHotkey = strings.TrimSpace(fc.Tmux.CustomMenuHotkey) + } + return out } @@ -639,6 +701,70 @@ func (a *App) mergePrompts(other *App) { if strings.TrimSpace(other.PromptCLIExplainSystem) != "" { a.PromptCLIExplainSystem = other.PromptCLIExplainSystem } + // Custom actions + if len(other.CustomActions) > 0 { + a.CustomActions = append([]CustomAction{}, other.CustomActions...) + } + if strings.TrimSpace(other.TmuxCustomMenuHotkey) != "" { + a.TmuxCustomMenuHotkey = other.TmuxCustomMenuHotkey + } +} + +// Validate checks custom actions and tmux settings for duplicates and consistency. +func (a App) Validate() error { + // Normalize and check duplicates for IDs and hotkeys + seenID := make(map[string]struct{}) + seenHK := make(map[string]struct{}) + for _, ca := range a.CustomActions { + id := strings.ToLower(strings.TrimSpace(ca.ID)) + if id == "" { + return fmt.Errorf("config: custom action missing required field id") + } + if _, ok := seenID[id]; ok { + return fmt.Errorf("config: duplicate custom action id: %s", ca.ID) + } + seenID[id] = struct{}{} + if strings.TrimSpace(ca.Title) == "" { + return fmt.Errorf("config: custom action %s missing required field title", ca.ID) + } + // Validate scope + scope := strings.TrimSpace(ca.Scope) + if scope != "" && scope != "selection" && scope != "diagnostics" { + return fmt.Errorf("config: custom action %s has invalid scope: %s", ca.ID, ca.Scope) + } + // Instruction vs user + hasInstr := strings.TrimSpace(ca.Instruction) != "" + hasUser := strings.TrimSpace(ca.User) != "" + if hasInstr && hasUser { + return fmt.Errorf("config: custom action %s must set either instruction or user, not both", ca.ID) + } + if !hasInstr && !hasUser { + return fmt.Errorf("config: custom action %s requires instruction or user", ca.ID) + } + // Hotkey unique (case-insensitive), one rune if provided + if hk := strings.TrimSpace(ca.Hotkey); hk != "" { + if []rune(hk) == nil || len([]rune(hk)) != 1 { + return fmt.Errorf("config: custom action %s hotkey must be a single character", ca.ID) + } + lhk := strings.ToLower(hk) + if _, ok := seenHK[lhk]; ok { + return fmt.Errorf("config: duplicate custom action hotkey: %s", hk) + } + seenHK[lhk] = struct{}{} + } + } + // Tmux custom menu hotkey validation + if hk := strings.TrimSpace(a.TmuxCustomMenuHotkey); hk != "" { + if len([]rune(hk)) != 1 { + return fmt.Errorf("config: invalid tmux.custom_menu_hotkey: %s", hk) + } + // built-in hotkeys in tmux TUI: r,i,c,t,p,s + switch strings.ToLower(hk) { + case "r", "i", "c", "t", "p", "s": + return fmt.Errorf("config: invalid tmux.custom_menu_hotkey: %s (clashes with built-in)", hk) + } + } + return nil } // mergeProviderFields merges per-provider configuration. diff --git a/internal/appconfig/config_test.go b/internal/appconfig/config_test.go index dc8c39c..b03137e 100644 --- a/internal/appconfig/config_test.go +++ b/internal/appconfig/config_test.go @@ -357,3 +357,131 @@ explain_system = "CLI-EXPLAIN" t.Fatalf("cli prompts wrong: %q %q", cfg.PromptCLIDefaultSystem, cfg.PromptCLIExplainSystem) } } + +func TestCustomActions_ParseAndValidate_OK(t *testing.T) { + clearHexaiEnv(t) + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + cfgPath := filepath.Join(dir, "hexai", "config.toml") + content := ` +[prompts.code_action] + +[[prompts.code_action.custom]] +id = "extract-function" +title = "Extract function" +kind = "refactor.extract" +scope = "selection" +hotkey = "e" +instruction = "Extract the selected code into a new function named 'extracted' and replace with a call. Return only code." + +[[prompts.code_action.custom]] +id = "fix-lints" +title = "Fix linters" +kind = "quickfix" +scope = "diagnostics" +hotkey = "l" +system = "You are a precise code fixer." +user = "Diagnostics to resolve (selection only):\n{{diagnostics}}\n\nSelected code:\n{{selection}}" + +[tmux] +custom_menu_hotkey = "a" +` + writeFile(t, cfgPath, content) + cfg := Load(newLogger()) + if err := cfg.Validate(); err != nil { + t.Fatalf("validate: %v", err) + } + if len(cfg.CustomActions) != 2 { + t.Fatalf("expected 2 custom actions, got %d", len(cfg.CustomActions)) + } + if cfg.TmuxCustomMenuHotkey != "a" { + t.Fatalf("tmux hotkey wrong: %q", cfg.TmuxCustomMenuHotkey) + } + // spot-check mapping + if cfg.CustomActions[0].ID != "extract-function" || cfg.CustomActions[0].Scope != "selection" || cfg.CustomActions[0].Instruction == "" { + t.Fatalf("first action mapping wrong: %+v", cfg.CustomActions[0]) + } + if cfg.CustomActions[1].User == "" || cfg.CustomActions[1].Scope != "diagnostics" { + t.Fatalf("second action mapping wrong: %+v", cfg.CustomActions[1]) + } +} + +func TestCustomActions_DuplicateID_Error(t *testing.T) { + clearHexaiEnv(t) + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + cfgPath := filepath.Join(dir, "hexai", "config.toml") + writeFile(t, cfgPath, ` +[prompts.code_action] +[[prompts.code_action.custom]] +id = "dup" +title = "A" +instruction = "x" +[[prompts.code_action.custom]] +id = "DUP" +title = "B" +instruction = "y" +`) + cfg := Load(newLogger()) + if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "duplicate custom action id") { + t.Fatalf("expected duplicate id error, got %v", err) + } +} + +func TestCustomActions_DuplicateHotkey_Error(t *testing.T) { + clearHexaiEnv(t) + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + cfgPath := filepath.Join(dir, "hexai", "config.toml") + writeFile(t, cfgPath, ` +[prompts.code_action] +[[prompts.code_action.custom]] +id = "a1" +title = "A" +instruction = "x" +hotkey = "e" +[[prompts.code_action.custom]] +id = "a2" +title = "B" +instruction = "y" +hotkey = "E" +`) + cfg := Load(newLogger()) + if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "duplicate custom action hotkey") { + t.Fatalf("expected duplicate hotkey error, got %v", err) + } +} + +func TestCustomActions_InvalidScope_Error(t *testing.T) { + clearHexaiEnv(t) + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + cfgPath := filepath.Join(dir, "hexai", "config.toml") + writeFile(t, cfgPath, ` +[prompts.code_action] +[[prompts.code_action.custom]] +id = "a1" +title = "A" +instruction = "x" +scope = "bad" +`) + cfg := Load(newLogger()) + if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "invalid scope") { + t.Fatalf("expected invalid scope error, got %v", err) + } +} + +func TestTmuxMenuHotkey_Clash_Error(t *testing.T) { + clearHexaiEnv(t) + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + cfgPath := filepath.Join(dir, "hexai", "config.toml") + writeFile(t, cfgPath, ` +[tmux] +custom_menu_hotkey = "r" +`) + cfg := Load(newLogger()) + if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "clashes with built-in") { + t.Fatalf("expected clash error, got %v", err) + } +} diff --git a/internal/appconfig/custom_validation_more_test.go b/internal/appconfig/custom_validation_more_test.go new file mode 100644 index 0000000..05d0d1a --- /dev/null +++ b/internal/appconfig/custom_validation_more_test.go @@ -0,0 +1,48 @@ +package appconfig + +import ( + "path/filepath" + "strings" + "testing" +) + +func TestCustomActions_MissingFields(t *testing.T) { + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + cfgPath := filepath.Join(dir, "hexai", "config.toml") + writeFile(t, cfgPath, ` +[prompts.code_action] +[[prompts.code_action.custom]] +title = "No ID" +instruction = "x" +[[prompts.code_action.custom]] +id = "no-title" +instruction = "x" +`) + cfg := Load(newLogger()) + if err := cfg.Validate(); err == nil || (!strings.Contains(err.Error(), "missing required field id") && !strings.Contains(err.Error(), "missing required field title")) { + t.Fatalf("expected missing field error, got %v", err) + } +} + +func TestCustomActions_InvalidHotkeys(t *testing.T) { + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + cfgPath := filepath.Join(dir, "hexai", "config.toml") + writeFile(t, cfgPath, ` +[prompts.code_action] +[[prompts.code_action.custom]] +id = "a" +title = "A" +instruction = "x" +hotkey = "too" + +[tmux] +custom_menu_hotkey = "ab" +`) + cfg := Load(newLogger()) + if err := cfg.Validate(); err == nil || (!strings.Contains(err.Error(), "hotkey must be a single character") && !strings.Contains(err.Error(), "invalid tmux.custom_menu_hotkey")) { + t.Fatalf("expected invalid hotkey error, got %v", err) + } +} + 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 +} diff --git a/internal/hexailsp/run.go b/internal/hexailsp/run.go index e3dfb28..92548b3 100644 --- a/internal/hexailsp/run.go +++ b/internal/hexailsp/run.go @@ -34,6 +34,9 @@ func Run(logPath string, stdin io.Reader, stdout io.Writer, stderr io.Writer) er } logging.Bind(logger) cfg := appconfig.Load(logger) + if err := cfg.Validate(); err != nil { + logger.Fatalf("invalid config: %v", err) + } return RunWithFactory(logPath, stdin, stdout, logger, cfg, nil, nil) } @@ -41,6 +44,9 @@ func Run(logPath string, stdin io.Reader, stdout io.Writer, stderr io.Writer) er // When factory is nil, lsp.NewServer is used. func RunWithFactory(logPath string, stdin io.Reader, stdout io.Writer, logger *log.Logger, cfg appconfig.App, client llm.Client, factory ServerFactory) error { normalizeLoggingConfig(&cfg) + if err := cfg.Validate(); err != nil { + logger.Fatalf("invalid config: %v", err) + } client = buildClientIfNil(cfg, client) factory = ensureFactory(factory) @@ -106,6 +112,22 @@ func ensureFactory(factory ServerFactory) ServerFactory { } func makeServerOptions(cfg appconfig.App, logContext bool, client llm.Client) lsp.ServerOptions { + // Map custom actions from appconfig to lsp type + var customs []lsp.CustomAction + if len(cfg.CustomActions) > 0 { + customs = make([]lsp.CustomAction, 0, len(cfg.CustomActions)) + for _, ca := range cfg.CustomActions { + customs = append(customs, lsp.CustomAction{ + ID: ca.ID, + Title: ca.Title, + Kind: ca.Kind, + Scope: ca.Scope, + Instruction: ca.Instruction, + System: ca.System, + User: ca.User, + }) + } + } return lsp.ServerOptions{ LogContext: logContext, MaxTokens: cfg.MaxTokens, @@ -142,5 +164,6 @@ func makeServerOptions(cfg appconfig.App, logContext bool, client llm.Client) ls PromptGoTestUser: cfg.PromptCodeActionGoTestUser, PromptSimplifySystem: cfg.PromptCodeActionSimplifySystem, PromptSimplifyUser: cfg.PromptCodeActionSimplifyUser, + CustomActions: customs, } } diff --git a/internal/lsp/codeaction_custom_errors_test.go b/internal/lsp/codeaction_custom_errors_test.go new file mode 100644 index 0000000..2f42f65 --- /dev/null +++ b/internal/lsp/codeaction_custom_errors_test.go @@ -0,0 +1,92 @@ +package lsp + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "testing" + + "codeberg.org/snonux/hexai/internal/llm" +) + +func TestResolveCodeAction_Custom_UnknownID(t *testing.T) { + s := newTestServer() + // No matching custom action configured + s.customActions = []CustomAction{{ID: "known", Title: "Known", Instruction: "x"}} + uri := "file:///t.go" + payload := struct { + Type string `json:"type"` + ID string `json:"id"` + URI string `json:"uri"` + Range Range `json:"range"` + Selection string `json:"selection"` + }{Type: "custom", ID: "missing", URI: uri, Range: Range{}, Selection: "abc"} + raw, _ := json.Marshal(payload) + ca := CodeAction{Title: "Hexai: X", Data: raw} + if _, ok := s.resolveCodeAction(ca); ok { + t.Fatalf("expected resolve to fail for unknown custom id") + } +} + +type errLLM struct{} +func (errLLM) Chat(_ context.Context, _ []llm.Message, _ ...llm.RequestOption) (string, error) { return "", errors.New("boom") } +func (errLLM) Name() string { return "prov" } +func (errLLM) DefaultModel() string { return "m" } + +func TestResolveCodeAction_Custom_EmptyAndError(t *testing.T) { + // empty output case + s1 := newTestServer() + s1.llmClient = fakeLLM{resp: " \n\n"} + s1.customActions = []CustomAction{{ID: "empty", Title: "Empty", Instruction: "x"}} + raw1, _ := json.Marshal(struct{ Type, ID, URI, Selection string; Range Range }{"custom", "empty", "file:///t.go", "sel", Range{}}) + if resolved, ok := s1.resolveCodeAction(CodeAction{Data: raw1}); ok || resolved.Edit != nil { + t.Fatalf("expected no edit for empty llm output") + } + + // error case + s2 := newTestServer() + s2.llmClient = errLLM{} + s2.customActions = []CustomAction{{ID: "err", Title: "Err", Instruction: "x"}} + raw2, _ := json.Marshal(struct{ Type, ID, URI, Selection string; Range Range }{"custom", "err", "file:///t.go", "sel", Range{}}) + if resolved, ok := s2.resolveCodeAction(CodeAction{Data: raw2}); ok || resolved.Edit != nil { + t.Fatalf("expected no edit for llm error") + } +} + +func TestHandleCodeAction_Custom_SelectionSuppressedWhenEmpty(t *testing.T) { + s := newTestServer() + s.llmClient = fakeLLM{resp: "IGN"} + // One selection-scoped and one diagnostics-scoped custom + s.customActions = []CustomAction{ + {ID: "sel", Title: "Sel", Scope: "selection", Instruction: "x"}, + {ID: "diag", Title: "Diag", Scope: "diagnostics", User: "{{diagnostics}}"}, + } + uri := "file:///t.go" + s.setDocument(uri, "package p\nfunc f(){}\n") + // Empty selection range (start==end) + p := CodeActionParams{TextDocument: TextDocumentIdentifier{URI: uri}, Range: Range{Start: Position{Line: 1}, End: Position{Line: 1}}} + // include a diagnostic so diagnostics action is allowed + ctx := CodeActionContext{Diagnostics: []Diagnostic{{Range: Range{Start: Position{Line: 1}}, Message: "x"}}} + rawCtx, _ := json.Marshal(ctx) + p.Context = json.RawMessage(rawCtx) + // Build request + req := Request{JSONRPC: "2.0", ID: json.RawMessage("1"), Method: "textDocument/codeAction"} + req.Params, _ = json.Marshal(p) + // capture + var out bytes.Buffer + s.out = &out + s.handleCodeAction(req) + resp := captureResponse(t, &out) + rb, _ := json.Marshal(resp.Result) + var actions []CodeAction + _ = json.Unmarshal(rb, &actions) + seenSel, seenDiag := false, false + for _, a := range actions { + if a.Title == "Hexai: Sel" { seenSel = true } + if a.Title == "Hexai: Diag" { seenDiag = true } + } + if seenSel || !seenDiag { + t.Fatalf("expected only diagnostics custom when selection is empty; got %+v", actions) + } +} diff --git a/internal/lsp/codeaction_custom_test.go b/internal/lsp/codeaction_custom_test.go new file mode 100644 index 0000000..7baf993 --- /dev/null +++ b/internal/lsp/codeaction_custom_test.go @@ -0,0 +1,110 @@ +package lsp + +import ( + "bytes" + "encoding/json" + "io" + "log" + "strings" + "testing" +) + +// local copy of captureResponse for this test file +func capResp(t *testing.T, buf *bytes.Buffer) Response { + t.Helper() + raw := buf.String() + idx := strings.Index(raw, "\r\n\r\n") + if idx < 0 { + t.Fatalf("no header/body separator in %q", raw) + } + body := raw[idx+4:] + var resp Response + if err := json.Unmarshal([]byte(body), &resp); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + return resp +} + +func TestHandleCodeAction_ListsCustomActions(t *testing.T) { + var out bytes.Buffer + s := &Server{logger: log.New(io.Discard, "", 0), docs: make(map[string]*document), out: &out} + s.llmClient = fakeLLM{resp: "IGN"} + // Inject two custom actions + s.customActions = []CustomAction{ + {ID: "extract", Title: "Extract function", Scope: "selection", Kind: "refactor.extract", Instruction: "Extract into function"}, + {ID: "fix", Title: "Fix diagnostics", Scope: "diagnostics", Kind: "quickfix", User: "Fix:\n{{diagnostics}}\n\n{{selection}}"}, + } + // Prepare document and params + uri := "file:///t.go" + s.setDocument(uri, "package x\n\nfunc f(){}\n") + p := CodeActionParams{TextDocument: TextDocumentIdentifier{URI: uri}, Range: Range{Start: Position{Line: 2}, End: Position{Line: 2, Character: 5}}} + // Include diagnostics context so diagnostics-scoped action appears + ctx := CodeActionContext{Diagnostics: []Diagnostic{{Range: Range{Start: Position{Line: 2}}, Message: "warn"}}} + raw, _ := json.Marshal(ctx) + p.Context = json.RawMessage(raw) + + // Call handler + req := Request{JSONRPC: "2.0", ID: json.RawMessage("1"), Method: "textDocument/codeAction"} + req.Params, _ = json.Marshal(p) + out.Reset() + s.handleCodeAction(req) + resp := capResp(t, &out) + var actions []CodeAction + rb, _ := json.Marshal(resp.Result) + if err := json.Unmarshal(rb, &actions); err != nil { + t.Fatalf("decode: %v", err) + } + var seenSel, seenDiag bool + for _, a := range actions { + if a.Title == "Hexai: Extract function" { + seenSel = true + } + if a.Title == "Hexai: Fix diagnostics" { + seenDiag = true + } + } + if !seenSel || !seenDiag { + t.Fatalf("expected both custom actions, got %+v", actions) + } +} + +func TestResolveCodeAction_CustomInstructionAndUser(t *testing.T) { + s := newTestServer() + s.llmClient = fakeLLM{resp: "REPLACED"} + // one instruction-based and one user-based + s.customActions = []CustomAction{ + {ID: "extract", Title: "Extract function", Scope: "selection", Kind: "refactor.extract", Instruction: "Extract into function"}, + {ID: "fix", Title: "Fix diagnostics", Scope: "diagnostics", Kind: "quickfix", User: "Fix: {{diagnostics}}\n{{selection}}"}, + } + uri := "file:///t.go" + p := CodeActionParams{TextDocument: TextDocumentIdentifier{URI: uri}, Range: Range{Start: Position{Line: 1}, End: Position{Line: 1, Character: 3}}} + + // Build selection-scoped custom action payload + selPayload := struct { + Type string `json:"type"` + ID string `json:"id"` + URI string `json:"uri"` + Range Range `json:"range"` + Selection string `json:"selection"` + }{Type: "custom", ID: "extract", URI: uri, Range: p.Range, Selection: "abc"} + raw1, _ := json.Marshal(selPayload) + ca1 := CodeAction{Title: "Hexai: Extract function", Data: raw1} + if resolved, ok := s.resolveCodeAction(ca1); !ok || resolved.Edit == nil { + t.Fatalf("expected resolve for instruction-based custom action") + } + + // Build diagnostics-scoped custom action payload + diagPayload := struct { + Type string `json:"type"` + ID string `json:"id"` + URI string `json:"uri"` + Range Range `json:"range"` + Selection string `json:"selection"` + Diagnostics []Diagnostic `json:"diagnostics"` + }{Type: "custom", ID: "fix", URI: uri, Range: p.Range, Selection: "abc", Diagnostics: []Diagnostic{{Message: "lint"}}} + raw2, _ := json.Marshal(diagPayload) + ca2 := CodeAction{Title: "Hexai: Fix diagnostics", Data: raw2} + if resolved, ok := s.resolveCodeAction(ca2); !ok || resolved.Edit == nil { + t.Fatalf("expected resolve for user-based custom action") + } +} diff --git a/internal/lsp/handlers_codeaction.go b/internal/lsp/handlers_codeaction.go index e1c2720..9bc3f51 100644 --- a/internal/lsp/handlers_codeaction.go +++ b/internal/lsp/handlers_codeaction.go @@ -31,7 +31,7 @@ func (s *Server) handleCodeAction(req Request) { } sel := extractRangeText(d, p.Range) - actions := make([]CodeAction, 0, 5) + actions := make([]CodeAction, 0, 8) if a := s.buildRewriteCodeAction(p, sel); a != nil { actions = append(actions, *a) } @@ -47,11 +47,65 @@ func (s *Server) handleCodeAction(req Request) { if a := s.buildSimplifyCodeAction(p, sel); a != nil { actions = append(actions, *a) } + // Custom actions from config + s.appendCustomActions(&actions, p, sel) if len(req.ID) != 0 { s.reply(req.ID, actions, nil) } } +// appendCustomActions adds user-defined actions depending on scope and availability. +func (s *Server) appendCustomActions(actions *[]CodeAction, p CodeActionParams, sel string) { + if len(s.customActions) == 0 { + return + } + diags := s.diagnosticsInRange(p.Context, p.Range) + for _, ca := range s.customActions { + title := strings.TrimSpace(ca.Title) + if title == "" { + continue + } + scope := strings.TrimSpace(strings.ToLower(ca.Scope)) + if scope == "diagnostics" { + if len(diags) == 0 { + continue + } + payload := struct { + Type string `json:"type"` + ID string `json:"id"` + URI string `json:"uri"` + Range Range `json:"range"` + Selection string `json:"selection"` + Diagnostics []Diagnostic `json:"diagnostics"` + }{Type: "custom", ID: ca.ID, URI: p.TextDocument.URI, Range: p.Range, Selection: sel, Diagnostics: diags} + raw, _ := json.Marshal(payload) + kind := ca.Kind + if strings.TrimSpace(kind) == "" { + kind = "quickfix" + } + *actions = append(*actions, CodeAction{Title: "Hexai: " + title, Kind: kind, Data: raw}) + continue + } + // default: selection + if strings.TrimSpace(sel) == "" { + continue + } + payload := struct { + Type string `json:"type"` + ID string `json:"id"` + URI string `json:"uri"` + Range Range `json:"range"` + Selection string `json:"selection"` + }{Type: "custom", ID: ca.ID, URI: p.TextDocument.URI, Range: p.Range, Selection: sel} + raw, _ := json.Marshal(payload) + kind := ca.Kind + if strings.TrimSpace(kind) == "" { + kind = "refactor" + } + *actions = append(*actions, CodeAction{Title: "Hexai: " + title, Kind: kind, Data: raw}) + } +} + func (s *Server) buildSimplifyCodeAction(p CodeActionParams, sel string) *CodeAction { if strings.TrimSpace(sel) == "" { return nil @@ -106,6 +160,7 @@ func (s *Server) resolveCodeAction(ca CodeAction) (CodeAction, bool) { } var payload struct { Type string `json:"type"` + ID string `json:"id"` URI string `json:"uri"` Range Range `json:"range"` Instruction string `json:"instruction,omitempty"` @@ -200,6 +255,57 @@ func (s *Server) resolveCodeAction(ca CodeAction) (CodeAction, bool) { } else { logging.Logf("lsp ", "codeAction simplify llm error: %v", err) } + case "custom": + // Lookup action by ID + var action *CustomAction + for i := range s.customActions { + if s.customActions[i].ID == payload.ID { + action = &s.customActions[i] + break + } + } + if action == nil { + return ca, false + } + // Build messages + var sys, user string + if strings.TrimSpace(action.User) != "" { + if strings.TrimSpace(action.System) != "" { + sys = action.System + } else { + sys = s.promptRewriteSystem + } + var diagList string + if len(payload.Diagnostics) > 0 { + var b strings.Builder + for i, dgn := range payload.Diagnostics { + if dgn.Source != "" { + fmt.Fprintf(&b, "%d. [%s] %s\n", i+1, dgn.Source, dgn.Message) + } else { + fmt.Fprintf(&b, "%d. %s\n", i+1, dgn.Message) + } + } + diagList = b.String() + } + user = renderTemplate(action.User, map[string]string{"selection": payload.Selection, "diagnostics": diagList}) + } else { + // Use rewrite templates with fixed instruction + sys = s.promptRewriteSystem + user = renderTemplate(s.promptRewriteUser, map[string]string{"instruction": action.Instruction, "selection": payload.Selection}) + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + messages := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}} + opts := s.llmRequestOpts() + if text, err := s.chatWithStats(ctx, messages, opts...); err == nil { + if out := stripCodeFences(strings.TrimSpace(text)); out != "" { + edit := WorkspaceEdit{Changes: map[string][]TextEdit{payload.URI: {{Range: payload.Range, NewText: out}}}} + ca.Edit = &edit + return ca, true + } + } else { + logging.Logf("lsp ", "codeAction custom id=%s llm error: %v", action.ID, err) + } } return ca, false } diff --git a/internal/lsp/server.go b/internal/lsp/server.go index 97d7de7..e3728c8 100644 --- a/internal/lsp/server.go +++ b/internal/lsp/server.go @@ -83,6 +83,9 @@ type Server struct { promptGoTestUser string promptSimplifySystem string promptSimplifyUser string + + // Custom actions configured by user + customActions []CustomAction } // ServerOptions collects configuration for NewServer to avoid long parameter lists. @@ -125,6 +128,20 @@ type ServerOptions struct { PromptGoTestUser string PromptSimplifySystem string PromptSimplifyUser string + + // Custom actions + CustomActions []CustomAction +} + +// CustomAction mirrors user-defined code actions passed from config. +type CustomAction struct { + ID string + Title string + Kind string + Scope string // "selection" | "diagnostics" + Instruction string // if set, use rewrite templates + System string // optional when User is set + User string // if set, use this user template } func NewServer(r io.Reader, w io.Writer, logger *log.Logger, opts ServerOptions) *Server { @@ -209,6 +226,10 @@ func NewServer(r io.Reader, w io.Writer, logger *log.Logger, opts ServerOptions) s.promptSimplifySystem = opts.PromptSimplifySystem s.promptSimplifyUser = opts.PromptSimplifyUser + if len(opts.CustomActions) > 0 { + s.customActions = append([]CustomAction{}, opts.CustomActions...) + } + // Assign package-level inline trigger chars for free helper functions if s.inlineOpen != "" { inlineOpenChar = s.inlineOpen[0] diff --git a/internal/version.go b/internal/version.go index 3162341..2327e44 100644 --- a/internal/version.go +++ b/internal/version.go @@ -1,4 +1,4 @@ // Summary: Hexai semantic version identifier used by CLI and LSP binaries. package internal -const Version = "0.9.0" +const Version = "0.10.0" |
