summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2025-09-15 08:07:51 +0300
committerPaul Buetow <paul@buetow.org>2025-09-15 08:07:51 +0300
commitdac90ba0e3036a15779da70e771c5cf2818c817f (patch)
treebd6ddb850a640b3fce79f5e65b27ce41b95e11a3
parent828d1cc59ac22d10cd1298aac0488f868e2280db (diff)
release: v0.10.1v0.10.1
- Fix TUI 'p' hotkey: open editor for Custom prompt - Introduce ActionCustomPrompt to disambiguate from Custom actions submenu - Bump version to 0.10.1
-rw-r--r--internal/appconfig/custom_validation_more_test.go39
-rw-r--r--internal/hexaiaction/custom_action_test.go2
-rw-r--r--internal/hexaiaction/custom_exec_more_test.go71
-rw-r--r--internal/hexaiaction/run.go7
-rw-r--r--internal/hexaiaction/tui.go2
-rw-r--r--internal/hexaiaction/tui_custom.go1
-rw-r--r--internal/hexaiaction/types.go5
-rw-r--r--internal/lsp/codeaction_custom_errors_test.go159
-rw-r--r--internal/version.go2
9 files changed, 157 insertions, 131 deletions
diff --git a/internal/appconfig/custom_validation_more_test.go b/internal/appconfig/custom_validation_more_test.go
index 05d0d1a..36212aa 100644
--- a/internal/appconfig/custom_validation_more_test.go
+++ b/internal/appconfig/custom_validation_more_test.go
@@ -1,16 +1,16 @@
package appconfig
import (
- "path/filepath"
- "strings"
- "testing"
+ "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, `
+ 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"
@@ -19,17 +19,17 @@ instruction = "x"
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)
- }
+ 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, `
+ 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"
@@ -40,9 +40,8 @@ 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)
- }
+ 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_action_test.go b/internal/hexaiaction/custom_action_test.go
index 72cfbc4..71319f4 100644
--- a/internal/hexaiaction/custom_action_test.go
+++ b/internal/hexaiaction/custom_action_test.go
@@ -23,7 +23,7 @@ func TestActionCustom_UsesEditorPrompt(t *testing.T) {
// Seam: choose custom, fake client, and fake editor
oldChoose := chooseActionFn
oldNew := newClientFromApp
- chooseActionFn = func() (ActionKind, error) { return ActionCustom, nil }
+ chooseActionFn = func() (ActionKind, error) { return ActionCustomPrompt, nil }
newClientFromApp = func(_ appconfig.App) (llm.Client, error) { return llmFake2{}, nil }
t.Cleanup(func() { chooseActionFn = oldChoose; newClientFromApp = oldNew })
diff --git a/internal/hexaiaction/custom_exec_more_test.go b/internal/hexaiaction/custom_exec_more_test.go
index 657d0d8..de45d26 100644
--- a/internal/hexaiaction/custom_exec_more_test.go
+++ b/internal/hexaiaction/custom_exec_more_test.go
@@ -1,48 +1,53 @@
package hexaiaction
import (
- "context"
- "strings"
- "testing"
+ "context"
+ "strings"
+ "testing"
- "codeberg.org/snonux/hexai/internal/appconfig"
- "codeberg.org/snonux/hexai/internal/llm"
+ "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 (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")
- }
+ 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)
- }
+ 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/run.go b/internal/hexaiaction/run.go
index d32edbf..b07fbbb 100644
--- a/internal/hexaiaction/run.go
+++ b/internal/hexaiaction/run.go
@@ -101,7 +101,12 @@ func executeAction(ctx context.Context, kind ActionKind, parts InputParts, cfg a
selectedCustom = nil // clear after use
return out, err
}
- // Fallback: open editor for free-form instruction
+ // No selected custom; treat as no-op
+ return parts.Selection, nil
+ case ActionCustomPrompt:
+ cctx, cancel := timeout10s(ctx)
+ defer cancel()
+ // 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.go b/internal/hexaiaction/tui.go
index d07bb78..549a6ab 100644
--- a/internal/hexaiaction/tui.go
+++ b/internal/hexaiaction/tui.go
@@ -31,7 +31,7 @@ func newModel() model {
item{title: "Simplify and improve", desc: "", kind: ActionSimplify, hotkey: 'i'},
item{title: "Document code", desc: "", kind: ActionDocument, hotkey: 'c'},
item{title: "Generate Go unit test(s)", desc: "", kind: ActionGoTest, hotkey: 't'},
- item{title: "Custom prompt", desc: "", kind: ActionCustom, hotkey: 'p'},
+ item{title: "Custom prompt", desc: "", kind: ActionCustomPrompt, hotkey: 'p'},
item{title: "Skip", desc: "", kind: ActionSkip, hotkey: 's'},
}
l := list.New(items, oneLineDelegate{}, 0, 0)
diff --git a/internal/hexaiaction/tui_custom.go b/internal/hexaiaction/tui_custom.go
index 91d4b81..fe32588 100644
--- a/internal/hexaiaction/tui_custom.go
+++ b/internal/hexaiaction/tui_custom.go
@@ -34,6 +34,7 @@ func RunTUIWithCustom(customs []appconfig.CustomAction, menuHotkey string) (Acti
return ActionSkip, 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
}
diff --git a/internal/hexaiaction/types.go b/internal/hexaiaction/types.go
index d3cda4e..8c5652b 100644
--- a/internal/hexaiaction/types.go
+++ b/internal/hexaiaction/types.go
@@ -11,7 +11,10 @@ const (
ActionDocument ActionKind = "document"
ActionGoTest ActionKind = "gotest"
ActionSimplify ActionKind = "simplify"
- ActionCustom ActionKind = "custom"
+ // ActionCustom represents a configured custom action from the submenu.
+ ActionCustom ActionKind = "custom"
+ // ActionCustomPrompt is the free-form prompt opened in the editor (hotkey 'p').
+ ActionCustomPrompt ActionKind = "custom_prompt"
)
// InputParts represents parsed stdin input for actions.
diff --git a/internal/lsp/codeaction_custom_errors_test.go b/internal/lsp/codeaction_custom_errors_test.go
index 2f42f65..ca6111f 100644
--- a/internal/lsp/codeaction_custom_errors_test.go
+++ b/internal/lsp/codeaction_custom_errors_test.go
@@ -1,92 +1,105 @@
package lsp
import (
- "bytes"
- "context"
- "encoding/json"
- "errors"
- "testing"
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "testing"
- "codeberg.org/snonux/hexai/internal/llm"
+ "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")
- }
+ 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) 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")
- }
+ // 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")
- }
+ // 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)
- }
+ 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/version.go b/internal/version.go
index 2327e44..3402f04 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.10.0"
+const Version = "0.10.1"