diff options
| author | Paul Buetow <paul@buetow.org> | 2025-09-07 11:26:10 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2025-09-07 11:26:10 +0300 |
| commit | 8889949ad3851bfbf36ff5b73128286d67c88201 (patch) | |
| tree | 0f515ae6ee3da898dea113799c09e943f3e3f8fb | |
| parent | 7c0266e94378f6121719939c6d53915eb72eed3e (diff) | |
tiding up
| -rw-r--r-- | .gitignore | 1 | ||||
| -rw-r--r-- | AGENTS.md | 2 | ||||
| -rw-r--r-- | Magefile.go | 62 | ||||
| -rw-r--r-- | PROJECTSTATUS.md | 17 | ||||
| -rw-r--r-- | README.md | 65 | ||||
| -rw-r--r-- | cmd/internal/hexai-action/main.go | 196 | ||||
| -rw-r--r-- | cmd/internal/hexai-action/main_test.go | 357 | ||||
| -rw-r--r-- | docs/configuration.md | 33 | ||||
| -rw-r--r-- | docs/coverage.html | 513 | ||||
| -rw-r--r-- | docs/coverage.out | 6525 | ||||
| -rw-r--r-- | docs/usage.md | 8 | ||||
| -rw-r--r-- | internal/appconfig/config_test.go | 31 | ||||
| -rw-r--r-- | internal/hexaiaction/tui_test.go | 21 |
13 files changed, 6724 insertions, 1107 deletions
@@ -8,4 +8,5 @@ /docs/coverage/ /docs/*.out /docs/*.html +/llminputs llm.out @@ -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 @@ -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 |
