summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2025-09-07 17:54:42 +0300
committerPaul Buetow <paul@buetow.org>2025-09-07 17:54:42 +0300
commit77e41a1018715fa5ac4e6156354710b3b224b4fc (patch)
tree23d847008c34a4c8b1329151fd33ac107fdefe76
parenta240e1e106d3b8028bbb3667b44cf3085875e405 (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.go70
-rw-r--r--internal/hexaiaction/run.go11
-rw-r--r--internal/hexaiaction/tui.go3
-rw-r--r--internal/hexaiaction/types.go1
-rw-r--r--internal/hexaicli/run.go19
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