summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2025-09-14 23:40:26 +0300
committerPaul Buetow <paul@buetow.org>2025-09-14 23:40:26 +0300
commitf4470bbcfbe3b14c99baeef475fe872825a13a39 (patch)
treee12fc6168d21119dfff3a0fef5b6c5b54149f3ab /internal
parent68438c98d23545ff791768e3e219cd21d3814e0c (diff)
release: v0.10.0v0.10.0
Diffstat (limited to 'internal')
-rw-r--r--internal/appconfig/config.go148
-rw-r--r--internal/appconfig/config_test.go128
-rw-r--r--internal/appconfig/custom_validation_more_test.go48
-rw-r--r--internal/hexaiaction/custom_exec_more_test.go48
-rw-r--r--internal/hexaiaction/custom_exec_test.go29
-rw-r--r--internal/hexaiaction/prompts.go15
-rw-r--r--internal/hexaiaction/run.go20
-rw-r--r--internal/hexaiaction/tui_custom.go76
-rw-r--r--internal/hexaiaction/tui_custom_test.go77
-rw-r--r--internal/hexailsp/run.go23
-rw-r--r--internal/lsp/codeaction_custom_errors_test.go92
-rw-r--r--internal/lsp/codeaction_custom_test.go110
-rw-r--r--internal/lsp/handlers_codeaction.go108
-rw-r--r--internal/lsp/server.go21
-rw-r--r--internal/version.go2
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"