summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2025-09-07 11:26:10 +0300
committerPaul Buetow <paul@buetow.org>2025-09-07 11:26:10 +0300
commit8889949ad3851bfbf36ff5b73128286d67c88201 (patch)
tree0f515ae6ee3da898dea113799c09e943f3e3f8fb
parent7c0266e94378f6121719939c6d53915eb72eed3e (diff)
tiding up
-rw-r--r--.gitignore1
-rw-r--r--AGENTS.md2
-rw-r--r--Magefile.go62
-rw-r--r--PROJECTSTATUS.md17
-rw-r--r--README.md65
-rw-r--r--cmd/internal/hexai-action/main.go196
-rw-r--r--cmd/internal/hexai-action/main_test.go357
-rw-r--r--docs/configuration.md33
-rw-r--r--docs/coverage.html513
-rw-r--r--docs/coverage.out6525
-rw-r--r--docs/usage.md8
-rw-r--r--internal/appconfig/config_test.go31
-rw-r--r--internal/hexaiaction/tui_test.go21
13 files changed, 6724 insertions, 1107 deletions
diff --git a/.gitignore b/.gitignore
index 2677aac..9f8a3d9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,4 +8,5 @@
/docs/coverage/
/docs/*.out
/docs/*.html
+/llminputs
llm.out
diff --git a/AGENTS.md b/AGENTS.md
index f9486e9..3247667 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -10,7 +10,7 @@
- Avoid duplication of code when the functions are larger than 5 lines.
- If possible, construct individual methods so that they can be unit tested. But only if it doesn't add too much boilerplate to the code base.
-- Aim for at least 85% unit test coverage of all source code.
+- Aim for at least 85% unit test coverage of all source code. The command to check the coverage is "mage coverage"
- Ensure that all unit tests pass before commiting any changes.
- Always run the gofumpt code reformatter on all go files modified.
- There should be no source code file larger than 1000 lines. If so, split it up into multiple.
diff --git a/Magefile.go b/Magefile.go
index dedb72c..bd55ef6 100644
--- a/Magefile.go
+++ b/Magefile.go
@@ -17,16 +17,16 @@ import (
)
var (
- Default = Build // Default target: build all binaries.
- coverageThreshold float64 = 85
- coveragePrinted = make(chan struct{}, 1)
+ Default = Build // Default target: build all binaries.
+ coverageThreshold float64 = 85
+ coveragePrinted = make(chan struct{}, 1)
)
// Build builds the Hexai LSP and CLI binaries.
func Build() error {
- mg.Deps(BuildHexaiLSP, BuildHexaiCLI, BuildHexaiAction)
- printCoverage()
- return nil
+ mg.Deps(BuildHexaiLSP, BuildHexaiCLI, BuildHexaiAction)
+ printCoverage()
+ return nil
}
// BuildHexaiLSP builds the LSP server binary.
@@ -37,27 +37,27 @@ func BuildHexaiLSP() error {
// BuildHexaiCLI builds the CLI binary.
func BuildHexaiCLI() error {
- printCoverage()
- return sh.RunV("go", "build", "-o", "hexai", "cmd/hexai/main.go")
+ printCoverage()
+ return sh.RunV("go", "build", "-o", "hexai", "cmd/hexai/main.go")
}
// BuildHexaiAction builds the hexai-action TUI binary.
func BuildHexaiAction() error {
- printCoverage()
- return sh.RunV("go", "build", "-o", "hexai-action", "cmd/internal/hexai-action/main.go")
+ printCoverage()
+ return sh.RunV("go", "build", "-o", "hexai-action", "cmd/internal/hexai-action/main.go")
}
// Dev runs tests, vet, lint, then builds with race for both binaries.
func Dev() error {
- printCoverage()
- mg.Deps(Test, Vet, Lint)
- if err := sh.RunV("go", "build", "-race", "-o", "hexai-lsp", "cmd/hexai-lsp/main.go"); err != nil {
- return err
- }
- if err := sh.RunV("go", "build", "-race", "-o", "hexai", "cmd/hexai/main.go"); err != nil {
- return err
- }
- return sh.RunV("go", "build", "-race", "-o", "hexai-action", "cmd/internal/hexai-action/main.go")
+ printCoverage()
+ mg.Deps(Test, Vet, Lint)
+ if err := sh.RunV("go", "build", "-race", "-o", "hexai-lsp", "cmd/hexai-lsp/main.go"); err != nil {
+ return err
+ }
+ if err := sh.RunV("go", "build", "-race", "-o", "hexai", "cmd/hexai/main.go"); err != nil {
+ return err
+ }
+ return sh.RunV("go", "build", "-race", "-o", "hexai-action", "cmd/internal/hexai-action/main.go")
}
// Run launches the LSP server via go run (useful during development).
@@ -77,8 +77,8 @@ func RunCLI() error {
// Install copies built binaries to GOPATH/bin (defaults to ~/go/bin when GOPATH is unset).
func Install() error {
- printCoverage()
- mg.Deps(Build)
+ printCoverage()
+ mg.Deps(Build)
gopath := os.Getenv("GOPATH")
if gopath == "" {
home, err := os.UserHomeDir()
@@ -91,20 +91,20 @@ func Install() error {
if err := os.MkdirAll(bin, 0o755); err != nil {
return err
}
- if err := sh.RunV("cp", "-v", "./hexai-lsp", bin+"/"); err != nil {
- return err
- }
- if err := sh.RunV("cp", "-v", "./hexai", bin+"/"); err != nil {
- return err
- }
- return sh.RunV("cp", "-v", "./hexai-action", bin+"/")
+ if err := sh.RunV("cp", "-v", "./hexai-lsp", bin+"/"); err != nil {
+ return err
+ }
+ if err := sh.RunV("cp", "-v", "./hexai", bin+"/"); err != nil {
+ return err
+ }
+ return sh.RunV("cp", "-v", "./hexai-action", bin+"/")
}
// RunAction runs the hexai-action TUI via go run (reads stdin).
func RunAction() error {
- printCoverage()
- mg.Deps(Dev)
- return sh.RunV("go", "run", "cmd/internal/hexai-action/main.go")
+ printCoverage()
+ mg.Deps(Dev)
+ return sh.RunV("go", "run", "cmd/internal/hexai-action/main.go")
}
// printCoverage prints a warning if an existing coverage profile shows total < coverateThreshold.
diff --git a/PROJECTSTATUS.md b/PROJECTSTATUS.md
index 5959f31..18b0278 100644
--- a/PROJECTSTATUS.md
+++ b/PROJECTSTATUS.md
@@ -18,11 +18,26 @@ Or maybe
C-p = ":sh hexai-action"
```
-
And then generate a menu with all the code actions hexai-lsp knows of and include hotkeys for each menu item! Also print out a notice that this is a work-around due to limitations in Helix's current LSP UI.
### More features
+* [ ] Kagi FastGPT for in-editor search
+ - Think about an in-editor chat trigger, maybe with S> for search!
+* [ ] Test whethe GitHub Copilot support actually works now, and if not, fix it!
+
+> It looks like your message is:
+
+```
+- >foo bar baz?
+```
+
+Could you clarify what you mean or what you’re asking for?
+- If you’re asking about the syntax, `>foo bar baz` is not standard in most programming languages.
+- If you’re referencing a command-line or shell prompt, `foo bar baz` could be a command (`foo`) with arguments (`bar` and `baz`).
+- If you’re asking about a specific language or context, please provide more details so I can help you better!
+
+
* [/] implement a code action for selected code block the way via a unix pipe as faster access in helix
- pipe selected code to external command and replace selection with output
- the external command should open a menu to select an action (e.g. "format", "refactor", "explain", "test", etc.) and then apply it to the selected code
diff --git a/README.md b/README.md
index 7a4e214..be68bec 100644
--- a/README.md
+++ b/README.md
@@ -5,6 +5,7 @@
Hexai, the AI addition for your Helix Editor (https://helix-editor.com) .. Other editors should work but weren't tested.
It has got improved capabilities for Go code understanding (for example, create unit tests from function), but other programming language work as well.
+
## Features
* LSP Code auto-completion
@@ -29,6 +30,7 @@ Hexai uses Mage for developer tasks. Install Mage, then run targets like build,
- Dev build (+ tests, vet, lint): `mage dev`
- Run tests: `mage test`
- Run tests with coverage: `go test ./... -cover`
+- Full cross-package coverage and HTML report: `mage coverage` (writes `docs/coverage.html`)
- In restricted sandboxes/CI (no sockets), skip network-based tests:
- `HEXAI_TEST_SKIP_NET=1 go test ./... -cover`
- Install binaries to `GOPATH/bin`: `mage install`
@@ -41,64 +43,5 @@ Either use the Mage method as mentioned above, or install directly with:
- CLI: `go install codeberg.org/snonux/hexai/cmd/hexai@latest`
- LSP: `go install codeberg.org/snonux/hexai/cmd/hexai-lsp@latest`
-
-For `hexai-action`, use Mage or a local build:
-
-- Build locally: `go build -o hexai-action cmd/internal/hexai-action/main.go`
-- Or via Mage: `mage buildHexaiAction` (or `mage build`)
-- Install: `mage install` (copies `hexai-action` to `GOPATH/bin` together with other binaries)
-
-## Hexai Action (TUI)
-
-`hexai-action` is a small TUI to run Hexai code actions from stdin. It loads the same `config.toml` as `hexai` and `hexai-lsp` (XDG path: `~/.config/hexai/config.toml`), and respects the same environment overrides.
-
-- Pipe code (and optionally diagnostics) into the tool.
-- Select an action with arrow keys, vi keys (`j/k`, `g/G`), Enter, or hotkeys `[s] [r] [d] [c] [t]`.
-- The tool prints the transformed text to stdout.
-
-Input formats
-
-- Rewrite: include an inline instruction near the top of the selection using one of:
- - `;do something;`
- - `/* do something */`
- - `<!-- do something -->`
- - `// do something` (or `#`, `--`)
-
-- Diagnostics (optional block):
- - Begin with a header line `Diagnostics:` (case-insensitive), one diagnostic per line, blank line, then the code selection.
-
-Examples
-
-- Rewrite selection:
-
-```
-;replace fmt.Println with log.Println;
-package main
-
-import "fmt"
-
-func main() { fmt.Println("hi") }
-```
-
-- Diagnostics + selection:
-
-```
-Diagnostics:
-missing return at end of function
-use of undefined: foo
-
-func f() int {
- foo()
-}
-```
-
-Run:
-
-- `cat input.go | ./hexai-action`
-- or `./hexai-action < input.go`
-- or with files: `./hexai-action --infile input.go --outfile output.go`
-
-Flags
-
-- `--infile` Read input from the given file instead of stdin.
-- `--outfile` Write output to the given file instead of stdout (truncates/creates).
+- Action runner: `go install codeberg.org/snonux/hexai/cmd/hexai-action@latest`
+Install: `mage install` (copies `hexai-action` to `GOPATH/bin` together with other binaries)
diff --git a/cmd/internal/hexai-action/main.go b/cmd/internal/hexai-action/main.go
index 8bcc3cd..b8ba524 100644
--- a/cmd/internal/hexai-action/main.go
+++ b/cmd/internal/hexai-action/main.go
@@ -6,24 +6,62 @@ import (
"fmt"
"io"
"os"
+ "path/filepath"
+ "time"
"codeberg.org/snonux/hexai/internal/hexaiaction"
+ "codeberg.org/snonux/hexai/internal/tmux"
+ "golang.org/x/term"
)
func main() {
infile := flag.String("infile", "", "Read input from this file instead of stdin")
outfile := flag.String("outfile", "", "Write output to this file instead of stdout")
+ // Tmux/UI flags
+ forceTmux := flag.Bool("tmux", false, "Force running the UI in a tmux split-pane (auto if not set)")
+ noTmux := flag.Bool("no-tmux", false, "Disable tmux mode even if available")
+ uiChild := flag.Bool("ui-child", false, "INTERNAL: run interactive UI and write to -outfile atomically")
+ tmuxTarget := flag.String("tmux-target", "", "tmux split target (advanced)")
+ tmuxSplit := flag.String("tmux-split", "v", "tmux split orientation: v or h")
+ tmuxPercent := flag.Int("tmux-percent", 33, "tmux split size percentage (1-100)")
flag.Parse()
- in, out, closeIn, closeOut, err := openIO(*infile, *outfile)
- if err != nil {
- fmt.Fprintln(os.Stderr, err)
- os.Exit(1)
+ // Child mode: run TUI and write atomically to -outfile
+ if *uiChild {
+ if err := runChild(*infile, *outfile); err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+ return
+ }
+
+ // Parent mode: decide inline vs tmux
+ if shouldRunInTmux(*forceTmux, *noTmux) {
+ if err := runInTmuxParent(*tmuxTarget, *tmuxSplit, *tmuxPercent); err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+ return
}
- defer closeIn()
- defer closeOut()
- if err := hexaiaction.Run(context.Background(), in, out, os.Stderr); err != nil {
+ // Inline path: only if we have a TTY for UI; otherwise echo input
+ if isTTY(os.Stdout.Fd()) && isTTY(os.Stdin.Fd()) {
+ in, out, closeIn, closeOut, err := openIO(*infile, *outfile)
+ if err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+ defer closeIn()
+ defer closeOut()
+ if err := hexaiactionRun(context.Background(), in, out, os.Stderr); err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+ return
+ }
+
+ // Fallback: no TTY and tmux not available; echo input to output
+ if err := echoThrough(*infile, *outfile); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
@@ -54,3 +92,147 @@ func openIO(infile, outfile string) (io.Reader, io.Writer, func(), func(), error
}
return in, out, closeIn, closeOut, nil
}
+
+// runChild runs the interactive flow and writes the final output atomically to outfile.
+var hexaiactionRun = hexaiaction.Run
+
+func runChild(infile, outfile string) error {
+ if outfile == "" {
+ // No atomic handoff needed; just run normally to stdout
+ in, out, closeIn, closeOut, err := openIO(infile, "")
+ if err != nil {
+ return err
+ }
+ defer closeIn()
+ defer closeOut()
+ return hexaiactionRun(context.Background(), in, out, os.Stderr)
+ }
+ tmp := outfile + ".tmp"
+ in, out, closeIn, closeOut, err := openIO(infile, tmp)
+ if err != nil {
+ return err
+ }
+ defer closeIn()
+ if err := hexaiactionRun(context.Background(), in, out, os.Stderr); err != nil {
+ // On error, try to echo input to tmp to avoid blocking
+ closeOut()
+ if copyErr := echoThrough(infile, tmp); copyErr != nil {
+ return fmt.Errorf("hexai-action child: %v; echo failed: %v", err, copyErr)
+ }
+ } else {
+ closeOut()
+ }
+ return os.Rename(tmp, outfile)
+}
+
+var isTTYFn = isTTY
+var tmuxAvailableFn = tmux.Available
+var splitRunFn = tmux.SplitRun
+var osExecutableFn = os.Executable
+
+func shouldRunInTmux(forceTmux, noTmux bool) bool {
+ if noTmux {
+ return false
+ }
+ if forceTmux {
+ return true
+ }
+ // Auto: prefer tmux when stdio are not TTYs (Helix :pipe scenario)
+ if !(isTTYFn(os.Stdin.Fd()) && isTTYFn(os.Stdout.Fd())) && tmuxAvailableFn() {
+ return true
+ }
+ return false
+}
+
+func isTTY(fd uintptr) bool { return term.IsTerminal(int(fd)) }
+
+func runInTmuxParent(target, split string, percent int) error {
+ // Prepare temp files
+ dir, err := os.MkdirTemp("", "hexai-action-")
+ if err != nil {
+ return err
+ }
+ defer func() { _ = os.RemoveAll(dir) }()
+ inPath := filepath.Join(dir, "input.txt")
+ outPath := filepath.Join(dir, "reply.txt")
+ // Read stdin and persist to inPath
+ if err := persistStdin(inPath); err != nil {
+ return err
+ }
+ // Build child argv
+ exe, err := osExecutableFn()
+ if err != nil {
+ return err
+ }
+ argv := []string{exe, "-ui-child", "-infile", inPath, "-outfile", outPath}
+ // Spawn tmux split
+ opts := tmux.SplitOpts{Target: target, Vertical: split != "h", Percent: percent}
+ if err := splitRunFn(opts, argv); err != nil {
+ return err
+ }
+ // Wait for outfile to appear
+ if err := waitForFile(outPath, 60*time.Second); err != nil {
+ return err
+ }
+ // Print to stdout
+ return catFileToStdout(outPath)
+}
+
+func persistStdin(path string) error {
+ f, err := os.Create(path)
+ if err != nil {
+ return err
+ }
+ defer func() { _ = f.Close() }()
+ if _, err := io.Copy(f, os.Stdin); err != nil {
+ return err
+ }
+ return f.Sync()
+}
+
+func waitForFile(path string, timeout time.Duration) error {
+ deadline := time.Now().Add(timeout)
+ for {
+ if _, err := os.Stat(path); err == nil {
+ return nil
+ }
+ if time.Now().After(deadline) {
+ return fmt.Errorf("hexai-action: timeout waiting for reply file")
+ }
+ time.Sleep(200 * time.Millisecond)
+ }
+}
+
+func catFileToStdout(path string) error {
+ f, err := os.Open(path)
+ if err != nil {
+ return err
+ }
+ defer func() { _ = f.Close() }()
+ _, err = io.Copy(os.Stdout, f)
+ return err
+}
+
+func echoThrough(infile, outfile string) error {
+ // Read from infile or stdin and write to outfile or stdout
+ var in io.Reader = os.Stdin
+ var out io.Writer = os.Stdout
+ if infile != "" {
+ f, err := os.Open(infile)
+ if err != nil {
+ return err
+ }
+ defer func() { _ = f.Close() }()
+ in = f
+ }
+ if outfile != "" {
+ f, err := os.Create(outfile)
+ if err != nil {
+ return err
+ }
+ defer func() { _ = f.Close() }()
+ out = f
+ }
+ _, err := io.Copy(out, in)
+ return err
+}
diff --git a/cmd/internal/hexai-action/main_test.go b/cmd/internal/hexai-action/main_test.go
index 9603826..16bb2ed 100644
--- a/cmd/internal/hexai-action/main_test.go
+++ b/cmd/internal/hexai-action/main_test.go
@@ -1,44 +1,355 @@
package main
import (
+ "context"
+ "fmt"
"io"
"os"
"path/filepath"
+ "strings"
"testing"
+
+ "codeberg.org/snonux/hexai/internal/tmux"
)
-// TestOpenIO_InOutFiles verifies that openIO opens the specified files
-// and that writing via the returned writer persists to disk.
-func TestOpenIO_InOutFiles(t *testing.T) {
+func TestShouldRunInTmux_Preferences(t *testing.T) {
+ // no-tmux overrides
+ if shouldRunInTmux(false, true) {
+ t.Fatal("expected false when no-tmux is set")
+ }
+ // force tmux overrides
+ if !shouldRunInTmux(true, false) {
+ t.Fatal("expected true when -tmux is set")
+ }
+}
+
+func TestShouldRunInTmux_Auto(t *testing.T) {
+ oldIsTTY := isTTYFn
+ oldAvail := tmuxAvailableFn
+ t.Cleanup(func() { isTTYFn = oldIsTTY; tmuxAvailableFn = oldAvail })
+ // Simulate Helix :pipe (no TTY) and tmux available
+ isTTYFn = func(_ uintptr) bool { return false }
+ tmuxAvailableFn = func() bool { return true }
+ if !shouldRunInTmux(false, false) {
+ t.Fatal("expected true when not TTY and tmux available")
+ }
+ // Simulate TTY present: prefer inline
+ isTTYFn = func(_ uintptr) bool { return true }
+ if shouldRunInTmux(false, false) {
+ t.Fatal("expected false when TTY present")
+ }
+ // Simulate tmux not available
+ isTTYFn = func(_ uintptr) bool { return false }
+ tmuxAvailableFn = func() bool { return false }
+ if shouldRunInTmux(false, false) {
+ t.Fatal("expected false when tmux unavailable")
+ }
+}
+
+func TestPersistStdin_WritesFile(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "in.txt")
+ // Point os.Stdin to a temp file with content
+ src := filepath.Join(dir, "src.txt")
+ if err := os.WriteFile(src, []byte("hello world"), 0o600); err != nil {
+ t.Fatalf("write src: %v", err)
+ }
+ f, err := os.Open(src)
+ if err != nil { t.Fatalf("open src: %v", err) }
+ old := os.Stdin
+ os.Stdin = f
+ t.Cleanup(func() { os.Stdin = old; _ = f.Close() })
+ if err := persistStdin(path); err != nil {
+ t.Fatalf("persistStdin error: %v", err)
+ }
+ b, err := os.ReadFile(path)
+ if err != nil { t.Fatalf("read out: %v", err) }
+ if string(b) != "hello world" {
+ t.Fatalf("unexpected content %q", string(b))
+ }
+}
+
+func TestEchoThrough(t *testing.T) {
+ dir := t.TempDir()
+ in := filepath.Join(dir, "in.txt")
+ out := filepath.Join(dir, "out.txt")
+ if err := os.WriteFile(in, []byte("hello"), 0o600); err != nil {
+ t.Fatalf("write in: %v", err)
+ }
+ if err := echoThrough(in, out); err != nil {
+ t.Fatalf("echoThrough: %v", err)
+ }
+ b, _ := os.ReadFile(out)
+ if string(b) != "hello" {
+ t.Fatalf("unexpected: %q", string(b))
+ }
+}
+
+func TestEchoThrough_StdinStdout(t *testing.T) {
+ // set stdin
+ rIn, wIn, _ := os.Pipe()
+ _, _ = wIn.Write([]byte("PIPE"))
+ _ = wIn.Close()
+ oldIn := os.Stdin
+ os.Stdin = rIn
+ defer func() { os.Stdin = oldIn; _ = rIn.Close() }()
+ // capture stdout
+ r, w, _ := os.Pipe()
+ oldOut := os.Stdout
+ os.Stdout = w
+ defer func() { os.Stdout = oldOut; _ = r.Close(); _ = w.Close() }()
+ if err := echoThrough("", ""); err != nil { t.Fatalf("echoThrough: %v", err) }
+ _ = w.Close()
+ data, _ := io.ReadAll(r)
+ if string(data) != "PIPE" {
+ t.Fatalf("stdout: %q", string(data))
+ }
+}
+
+func TestWaitForFile(t *testing.T) {
dir := t.TempDir()
- inPath := filepath.Join(dir, "in.txt")
- outPath := filepath.Join(dir, "out.txt")
+ p := filepath.Join(dir, "x")
+ go func() {
+ // create shortly after
+ f, _ := os.Create(p)
+ defer f.Close()
+ f.WriteString("ok")
+ }()
+ if err := waitForFile(p, 2_000_000_000); err != nil { // 2s
+ t.Fatalf("waitForFile: %v", err)
+ }
+}
- // Prepare input file
- want := "hello world"
- if err := os.WriteFile(inPath, []byte(want), 0o600); err != nil {
- t.Fatalf("write infile: %v", err)
+func TestCatFileToStdout(t *testing.T) {
+ dir := t.TempDir()
+ p := filepath.Join(dir, "f")
+ if err := os.WriteFile(p, []byte("abc"), 0o600); err != nil {
+ t.Fatalf("write: %v", err)
+ }
+ // capture stdout
+ old := os.Stdout
+ r, w, _ := os.Pipe()
+ os.Stdout = w
+ defer func() { os.Stdout = old; _ = r.Close(); _ = w.Close() }()
+ if err := catFileToStdout(p); err != nil {
+ t.Fatalf("catFileToStdout: %v", err)
+ }
+ _ = w.Close()
+ buf, _ := io.ReadAll(r)
+ if string(buf) != "abc" {
+ t.Fatalf("stdout = %q", string(buf))
}
+}
+
+func TestRunInTmuxParent_Stubbed(t *testing.T) {
+ dir := t.TempDir()
+ // set stdin content
+ src := filepath.Join(dir, "stdin.txt")
+ _ = os.WriteFile(src, []byte("input"), 0o600)
+ f, _ := os.Open(src)
+ oldStdin := os.Stdin
+ os.Stdin = f
+ defer func() { os.Stdin = oldStdin; _ = f.Close() }()
- in, out, cin, cout, err := openIO(inPath, outPath)
- if err != nil {
- t.Fatalf("openIO: %v", err)
+ // capture stdout
+ oldStdout := os.Stdout
+ r, w, _ := os.Pipe()
+ os.Stdout = w
+ defer func() { os.Stdout = oldStdout; _ = r.Close(); _ = w.Close() }()
+
+ // stub seams
+ oldExec := osExecutableFn
+ oldSplit := splitRunFn
+ oldRun := hexaiactionRun
+ osExecutableFn = func() (string, error) { return "/bin/hexai-action", nil }
+ splitRunFn = func(opts tmux.SplitOpts, argv []string) error {
+ // find -outfile path and write content to simulate child
+ for i := 0; i < len(argv)-1; i++ {
+ if argv[i] == "-outfile" && i+1 < len(argv) {
+ _ = os.WriteFile(argv[i+1], []byte("OUT:"+strings.Join(argv, ",")), 0o600)
+ break
+ }
+ }
+ return nil
+ }
+ // Ensure child mode won't try to run the real TUI if invoked in tests.
+ hexaiactionRun = func(_ context.Context, _ io.Reader, w io.Writer, _ io.Writer) error {
+ _, _ = io.WriteString(w, "child-stub")
+ return nil
}
- defer cin()
- defer cout()
+ defer func() { osExecutableFn = oldExec; splitRunFn = oldSplit; hexaiactionRun = oldRun }()
- // Copy through to simulate main's behavior
- if _, err := io.Copy(out.(io.Writer), in); err != nil {
- t.Fatalf("copy: %v", err)
+ if err := runInTmuxParent("", "v", 33); err != nil {
+ t.Fatalf("runInTmuxParent: %v", err)
}
+ _ = w.Close()
+ got, _ := io.ReadAll(r)
+ if !strings.HasPrefix(string(got), "OUT:") {
+ t.Fatalf("unexpected stdout: %q", string(got))
+ }
+}
- // Verify outfile content
- got, err := os.ReadFile(outPath)
- if err != nil {
- t.Fatalf("read outfile: %v", err)
+func TestRunChild_StubbedOutfile(t *testing.T) {
+ dir := t.TempDir()
+ in := filepath.Join(dir, "in.txt")
+ out := filepath.Join(dir, "out.txt")
+ _ = os.WriteFile(in, []byte("sel"), 0o600)
+ old := hexaiactionRun
+ hexaiactionRun = func(_ context.Context, _ io.Reader, w io.Writer, _ io.Writer) error {
+ _, _ = io.WriteString(w, "RESULT")
+ return nil
}
- if string(got) != want {
- t.Fatalf("mismatch: got %q want %q", string(got), want)
+ defer func() { hexaiactionRun = old }()
+ if err := runChild(in, out); err != nil {
+ t.Fatalf("runChild: %v", err)
+ }
+ b, _ := os.ReadFile(out)
+ if string(b) != "RESULT" {
+ t.Fatalf("unexpected outfile: %q", string(b))
}
}
+func TestRunChild_StubbedStdout(t *testing.T) {
+ dir := t.TempDir()
+ in := filepath.Join(dir, "in.txt")
+ _ = os.WriteFile(in, []byte("sel"), 0o600)
+ // capture stdout
+ oldStdout := os.Stdout
+ r, w, _ := os.Pipe()
+ os.Stdout = w
+ defer func() { os.Stdout = oldStdout; _ = r.Close(); _ = w.Close() }()
+ old := hexaiactionRun
+ hexaiactionRun = func(_ context.Context, _ io.Reader, w io.Writer, _ io.Writer) error {
+ _, _ = io.WriteString(w, "STDOUT-RESULT")
+ return nil
+ }
+ defer func() { hexaiactionRun = old }()
+ if err := runChild(in, ""); err != nil {
+ t.Fatalf("runChild: %v", err)
+ }
+ _ = w.Close()
+ data, _ := io.ReadAll(r)
+ if string(data) != "STDOUT-RESULT" {
+ t.Fatalf("stdout: %q", string(data))
+ }
+}
+
+func TestRunChild_ErrorFallback(t *testing.T) {
+ dir := t.TempDir()
+ in := filepath.Join(dir, "in.txt")
+ out := filepath.Join(dir, "out.txt")
+ _ = os.WriteFile(in, []byte("INPUT"), 0o600)
+ old := hexaiactionRun
+ hexaiactionRun = func(_ context.Context, _ io.Reader, _ io.Writer, _ io.Writer) error {
+ return fmt.Errorf("boom")
+ }
+ defer func() { hexaiactionRun = old }()
+ if err := runChild(in, out); err != nil {
+ t.Fatalf("runChild: %v", err)
+ }
+ b, _ := os.ReadFile(out)
+ if string(b) != "INPUT" {
+ t.Fatalf("expected fallback echo, got %q", string(b))
+ }
+}
+
+func TestWaitForFile_Timeout(t *testing.T) {
+ dir := t.TempDir()
+ p := filepath.Join(dir, "nope")
+ if err := waitForFile(p, 10_000_000); err == nil { // 10ms
+ t.Fatal("expected timeout error")
+ }
+}
+
+func TestOpenIO_InfileOutfile(t *testing.T) {
+ dir := t.TempDir()
+ in := filepath.Join(dir, "i")
+ out := filepath.Join(dir, "o")
+ _ = os.WriteFile(in, []byte("X"), 0o600)
+ r, w, ci, co, err := openIO(in, out)
+ if err != nil { t.Fatalf("openIO: %v", err) }
+ defer ci(); defer co()
+ if _, err := io.Copy(w, r); err != nil { t.Fatalf("copy: %v", err) }
+ b, _ := os.ReadFile(out)
+ if string(b) != "X" { t.Fatalf("got %q", string(b)) }
+}
+
+func TestRunInTmuxParent_ExecutableError(t *testing.T) {
+ old := osExecutableFn
+ osExecutableFn = func() (string, error) { return "", fmt.Errorf("no exe") }
+ defer func() { osExecutableFn = old }()
+ // set stdin content
+ r, w, _ := os.Pipe()
+ _, _ = w.Write([]byte("x"))
+ _ = w.Close()
+ oldIn := os.Stdin
+ os.Stdin = r
+ defer func() { os.Stdin = oldIn; _ = r.Close() }()
+ if err := runInTmuxParent("", "v", 33); err == nil {
+ t.Fatal("expected error from missing executable")
+ }
+}
+
+func TestRunInTmuxParent_SplitError(t *testing.T) {
+ oldExec := osExecutableFn
+ osExecutableFn = func() (string, error) { return "/bin/hexai-action", nil }
+ oldSplit := splitRunFn
+ splitRunFn = func(_ tmux.SplitOpts, _ []string) error { return fmt.Errorf("split failed") }
+ defer func() { osExecutableFn = oldExec; splitRunFn = oldSplit }()
+ // set stdin
+ r, w, _ := os.Pipe()
+ _, _ = w.Write([]byte("x"))
+ _ = w.Close()
+ oldIn := os.Stdin
+ os.Stdin = r
+ defer func() { os.Stdin = oldIn; _ = r.Close() }()
+ if err := runInTmuxParent("", "v", 33); err == nil {
+ t.Fatal("expected split error")
+ }
+}
+
+func TestEchoThrough_OutfileError(t *testing.T) {
+ dir := t.TempDir()
+ in := filepath.Join(dir, "i.txt")
+ _ = os.WriteFile(in, []byte("x"), 0o600)
+ // Outfile inside non-existent subdir -> Create should fail
+ out := filepath.Join(dir, "nope", "out.txt")
+ if err := echoThrough(in, out); err == nil {
+ t.Fatal("expected echoThrough outfile error")
+ }
+}
+
+func TestPersistStdin_Error(t *testing.T) {
+ // Parent directory missing -> Create should fail
+ dir := t.TempDir()
+ p := filepath.Join(dir, "missing", "x.txt")
+ // set stdin to something
+ r, w, _ := os.Pipe()
+ _, _ = w.Write([]byte("x"))
+ _ = w.Close()
+ old := os.Stdin
+ os.Stdin = r
+ defer func() { os.Stdin = old; _ = r.Close() }()
+ if err := persistStdin(p); err == nil {
+ t.Fatal("expected persistStdin error")
+ }
+}
+
+func TestCatFileToStdout_Error(t *testing.T) {
+ if err := catFileToStdout("/nonexistent/path/file.txt"); err == nil {
+ t.Fatal("expected error for missing file")
+ }
+}
+
+func TestOpenIO_Errors(t *testing.T) {
+ // Non-existent infile
+ if _, _, _, _, err := openIO("/definitely/missing/file.txt", ""); err == nil {
+ t.Fatal("expected infile error")
+ }
+ // Outfile in missing dir
+ dir := t.TempDir()
+ out := filepath.Join(dir, "nope", "x.txt")
+ if _, _, _, _, err := openIO("", out); err == nil {
+ t.Fatal("expected outfile error")
+ }
+}
diff --git a/docs/configuration.md b/docs/configuration.md
index 3fbb1dc..dc4adbd 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -30,23 +30,30 @@ API keys:
Selecting a provider
- Sectioned: set `[provider] name = "openai" | "copilot" | "ollama"`.
-- Flat: set `provider = "openai" | "copilot" | "ollama"`.
- If omitted, Hexai defaults to `openai`.
-Provider-specific options
-
-- See [config.toml.example](../config.toml.example) for the per-provider tables and defaults.
-
-Notes:
+Notes on Ollama:
- Ensure the model is available locally (e.g., `ollama pull qwen3-coder:30b-a3b-q4_K_M`).
- Alternatively, run Ollama in OpenAI‑compatible mode and use the OpenAI provider with
`openai_base_url` pointed at your local endpoint.
-LSP completion tuning
-
-- See the [completion] section in [config.toml.example](../config.toml.example).
-
-Temperature behavior
-
-- Defaults and recommended ranges are commented inline in [config.toml.example](../config.toml.example) under [general] and provider tables.
+Hexai Action (TUI) configuration
+
+This is mostly useful when Helix runs in a [tmux](https://tmux.github.io/) session!
+
+- Helix integration (recommended): bind a key to pipe the current selection to `hexai-action` and replace it with the output.
+ - Example: `C-a = ":pipe hexai-action"`
+- Default behavior:
+ - Inline TUI when run in a real terminal (TTY).
+ - When invoked via Helix `:pipe` and a tmux session is available, `hexai-action` opens a split pane to render the menu and returns the result on stdout for Helix to apply.
+ - If no TTY and no tmux are available, it falls back to echoing the input.
+- Flags:
+ - `--infile` Read input from the given file instead of stdin.
+ - `--outfile` Write output to the given file instead of stdout (truncates/creates).
+ - `--tmux` force tmux-pane mode.
+ - `--no-tmux` disable tmux mode even if available.
+ - `--tmux-target` tmux target pane/window (advanced).
+ - `--tmux-split v|h` split orientation (default: `v`).
+ - `--tmux-percent N` split size percentage (default: `33`).
+ - `--ui-child` internal; used by the parent process when spawning inside tmux.
diff --git a/docs/coverage.html b/docs/coverage.html
index 6b80630..2003a0d 100644
--- a/docs/coverage.html
+++ b/docs/coverage.html
@@ -59,19 +59,19 @@
<option value="file1">codeberg.org/snonux/hexai/cmd/hexai/main.go (71.4%)</option>
- <option value="file2">codeberg.org/snonux/hexai/cmd/internal/hexai-action/main.go (0.0%)</option>
+ <option value="file2">codeberg.org/snonux/hexai/cmd/internal/hexai-action/main.go (69.3%)</option>
<option value="file3">codeberg.org/snonux/hexai/internal/appconfig/config.go (91.6%)</option>
<option value="file4">codeberg.org/snonux/hexai/internal/hexaiaction/parse.go (92.6%)</option>
- <option value="file5">codeberg.org/snonux/hexai/internal/hexaiaction/prompts.go (81.1%)</option>
+ <option value="file5">codeberg.org/snonux/hexai/internal/hexaiaction/prompts.go (91.9%)</option>
- <option value="file6">codeberg.org/snonux/hexai/internal/hexaiaction/run.go (33.3%)</option>
+ <option value="file6">codeberg.org/snonux/hexai/internal/hexaiaction/run.go (48.7%)</option>
- <option value="file7">codeberg.org/snonux/hexai/internal/hexaiaction/tui.go (47.3%)</option>
+ <option value="file7">codeberg.org/snonux/hexai/internal/hexaiaction/tui.go (65.5%)</option>
- <option value="file8">codeberg.org/snonux/hexai/internal/hexaiaction/tui_de