diff options
| author | Paul Buetow <paul@buetow.org> | 2025-09-15 08:07:51 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2025-09-15 08:07:51 +0300 |
| commit | dac90ba0e3036a15779da70e771c5cf2818c817f (patch) | |
| tree | bd6ddb850a640b3fce79f5e65b27ce41b95e11a3 | |
| parent | 828d1cc59ac22d10cd1298aac0488f868e2280db (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.go | 39 | ||||
| -rw-r--r-- | internal/hexaiaction/custom_action_test.go | 2 | ||||
| -rw-r--r-- | internal/hexaiaction/custom_exec_more_test.go | 71 | ||||
| -rw-r--r-- | internal/hexaiaction/run.go | 7 | ||||
| -rw-r--r-- | internal/hexaiaction/tui.go | 2 | ||||
| -rw-r--r-- | internal/hexaiaction/tui_custom.go | 1 | ||||
| -rw-r--r-- | internal/hexaiaction/types.go | 5 | ||||
| -rw-r--r-- | internal/lsp/codeaction_custom_errors_test.go | 159 | ||||
| -rw-r--r-- | internal/version.go | 2 |
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" |
