diff options
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/hexaiaction/prompts_more_test.go | 19 | ||||
| -rw-r--r-- | internal/hexaiaction/run_more_test.go | 26 | ||||
| -rw-r--r-- | internal/tmux/tmux.go | 85 | ||||
| -rw-r--r-- | internal/tmux/tmux_test.go | 82 | ||||
| -rw-r--r-- | internal/version.go | 2 |
5 files changed, 213 insertions, 1 deletions
diff --git a/internal/hexaiaction/prompts_more_test.go b/internal/hexaiaction/prompts_more_test.go new file mode 100644 index 0000000..62abc97 --- /dev/null +++ b/internal/hexaiaction/prompts_more_test.go @@ -0,0 +1,19 @@ +package hexaiaction + +import ( + "context" + "strings" + "testing" + + "codeberg.org/snonux/hexai/internal/llm" +) + +type simpleDoer struct{ s string } + +func (d simpleDoer) Chat(_ context.Context, _ []llm.Message, _ ...llm.RequestOption) (string, error) { return d.s, nil } + +func TestRunOnce_StripsFences(t *testing.T) { + got, err := runOnce(context.Background(), simpleDoer{"```\nok\n```"}, "SYS", "USER") + if err != nil { t.Fatalf("runOnce: %v", err) } + if strings.TrimSpace(got) != "ok" { t.Fatalf("got %q", got) } +} diff --git a/internal/hexaiaction/run_more_test.go b/internal/hexaiaction/run_more_test.go new file mode 100644 index 0000000..d7ab025 --- /dev/null +++ b/internal/hexaiaction/run_more_test.go @@ -0,0 +1,26 @@ +package hexaiaction + +import ( + "bytes" + "context" + "os" + "testing" +) + +// Covers the early error path in Run when no API key is available for the default provider. +func TestRun_MissingAPIKey(t *testing.T) { + // Ensure no provider API keys in env + for _, k := range []string{"HEXAI_OPENAI_API_KEY", "OPENAI_API_KEY", "HEXAI_COPILOT_API_KEY", "COPILOT_API_KEY"} { + t.Setenv(k, "") + } + // Provide minimal stdin to get past empty input check (if reached) + in := bytes.NewBufferString("some selection text") + var out bytes.Buffer + var errBuf bytes.Buffer + // Expect an error due to missing OPENAI_API_KEY (default provider is openai) + if err := Run(context.Background(), in, &out, &errBuf); err == nil { + t.Fatal("expected error when API key is missing") + } + _ = os.Stderr +} + diff --git a/internal/tmux/tmux.go b/internal/tmux/tmux.go new file mode 100644 index 0000000..63b5660 --- /dev/null +++ b/internal/tmux/tmux.go @@ -0,0 +1,85 @@ +package tmux + +import ( + "os" + "os/exec" + "strconv" + "strings" +) + +// Available reports whether tmux is available and we appear to be in a tmux session. +func Available() bool { return HasBinary() && InSession() } + +// HasBinary reports whether the tmux binary is on PATH. +var lookPath = exec.LookPath +var command = exec.Command + +func HasBinary() bool { _, err := lookPath("tmux"); return err == nil } + +// InSession reports whether we seem to be running inside a tmux session. +func InSession() bool { return strings.TrimSpace(os.Getenv("TMUX")) != "" } + +// SplitOpts controls how a new pane is created for running a command. +type SplitOpts struct { + Target string // optional pane target, e.g. ":." + Vertical bool // true => split vertically (-v); false => horizontally (-h) + Percent int // 1..100; 0 means use tmux default +} + +// SplitRun splits the current tmux window and runs argv in the new pane. +// It returns once tmux has launched the child process. +func SplitRun(opts SplitOpts, argv []string) error { + if len(argv) == 0 { + return nil + } + args := []string{"split-window"} + if opts.Vertical { + args = append(args, "-v") + } else { + args = append(args, "-h") + } + if opts.Percent > 0 && opts.Percent <= 100 { + args = append(args, "-p", strconv.Itoa(opts.Percent)) + } + if strings.TrimSpace(opts.Target) != "" { + args = append(args, "-t", opts.Target) + } + // tmux takes a single command string. Use a conservative shell join. + cmdStr := shellJoin(argv) + args = append(args, cmdStr) + c := command("tmux", args...) + return c.Run() +} + +// shellJoin quotes argv elements for safe use in a single shell command string. +// It avoids interpretation by wrapping in single quotes and escaping embedded single quotes. +func shellJoin(argv []string) string { + out := make([]string, 0, len(argv)) + for _, a := range argv { + if a == "" { + out = append(out, "''") + continue + } + if isSafeBare(a) { + out = append(out, a) + continue + } + // single-quote wrapping with escaped single quotes + // ' => '\'' (close, escaped quote, reopen) + esc := strings.ReplaceAll(a, "'", "'\\''") + out = append(out, "'"+esc+"'") + } + return strings.Join(out, " ") +} + +// isSafeBare returns true if a contains only safe characters for bare words. +func isSafeBare(s string) bool { + for i := 0; i < len(s); i++ { + b := s[i] + if (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9') || b == '-' || b == '_' || b == '.' || b == '/' || b == ':' { + continue + } + return false + } + return true +} diff --git a/internal/tmux/tmux_test.go b/internal/tmux/tmux_test.go new file mode 100644 index 0000000..b18c8a3 --- /dev/null +++ b/internal/tmux/tmux_test.go @@ -0,0 +1,82 @@ +package tmux + +import ( + "errors" + "os" + "os/exec" + "testing" +) + +func TestInSession(t *testing.T) { + t.Setenv("TMUX", "/tmp/tmux-123,123,0") + if !InSession() { + t.Fatal("expected InSession true when TMUX is set") + } + t.Setenv("TMUX", "") + if InSession() { + t.Fatal("expected InSession false when TMUX is empty") + } +} + +func TestHasBinary_UsesLookPath(t *testing.T) { + old := lookPath + t.Cleanup(func() { lookPath = old }) + lookPath = func(file string) (string, error) { return "/bin/tmux", nil } + if !HasBinary() { + t.Fatal("expected HasBinary true when lookPath succeeds") + } + lookPath = func(file string) (string, error) { return "", errors.New("nope") } + if HasBinary() { + t.Fatal("expected HasBinary false when lookPath fails") + } +} + +func TestSplitRun_AssemblesArgs(t *testing.T) { + captured := struct{ name string; args []string }{} + oldCmd := command + t.Cleanup(func() { command = oldCmd }) + command = func(name string, args ...string) *exec.Cmd { + captured.name = name + captured.args = append([]string(nil), args...) + // Use a benign command that exits 0 + return exec.Command("true") + } + opts := SplitOpts{Target: ":.", Vertical: true, Percent: 40} + argv := []string{"/path/to/bin", "-flag", "value with spaces", "and'quote"} + if err := SplitRun(opts, argv); err != nil { + t.Fatalf("SplitRun error: %v", err) + } + if captured.name != "tmux" { + t.Fatalf("expected tmux, got %q", captured.name) + } + wantFlags := map[string]bool{"split-window": true, "-v": true, "-p": true, "40": true, "-t": true, ":.": true} + for _, a := range captured.args[:len(captured.args)-1] { + if wantFlags[a] { + delete(wantFlags, a) + } + } + if len(wantFlags) != 0 { + t.Fatalf("missing expected flags: %v", wantFlags) + } + last := captured.args[len(captured.args)-1] + if last == "" || last == argv[0] { + t.Fatalf("expected last arg to be joined command string, got %q", last) + } + _ = os.Unsetenv("TMUX") +} + +func TestAvailable(t *testing.T) { + oldLook := lookPath + t.Cleanup(func() { lookPath = oldLook }) + // Present binary + TMUX set -> available + lookPath = func(file string) (string, error) { return "/bin/tmux", nil } + t.Setenv("TMUX", "/tmp/tmux-1,1,1") + if !Available() { + t.Fatal("expected Available true with TMUX + binary") + } + // No binary -> not available + lookPath = func(file string) (string, error) { return "", errors.New("nope") } + if Available() { + t.Fatal("expected Available false without binary") + } +} diff --git a/internal/version.go b/internal/version.go index 39ed42e..c7a63eb 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.6.0" +const Version = "0.7.0" |
