diff options
| author | Paul Buetow <paul@buetow.org> | 2025-09-07 17:54:42 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2025-09-07 17:54:42 +0300 |
| commit | 77e41a1018715fa5ac4e6156354710b3b224b4fc (patch) | |
| tree | 23d847008c34a4c8b1329151fd33ac107fdefe76 | |
| parent | a240e1e106d3b8028bbb3667b44cf3085875e405 (diff) | |
feat: add Custom prompt action (p) with editor integration; shared editor helper in internal/editor; hexai CLI opens editor when no args
| -rw-r--r-- | internal/editor/editor.go | 70 | ||||
| -rw-r--r-- | internal/hexaiaction/run.go | 11 | ||||
| -rw-r--r-- | internal/hexaiaction/tui.go | 3 | ||||
| -rw-r--r-- | internal/hexaiaction/types.go | 1 | ||||
| -rw-r--r-- | internal/hexaicli/run.go | 19 |
5 files changed, 98 insertions, 6 deletions
diff --git a/internal/editor/editor.go b/internal/editor/editor.go new file mode 100644 index 0000000..44aa1d4 --- /dev/null +++ b/internal/editor/editor.go @@ -0,0 +1,70 @@ +package editor + +import ( + "errors" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// Resolve returns the editor command from HEXAI_EDITOR or EDITOR. +func Resolve() (string, error) { + ed := strings.TrimSpace(os.Getenv("HEXAI_EDITOR")) + if ed == "" { + ed = strings.TrimSpace(os.Getenv("EDITOR")) + } + if ed == "" { + return "", errors.New("no editor configured (set HEXAI_EDITOR or EDITOR)") + } + return ed, nil +} + +// RunEditor is the seam that invokes the editor on the given file path. +// Override in tests to avoid launching a real editor. +var RunEditor = func(editor, path string) error { + cmd := exec.Command(editor, path) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +// OpenTempAndEdit creates a temporary .md file, writes initial content if provided, +// opens it in the resolved editor, then reads the final content and removes the file. +// Returns the trimmed content. +func OpenTempAndEdit(initial []byte) (string, error) { + ed, err := Resolve() + if err != nil { + return "", err + } + // Create temp file under system temp dir; ensure .md suffix + dir := os.TempDir() + f, err := os.CreateTemp(dir, "hexai-*.md") + if err != nil { + return "", err + } + path := f.Name() + defer func() { _ = os.Remove(path) }() + if len(initial) > 0 { + if _, err := f.Write(initial); err != nil { + _ = f.Close() + return "", err + } + } + if err := f.Sync(); err != nil { + _ = f.Close() + return "", err + } + if err := f.Close(); err != nil { + return "", err + } + if err := RunEditor(ed, path); err != nil { + return "", err + } + b, err := os.ReadFile(filepath.Clean(path)) + if err != nil { + return "", err + } + return strings.TrimSpace(string(b)), nil +} diff --git a/internal/hexaiaction/run.go b/internal/hexaiaction/run.go index 5417d3f..1325e9d 100644 --- a/internal/hexaiaction/run.go +++ b/internal/hexaiaction/run.go @@ -8,6 +8,7 @@ import ( "strings" "codeberg.org/snonux/hexai/internal/appconfig" + "codeberg.org/snonux/hexai/internal/editor" "codeberg.org/snonux/hexai/internal/logging" "codeberg.org/snonux/hexai/internal/llmutils" ) @@ -74,6 +75,16 @@ func executeAction(ctx context.Context, kind ActionKind, parts InputParts, cfg a cctx, cancel := timeout10s(ctx) defer cancel() return runSimplify(cctx, cfg, client, parts.Selection) + case ActionCustom: + cctx, cancel := timeout10s(ctx) + defer cancel() + // Open editor for free-form instruction + prompt, err := editor.OpenTempAndEdit([]byte("# Enter your instruction below\n\n")) + if err != nil || strings.TrimSpace(prompt) == "" { + fmt.Fprintln(stderr, logging.AnsiBase+"hexai-tmux-action: custom prompt canceled or empty; echoing input"+logging.AnsiReset) + return parts.Selection, nil + } + return runRewrite(cctx, cfg, client, prompt, parts.Selection) default: return parts.Selection, nil } diff --git a/internal/hexaiaction/tui.go b/internal/hexaiaction/tui.go index 80b1fee..317a991 100644 --- a/internal/hexaiaction/tui.go +++ b/internal/hexaiaction/tui.go @@ -31,6 +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: "Skip", desc: "", kind: ActionSkip, hotkey: 's'}, } l := list.New(items, oneLineDelegate{}, 0, 0) @@ -78,7 +79,7 @@ func handleKey(m model, msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.list.Select(0) case "end": if n := len(m.list.Items()); n > 0 { m.list.Select(n - 1) } - case "s", "r", "c", "t", "i": + case "s", "r", "c", "t", "i", "p": items := m.list.Items() for i := 0; i < len(items); i++ { if it, ok := items[i].(item); ok && strings.ToLower(string(it.hotkey)) == low { diff --git a/internal/hexaiaction/types.go b/internal/hexaiaction/types.go index 708c433..7bc292e 100644 --- a/internal/hexaiaction/types.go +++ b/internal/hexaiaction/types.go @@ -11,6 +11,7 @@ const ( ActionDocument ActionKind = "document" ActionGoTest ActionKind = "gotest" ActionSimplify ActionKind = "simplify" + ActionCustom ActionKind = "custom" ) // InputParts represents parsed stdin input for actions. diff --git a/internal/hexaicli/run.go b/internal/hexaicli/run.go index f10850b..98e4c40 100644 --- a/internal/hexaicli/run.go +++ b/internal/hexaicli/run.go @@ -13,6 +13,7 @@ import ( "time" "codeberg.org/snonux/hexai/internal/appconfig" + "codeberg.org/snonux/hexai/internal/editor" "codeberg.org/snonux/hexai/internal/logging" "codeberg.org/snonux/hexai/internal/llm" "codeberg.org/snonux/hexai/internal/llmutils" @@ -21,16 +22,24 @@ import ( // Run executes the Hexai CLI behavior given arguments and I/O streams. // It assumes flags have already been parsed by the caller. func Run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) error { - // Load configuration with a logger so file-based config is respected. - logger := log.New(stderr, "hexai ", log.LstdFlags|log.Lmsgprefix) - cfg := appconfig.Load(logger) + // Load configuration with a logger so file-based config is respected. + logger := log.New(stderr, "hexai ", log.LstdFlags|log.Lmsgprefix) + cfg := appconfig.Load(logger) client, err := llmutils.NewClientFromApp(cfg) if err != nil { fmt.Fprintf(stderr, logging.AnsiBase+"hexai: LLM disabled: %v"+logging.AnsiReset+"\n", err) return err } - // Inline the flow here to use configured CLI prompts. - input, rerr := readInput(stdin, args) + // No args: open editor to capture a prompt, then combine with stdin as usual. + if len(args) == 0 { + if prompt, eerr := editor.OpenTempAndEdit([]byte("# Enter your prompt below\n\n")); eerr == nil && strings.TrimSpace(prompt) != "" { + args = []string{prompt} + } else { + // If editor fails or empty, continue; readInput will likely error if no stdin either. + } + } + // Inline the flow here to use configured CLI prompts. + input, rerr := readInput(stdin, args) if rerr != nil { fmt.Fprintln(stderr, logging.AnsiBase+rerr.Error()+logging.AnsiReset) return rerr |
