summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2025-09-07 11:27:43 +0300
committerPaul Buetow <paul@buetow.org>2025-09-07 11:27:43 +0300
commit0d424adfc64da1c61296c66a99162ec68cc4f8d0 (patch)
tree2aaaad9e6c5c1886809d213a4bf4f0fe8a5bc3c8
parent8889949ad3851bfbf36ff5b73128286d67c88201 (diff)
hexai-action: integrate tmux orchestration; add internal/tmux; tests+docs; bump version to v0.7.0v0.7.0
-rw-r--r--TODO.md122
-rw-r--r--internal/hexaiaction/prompts_more_test.go19
-rw-r--r--internal/hexaiaction/run_more_test.go26
-rw-r--r--internal/tmux/tmux.go85
-rw-r--r--internal/tmux/tmux_test.go82
-rw-r--r--internal/version.go2
-rwxr-xr-xscripts/coverage.sh31
7 files changed, 366 insertions, 1 deletions
diff --git a/TODO.md b/TODO.md
new file mode 100644
index 0000000..b32cec2
--- /dev/null
+++ b/TODO.md
@@ -0,0 +1,122 @@
+Comprehensive plan: integrate Helix + tmux flow into hexai-action
+
+Summary of current setup
+- Helix keybinding pipes selection to `ai`, which dispatches to `hx.hexai-action-prompt` for hexai-action mode.
+- `hx.hexai-action-prompt` writes stdin to `~/.hx-action-input`, opens a tmux split-pane, runs `hexai-action -infile <input> -outfile <reply>.tmp`, then atomically renames to `<reply>` and prints the reply back to Helix.
+- This works but requires shell scripts and out-of-band temp files.
+
+Goal
+- Helix should call hexai-action directly: `:pipe hexai-action`.
+- hexai-action itself handles: reading stdin; presenting TUI in a tmux pane (when needed); executing the chosen action; printing the result to stdout for Helix to apply.
+- Consolidate all tmux logic in a reusable `internal/tmux` package for future features.
+
+Proposed CLI/UX
+- Default (auto):
+ - If running in an interactive TTY, run TUI inline and print the result to stdout.
+ - If stdin/stdout are pipes (Helix `:pipe`) and a tmux session is available, spawn a temporary tmux pane to render the TUI, then return the final output on stdout.
+ - If tmux is not available and no TTY is present, fall back to a sensible non-interactive mode: echo input or use instruction-based rewrite if an inline instruction is detected.
+- Flags (public):
+ - `-tmux` (bool, default: auto): force enabling tmux-pane mode.
+ - `-no-tmux` (bool): force disabling tmux-pane mode even if available.
+ - `-infile`, `-outfile`: keep for compatibility/testing, but not needed for Helix.
+- Flags (internal/private; hidden in help):
+ - `-ui-child` (bool): internal child mode that assumes `-infile` and `-outfile` and runs the interactive TUI on the attached TTY, writing final output to outfile.
+ - `-tmux-target` (string, optional): tmux target pane/window (advanced users).
+ - `-tmux-split` (enum: `v|h`, default `v`): split orientation.
+
+High-level design
+1) IO orchestration (parent process)
+ - Determine whether to show TUI inline or via tmux based on TTY detection and `-tmux`/`-no-tmux`.
+ - Inline path: run current `hexaiaction.Run(ctx, in, out, err)` (unchanged behavior) and exit.
+ - Tmux path: write stdin to a secure temp file, spawn a tmux split-pane that executes a `hexai-action -ui-child -infile <in> -outfile <out>.tmp`, wait for completion (by process exit and/or file rename), then print `<out>` to stdout.
+
+2) Child/TUI execution (`-ui-child`)
+ - Read from `-infile`, parse input (diagnostics + selection), construct LLM client, show Bubble Tea menu, run selected action, write result to `-outfile.tmp`, fsync, rename to `-outfile`.
+ - On error, write a human-readable message to stderr and a minimal fallback to outfile (e.g., echo selection) to avoid blocking Helix.
+
+3) Tmux integration package (`internal/tmux`)
+ - Responsibilities:
+ - Detect availability: binary present and inside a tmux session (`$TMUX` set) or a viable target.
+ - Run commands in a new split pane and return control immediately or after completion.
+ - Small helpers for file-based rendezvous (optional), e.g., `WaitForFile(path, timeout)`.
+ - Minimal API (initial):
+ - `func Available() bool`
+ - `type SplitOpts struct { Target string; Vertical bool; Percent int }`
+ - `func SplitRun(opts SplitOpts, argv []string) error` — runs `tmux split-window ... <argv>` and returns once tmux has launched the child process.
+ - `func HasBinary() bool` and `func InSession() bool` (if we want finer checks).
+ - Implementation details:
+ - Shell out to `tmux` (no lib dep). Build command like: `tmux split-window -v -p 33 "<cmd>"`.
+ - Quote/escape argv safely. Prefer `exec.Command` for the child in a shell wrapper, or join argv for `tmux`’s command string.
+ - Avoid writing to `~/.hx-*`; use `os.CreateTemp("", "hexai-action-*" )` under `$TMPDIR`.
+
+4) hexaiaction refactor (internal package)
+ - Separate concerns to keep functions small/testable:
+ - New function `ChooseAction(ctx, stdin, stderr) (ActionKind, InputParts, error)` that parses input and (conditionally) runs TUI.
+ - Existing action runners remain unchanged (rewrite, document, diagnostics, gotest).
+ - Keep `Run` as a thin orchestrator that assumes interactive mode; the parent (cmd) decides inline vs tmux/child.
+ - Ensure unit-testable seams: parse, instruction extraction, action execution already have tests; add tests for new branching logic where possible without tmux.
+
+5) Robustness and UX details
+ - TTY detection: use `golang.org/x/term` or a small `isatty` helper to decide inline vs tmux.
+ - Timeouts: child actions already use short timeouts; parent wait for outfile should have a reasonable deadline (e.g., 60s) to avoid hanging Helix.
+ - Atomic writes: write to `outfile.tmp`, `Sync`, then `Rename` for a clear completion signal.
+ - Cleanup: always remove temp files (defer and signal handling for SIGINT/SIGTERM).
+ - Logging: log to stderr with clear `hexai-action` prefixes; keep stdout clean for Helix’s `:pipe`.
+
+Helix configuration after change
+- Replace the current keybinding pipeline with a single call:
+ - `C-a = ":pipe hexai-action"`
+- Optional: users can force tmux pane with `:pipe hexai-action -tmux` or disable with `:pipe hexai-action -no-tmux` if auto-detection does not fit their setup.
+
+Migration plan
+1) Implement tmux package and integrate auto-mode in `cmd/internal/hexai-action/main.go`.
+2) Keep legacy flags (`-infile`, `-outfile`) for compatibility and tests.
+3) Update README and docs to show new Helix keybinding and describe flags.
+4) Mark shell scripts (`llminputs/ai`, `llminputs/hx.hexai-action-prompt`) as deprecated in repo notes; retain them temporarily.
+5) After a stabilization period, remove the scripts (or move to `scripts/legacy/`).
+
+Testing plan
+- Unit tests:
+ - `internal/tmux`: mock `exec.Command` via a small command-runner interface; verify command assembly and availability checks.
+ - `cmd/internal/hexai-action`: tests for parent decision logic (TTY vs pipe; tmux available vs not) using injectable detectors.
+ - hexaiaction: existing tests continue to pass; add tests for non-interactive fallback behavior when no TTY.
+- Integration tests (manual or scripted):
+ - Run under tmux: verify a pane opens, TUI choice is applied, stdout contains result.
+ - Run outside tmux: verify inline TUI works in terminal; verify fallback behavior under `:pipe` without tmux.
+ - Coverage target: ensure at least 85% unit test coverage for all new code paths added in this integration (verify with `go test -cover ./...` and per-package `-coverprofile`).
+
+Edge cases and mitigations
+- No tmux, no TTY: skip TUI; if inline instruction detected (`;...;`, `// ...`, etc.), run rewrite; else echo selection.
+- tmux available but spawning fails: warn on stderr and fall back to non-interactive mode.
+- Large inputs: spill to temp files; ensure temp dir has space; surface errors clearly.
+- Windows: tmux mode auto-disables; inline mode continues to work.
+
+Implementation steps (incremental)
+1) Add `internal/tmux` with `Available`, `SplitRun`, and helpers.
+2) Add TTY detection helper to `cmd/internal/hexai-action` and wire flags (`-tmux`, `-no-tmux`, `-ui-child`).
+3) Parent flow: detect mode, manage temp files, spawn child via tmux when selected, wait/print result.
+4) Child flow: reuse existing `hexaiaction.Run` to keep logic centralized; ensure outfile atomic write.
+5) Docs: update README with new Helix config and flags.
+6) Optional: add `-tmux-target`/`-tmux-split` for power users.
+
+Notes on code organization
+- Place all tmux-related code under `internal/tmux` and keep functions well under 50 lines.
+- Keep command entrypoint (`cmd/internal/hexai-action/main.go`) small and focused on wiring/mode selection.
+- Avoid duplication across `hexaiaction` and `tmux`; IO/file and action logic remain in `hexaiaction`.
+
+Outcome
+- One-step Helix integration (`:pipe hexai-action`).
+- No helper scripts required; cross-platform friendly with graceful fallbacks.
+- Reusable tmux utilities for future features.
+
+Progress
+- [x] Add `internal/tmux` with `Available`, `SplitRun`, quoting helpers.
+- [x] Wire flags in `hexai-action`: `-tmux`, `-no-tmux`, `-ui-child`, `-tmux-target`, `-tmux-split`, `-tmux-percent`.
+- [x] Parent tmux orchestration: write stdin to temp, split tmux, wait for outfile, print to stdout.
+- [x] Child mode: atomic `outfile.tmp` write and rename, with error echo fallback.
+- [x] Unit tests for `internal/tmux` and tmux decision logic in `hexai-action` (validate locally; target ≥85% coverage for new code).
+- [x] Update README/docs for new Helix keybinding and flags.
+- [ ] Delete legacy helper scripts (`llminputs/ai`, `llminputs/hx.hexai-action-prompt`) when ready; no deprecation notice.
+- [x] Ran coverage locally. Notes:
+ - `mage coverage` now passes (HTML at docs/coverage.html). Total cross-package coverage ≈ 84%.
+ - New package `internal/tmux` is ≥85% covered. The `hexai-action` entrypoint package sits ~69% overall; newly added helper paths are covered (openIO, runChild, runInTmuxParent, echoThrough, waitForFile, etc.). The inline TTY UI path and `main()` remain intentionally untested.
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"
diff --git a/scripts/coverage.sh b/scripts/coverage.sh
new file mode 100755
index 0000000..1492f0f
--- /dev/null
+++ b/scripts/coverage.sh
@@ -0,0 +1,31 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# Simple coverage helper focusing on new/modified packages.
+# Usage: scripts/coverage.sh [packages...]
+
+pkgs=("$@")
+if [ ${#pkgs[@]} -eq 0 ]; then
+ pkgs=(
+ "codeberg.org/snonux/hexai/internal/tmux"
+ "codeberg.org/snonux/hexai/cmd/internal/hexai-action"
+ )
+fi
+
+cover_dir="$(mktemp -d)"
+trap 'rm -rf "$cover_dir"' EXIT
+
+echo "Running coverage for packages:" "${pkgs[@]}"
+
+total=0
+for p in "${pkgs[@]}"; do
+ out="$cover_dir/$(echo "$p" | tr '/' '_').out"
+ go test -coverprofile="$out" -covermode=atomic "$p"
+ echo "--- $p ---"
+ go tool cover -func="$out" | tail -n1
+done
+
+echo
+echo "Hint: combine coverage across all packages with:"
+echo " go test ./... -coverprofile=cover.out && go tool cover -func=cover.out"
+