summaryrefslogtreecommitdiff
path: root/internal/hexaiaction
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2025-09-08 12:02:40 +0300
committerPaul Buetow <paul@buetow.org>2025-09-08 12:02:40 +0300
commit75cf6abd55bfb60324fc47cf91eac08dbb8b87b4 (patch)
tree6ef90d8014fe4d9a757d3f7e95bf736b70e4c685 /internal/hexaiaction
parent0dcf347c3fbc6e4ffb7e46294f5dd92dbbcd98ef (diff)
docs: move tmux documentation to its own file
Diffstat (limited to 'internal/hexaiaction')
-rw-r--r--internal/hexaiaction/cmdentry.go252
-rw-r--r--internal/hexaiaction/cmdentry_runcommand_test.go87
-rw-r--r--internal/hexaiaction/cmdentry_test.go250
-rw-r--r--internal/hexaiaction/custom_action_test.go59
-rw-r--r--internal/hexaiaction/parse.go8
-rw-r--r--internal/hexaiaction/parse_test.go2
-rw-r--r--internal/hexaiaction/prompts_more_test.go23
-rw-r--r--internal/hexaiaction/run_more_test.go35
-rw-r--r--internal/hexaiaction/run_seam_test.go56
-rw-r--r--internal/hexaiaction/run_test.go70
-rw-r--r--internal/hexaiaction/tui.go114
-rw-r--r--internal/hexaiaction/tui_delegate.go36
-rw-r--r--internal/hexaiaction/tui_delegate_test.go42
-rw-r--r--internal/hexaiaction/tui_test.go74
-rw-r--r--internal/hexaiaction/types.go14
15 files changed, 620 insertions, 502 deletions
diff --git a/internal/hexaiaction/cmdentry.go b/internal/hexaiaction/cmdentry.go
index cf72495..ca33443 100644
--- a/internal/hexaiaction/cmdentry.go
+++ b/internal/hexaiaction/cmdentry.go
@@ -1,149 +1,183 @@
package hexaiaction
import (
- "context"
- "fmt"
- "io"
- "os"
- "path/filepath"
- "time"
+ "context"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "time"
- "codeberg.org/snonux/hexai/internal/tmux"
- "golang.org/x/term"
+ "codeberg.org/snonux/hexai/internal/tmux"
+ "golang.org/x/term"
)
// Options configures the command-line orchestration for hexai-tmux-action.
type Options struct {
- Infile string
- Outfile string
- UIChild bool
- TmuxTarget string
- TmuxSplit string // "v" or "h"
- TmuxPercent int // 1-100
+ Infile string
+ Outfile string
+ UIChild bool
+ TmuxTarget string
+ TmuxSplit string // "v" or "h"
+ TmuxPercent int // 1-100
}
// RunCommand is the CLI orchestrator used by cmd/hexai-tmux-action. It runs in tmux
// split-pane mode by default, or child mode when -ui-child is set.
func RunCommand(ctx context.Context, opts Options, stdin io.Reader, stdout, stderr io.Writer) error {
- if opts.UIChild {
- return runChild(ctx, opts.Infile, opts.Outfile, stdout, stderr)
- }
- // Always use tmux path
- return runInTmuxParent(stdin, stdout, opts.TmuxTarget, opts.TmuxSplit, opts.TmuxPercent)
+ if opts.UIChild {
+ return runChild(ctx, opts.Infile, opts.Outfile, stdout, stderr)
+ }
+ // Always use tmux path
+ return runInTmuxParent(stdin, stdout, opts.TmuxTarget, opts.TmuxSplit, opts.TmuxPercent)
}
// seams for unit tests
-var isTTYFn = func(fd uintptr) bool { return term.IsTerminal(int(fd)) }
-var splitRunFn = tmux.SplitRun
-var osExecutableFn = os.Executable
-var runFn = Run
+var (
+ isTTYFn = func(fd uintptr) bool { return term.IsTerminal(int(fd)) }
+ splitRunFn = tmux.SplitRun
+ osExecutableFn = os.Executable
+ runFn = Run
+)
// openIO returns readers/writers for infile/outfile flags with deferred closers.
func openIO(infile, outfile string) (io.Reader, io.Writer, func(), func(), error) {
- in := io.Reader(os.Stdin)
- out := io.Writer(os.Stdout)
- closeIn := func() {}
- closeOut := func() {}
- if path := infile; path != "" {
- f, err := os.Open(path)
- if err != nil { return nil, nil, func(){}, func(){}, fmt.Errorf("hexai-tmux-action: cannot open infile: %w", err) }
- in = f
- closeIn = func() { _ = f.Close() }
- }
- if path := outfile; path != "" {
- f, err := os.Create(path)
- if err != nil { return nil, nil, func(){}, func(){}, fmt.Errorf("hexai-tmux-action: cannot open outfile: %w", err) }
- out = f
- closeOut = func() { _ = f.Close() }
- }
- return in, out, closeIn, closeOut, nil
+ in := io.Reader(os.Stdin)
+ out := io.Writer(os.Stdout)
+ closeIn := func() {}
+ closeOut := func() {}
+ if path := infile; path != "" {
+ f, err := os.Open(path)
+ if err != nil {
+ return nil, nil, func() {}, func() {}, fmt.Errorf("hexai-tmux-action: cannot open infile: %w", err)
+ }
+ in = f
+ closeIn = func() { _ = f.Close() }
+ }
+ if path := outfile; path != "" {
+ f, err := os.Create(path)
+ if err != nil {
+ return nil, nil, func() {}, func() {}, fmt.Errorf("hexai-tmux-action: cannot open outfile: %w", err)
+ }
+ out = f
+ closeOut = func() { _ = f.Close() }
+ }
+ return in, out, closeIn, closeOut, nil
}
// runChild runs the interactive flow and writes the final output atomically when outfile is set.
func runChild(ctx context.Context, infile, outfile string, stdout, stderr io.Writer) error {
- if outfile == "" {
- // No atomic handoff needed; just run normally to provided stdout
- var in io.Reader = os.Stdin
- if infile != "" {
- f, err := os.Open(infile)
- if err != nil { return err }
- defer func(){ _ = f.Close() }()
- in = f
- }
- return runFn(ctx, in, stdout, stderr)
- }
- tmp := outfile + ".tmp"
- in, out, closeIn, closeOut, err := openIO(infile, tmp)
- if err != nil { return err }
- defer closeIn()
- if err := runFn(ctx, in, out, stderr); err != nil {
- closeOut()
- if copyErr := echoThrough(infile, tmp, os.Stdin, stdout); copyErr != nil {
- return fmt.Errorf("hexai-tmux-action child: %v; echo failed: %v", err, copyErr)
- }
- } else {
- closeOut()
- }
- return os.Rename(tmp, outfile)
+ if outfile == "" {
+ // No atomic handoff needed; just run normally to provided stdout
+ var in io.Reader = os.Stdin
+ if infile != "" {
+ f, err := os.Open(infile)
+ if err != nil {
+ return err
+ }
+ defer func() { _ = f.Close() }()
+ in = f
+ }
+ return runFn(ctx, in, stdout, stderr)
+ }
+ tmp := outfile + ".tmp"
+ in, out, closeIn, closeOut, err := openIO(infile, tmp)
+ if err != nil {
+ return err
+ }
+ defer closeIn()
+ if err := runFn(ctx, in, out, stderr); err != nil {
+ closeOut()
+ if copyErr := echoThrough(infile, tmp, os.Stdin, stdout); copyErr != nil {
+ return fmt.Errorf("hexai-tmux-action child: %v; echo failed: %v", err, copyErr)
+ }
+ } else {
+ closeOut()
+ }
+ return os.Rename(tmp, outfile)
}
func runInTmuxParent(stdin io.Reader, stdout io.Writer, target, split string, percent int) error {
- dir, err := os.MkdirTemp("", "hexai-tmux-action-")
- if err != nil { return err }
- defer func() { _ = os.RemoveAll(dir) }()
- inPath := filepath.Join(dir, "input.txt")
- outPath := filepath.Join(dir, "reply.txt")
- if err := persistStdin(inPath, stdin); err != nil { return err }
- exe, err := osExecutableFn()
- if err != nil { return err }
- argv := []string{exe, "-ui-child", "-infile", inPath, "-outfile", outPath}
- opts := tmux.SplitOpts{Target: target, Vertical: split != "h", Percent: percent}
- if err := splitRunFn(opts, argv); err != nil { return err }
- if err := waitForFile(outPath, 60*time.Second); err != nil { return err }
- return catFileTo(stdout, outPath)
+ dir, err := os.MkdirTemp("", "hexai-tmux-action-")
+ if err != nil {
+ return err
+ }
+ defer func() { _ = os.RemoveAll(dir) }()
+ inPath := filepath.Join(dir, "input.txt")
+ outPath := filepath.Join(dir, "reply.txt")
+ if err := persistStdin(inPath, stdin); err != nil {
+ return err
+ }
+ exe, err := osExecutableFn()
+ if err != nil {
+ return err
+ }
+ argv := []string{exe, "-ui-child", "-infile", inPath, "-outfile", outPath}
+ opts := tmux.SplitOpts{Target: target, Vertical: split != "h", Percent: percent}
+ if err := splitRunFn(opts, argv); err != nil {
+ return err
+ }
+ if err := waitForFile(outPath, 60*time.Second); err != nil {
+ return err
+ }
+ return catFileTo(stdout, outPath)
}
func persistStdin(path string, stdin io.Reader) error {
- f, err := os.Create(path)
- if err != nil { return err }
- defer func() { _ = f.Close() }()
- if _, err := io.Copy(f, stdin); err != nil { return err }
- return f.Sync()
+ f, err := os.Create(path)
+ if err != nil {
+ return err
+ }
+ defer func() { _ = f.Close() }()
+ if _, err := io.Copy(f, 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-tmux-action: timeout waiting for reply file") }
- time.Sleep(200 * time.Millisecond)
- }
+ deadline := time.Now().Add(timeout)
+ for {
+ if _, err := os.Stat(path); err == nil {
+ return nil
+ }
+ if time.Now().After(deadline) {
+ return fmt.Errorf("hexai-tmux-action: timeout waiting for reply file")
+ }
+ time.Sleep(200 * time.Millisecond)
+ }
}
func catFileTo(w io.Writer, path string) error {
- f, err := os.Open(path)
- if err != nil { return err }
- defer func() { _ = f.Close() }()
- _, err = io.Copy(w, f)
- return err
+ f, err := os.Open(path)
+ if err != nil {
+ return err
+ }
+ defer func() { _ = f.Close() }()
+ _, err = io.Copy(w, f)
+ return err
}
// echoThrough no longer used in tmux-only flow, but kept for potential reuse.
func echoThrough(infile, outfile string, stdin io.Reader, stdout io.Writer) error {
- var in io.Reader = stdin
- var out io.Writer = 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
+ var in io.Reader = stdin
+ var out io.Writer = 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/internal/hexaiaction/cmdentry_runcommand_test.go b/internal/hexaiaction/cmdentry_runcommand_test.go
index 092e43b..b139bb3 100644
--- a/internal/hexaiaction/cmdentry_runcommand_test.go
+++ b/internal/hexaiaction/cmdentry_runcommand_test.go
@@ -1,53 +1,60 @@
package hexaiaction
import (
- "bytes"
- "context"
- "io"
- "os"
- "path/filepath"
- "testing"
+ "bytes"
+ "context"
+ "io"
+ "os"
+ "path/filepath"
+ "testing"
- "codeberg.org/snonux/hexai/internal/tmux"
+ "codeberg.org/snonux/hexai/internal/tmux"
)
func TestRunCommand_UIChild(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 := runFn
- runFn = func(_ context.Context, _ io.Reader, w io.Writer, _ io.Writer) error { _, _ = io.WriteString(w, "OK"); return nil }
- t.Cleanup(func(){ runFn = old })
- opts := Options{Infile: in, Outfile: out, UIChild: true}
- if err := RunCommand(context.Background(), opts, bytes.NewBuffer(nil), io.Discard, io.Discard); err != nil {
- t.Fatalf("RunCommand UIChild: %v", err)
- }
- b, _ := os.ReadFile(out)
- if string(b) != "OK" { t.Fatalf("outfile: %q", string(b)) }
+ dir := t.TempDir()
+ in := filepath.Join(dir, "in.txt")
+ out := filepath.Join(dir, "out.txt")
+ _ = os.WriteFile(in, []byte("sel"), 0o600)
+ old := runFn
+ runFn = func(_ context.Context, _ io.Reader, w io.Writer, _ io.Writer) error {
+ _, _ = io.WriteString(w, "OK")
+ return nil
+ }
+ t.Cleanup(func() { runFn = old })
+ opts := Options{Infile: in, Outfile: out, UIChild: true}
+ if err := RunCommand(context.Background(), opts, bytes.NewBuffer(nil), io.Discard, io.Discard); err != nil {
+ t.Fatalf("RunCommand UIChild: %v", err)
+ }
+ b, _ := os.ReadFile(out)
+ if string(b) != "OK" {
+ t.Fatalf("outfile: %q", string(b))
+ }
}
func TestRunCommand_Tmux(t *testing.T) {
- oldTTY := isTTYFn
- oldExec := osExecutableFn
- oldSplit := splitRunFn
- isTTYFn = func(_ uintptr) bool { return false }
- osExecutableFn = func() (string, error) { return "/bin/hexai-tmux-action", nil }
- splitRunFn = func(_ tmux.SplitOpts, argv []string) error {
- for i := 0; i < len(argv)-1; i++ {
- if argv[i] == "-outfile" && i+1 < len(argv) {
- _ = os.WriteFile(argv[i+1], []byte("OUT"), 0o600)
- break
- }
- }
- return nil
- }
- defer func(){ isTTYFn = oldTTY; osExecutableFn = oldExec; splitRunFn = oldSplit }()
- var out bytes.Buffer
- if err := RunCommand(context.Background(), Options{}, bytes.NewBufferString("X"), &out, io.Discard); err != nil {
- t.Fatalf("RunCommand tmux: %v", err)
- }
- if out.String() != "OUT" { t.Fatalf("stdout: %q", out.String()) }
+ oldTTY := isTTYFn
+ oldExec := osExecutableFn
+ oldSplit := splitRunFn
+ isTTYFn = func(_ uintptr) bool { return false }
+ osExecutableFn = func() (string, error) { return "/bin/hexai-tmux-action", nil }
+ splitRunFn = func(_ tmux.SplitOpts, argv []string) error {
+ for i := 0; i < len(argv)-1; i++ {
+ if argv[i] == "-outfile" && i+1 < len(argv) {
+ _ = os.WriteFile(argv[i+1], []byte("OUT"), 0o600)
+ break
+ }
+ }
+ return nil
+ }
+ defer func() { isTTYFn = oldTTY; osExecutableFn = oldExec; splitRunFn = oldSplit }()
+ var out bytes.Buffer
+ if err := RunCommand(context.Background(), Options{}, bytes.NewBufferString("X"), &out, io.Discard); err != nil {
+ t.Fatalf("RunCommand tmux: %v", err)
+ }
+ if out.String() != "OUT" {
+ t.Fatalf("stdout: %q", out.String())
+ }
}
// Inline TTY path is exercised implicitly via other helpers; testing it directly
diff --git a/internal/hexaiaction/cmdentry_test.go b/internal/hexaiaction/cmdentry_test.go
index de8b5dd..9c896f6 100644
--- a/internal/hexaiaction/cmdentry_test.go
+++ b/internal/hexaiaction/cmdentry_test.go
@@ -1,135 +1,183 @@
package hexaiaction
import (
- "context"
- "fmt"
- "io"
- "os"
- "path/filepath"
- "strings"
- "testing"
- "time"
+ "context"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
- "codeberg.org/snonux/hexai/internal/tmux"
+ "codeberg.org/snonux/hexai/internal/tmux"
)
// tmux-only flow: decision helpers removed.
func TestPersistStdin_WritesFile(t *testing.T) {
- dir := t.TempDir()
- path := filepath.Join(dir, "in.txt")
- // Point stdin to 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, _ := os.Open(src)
- defer f.Close()
- if err := persistStdin(path, f); err != nil { t.Fatalf("persistStdin: %v", err) }
- b, _ := os.ReadFile(path)
- if string(b) != "hello world" { t.Fatalf("unexpected content %q", string(b)) }
+ dir := t.TempDir()
+ path := filepath.Join(dir, "in.txt")
+ // Point stdin to 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, _ := os.Open(src)
+ defer f.Close()
+ if err := persistStdin(path, f); err != nil {
+ t.Fatalf("persistStdin: %v", err)
+ }
+ b, _ := os.ReadFile(path)
+ 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")
- _ = os.WriteFile(in, []byte("hello"), 0o600)
- if err := echoThrough(in, out, os.Stdin, os.Stdout); err != nil { t.Fatalf("echoThrough: %v", err) }
- b, _ := os.ReadFile(out)
- if string(b) != "hello" { t.Fatalf("unexpected: %q", string(b)) }
+ dir := t.TempDir()
+ in := filepath.Join(dir, "in.txt")
+ out := filepath.Join(dir, "out.txt")
+ _ = os.WriteFile(in, []byte("hello"), 0o600)
+ if err := echoThrough(in, out, os.Stdin, os.Stdout); 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()
- // capture stdout
- r, w, _ := os.Pipe()
- if err := echoThrough("", "", rIn, w); err != nil { t.Fatalf("echoThrough: %v", err) }
- _ = w.Close()
- data, _ := io.ReadAll(r)
- if string(data) != "PIPE" { t.Fatalf("stdout: %q", string(data)) }
+ // set stdin
+ rIn, wIn, _ := os.Pipe()
+ _, _ = wIn.Write([]byte("PIPE"))
+ _ = wIn.Close()
+ // capture stdout
+ r, w, _ := os.Pipe()
+ if err := echoThrough("", "", rIn, w); err != nil {
+ t.Fatalf("echoThrough: %v", err)
+ }
+ _ = w.Close()
+ data, _ := io.ReadAll(r)
+ if string(data) != "PIPE" {
+ t.Fatalf("stdout: %q", string(data))
+ }
}
func TestRunInTmuxParent_Stubbed(t *testing.T) {
- dir := t.TempDir()
- // set stdin content
- r, w, _ := os.Pipe()
- _, _ = w.Write([]byte("input"))
- _ = w.Close()
- // capture stdout
- rout, wout, _ := os.Pipe()
- oldExec := osExecutableFn
- oldSplit := splitRunFn
- osExecutableFn = func() (string, error) { return "/bin/hexai-tmux-action", nil }
- splitRunFn = func(opts tmux.SplitOpts, argv []string) error {
- 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
- }
- t.Cleanup(func() { osExecutableFn = oldExec; splitRunFn = oldSplit })
- if err := runInTmuxParent(r, wout, "", "v", 33); err != nil { t.Fatalf("runInTmuxParent: %v", err) }
- _ = wout.Close()
- got, _ := io.ReadAll(rout)
- if !strings.HasPrefix(string(got), "OUT:") { t.Fatalf("unexpected stdout: %q", string(got)) }
- _ = dir
+ dir := t.TempDir()
+ // set stdin content
+ r, w, _ := os.Pipe()
+ _, _ = w.Write([]byte("input"))
+ _ = w.Close()
+ // capture stdout
+ rout, wout, _ := os.Pipe()
+ oldExec := osExecutableFn
+ oldSplit := splitRunFn
+ osExecutableFn = func() (string, error) { return "/bin/hexai-tmux-action", nil }
+ splitRunFn = func(opts tmux.SplitOpts, argv []string) error {
+ 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
+ }
+ t.Cleanup(func() { osExecutableFn = oldExec; splitRunFn = oldSplit })
+ if err := runInTmuxParent(r, wout, "", "v", 33); err != nil {
+ t.Fatalf("runInTmuxParent: %v", err)
+ }
+ _ = wout.Close()
+ got, _ := io.ReadAll(rout)
+ if !strings.HasPrefix(string(got), "OUT:") {
+ t.Fatalf("unexpected stdout: %q", string(got))
+ }
+ _ = dir
}
func TestRunInTmuxParent_ExecutableError(t *testing.T) {
- old := osExecutableFn
- osExecutableFn = func() (string, error) { return "", fmt.Errorf("no exe") }
- t.Cleanup(func() { osExecutableFn = old })
- r, w, _ := os.Pipe(); _, _ = w.Write([]byte("x")); _ = w.Close()
- if err := runInTmuxParent(r, io.Discard, "", "v", 33); err == nil { t.Fatal("expected error from missing executable") }
+ old := osExecutableFn
+ osExecutableFn = func() (string, error) { return "", fmt.Errorf("no exe") }
+ t.Cleanup(func() { osExecutableFn = old })
+ r, w, _ := os.Pipe()
+ _, _ = w.Write([]byte("x"))
+ _ = w.Close()
+ if err := runInTmuxParent(r, io.Discard, "", "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-tmux-action", nil }
- oldSplit := splitRunFn
- splitRunFn = func(_ tmux.SplitOpts, _ []string) error { return fmt.Errorf("split failed") }
- t.Cleanup(func() { osExecutableFn = oldExec; splitRunFn = oldSplit })
- r, w, _ := os.Pipe(); _, _ = w.Write([]byte("x")); _ = w.Close()
- if err := runInTmuxParent(r, io.Discard, "", "v", 33); err == nil { t.Fatal("expected split error") }
+ oldExec := osExecutableFn
+ osExecutableFn = func() (string, error) { return "/bin/hexai-tmux-action", nil }
+ oldSplit := splitRunFn
+ splitRunFn = func(_ tmux.SplitOpts, _ []string) error { return fmt.Errorf("split failed") }
+ t.Cleanup(func() { osExecutableFn = oldExec; splitRunFn = oldSplit })
+ r, w, _ := os.Pipe()
+ _, _ = w.Write([]byte("x"))
+ _ = w.Close()
+ if err := runInTmuxParent(r, io.Discard, "", "v", 33); err == nil {
+ t.Fatal("expected split error")
+ }
}
func TestRunChild_StdoutAndOutfile(t *testing.T) {
- // Outfile mode
- dir := t.TempDir()
- in := filepath.Join(dir, "in.txt")
- out := filepath.Join(dir, "out.txt")
- _ = os.WriteFile(in, []byte("sel"), 0o600)
- oldRun := runFn
- runFn = func(_ context.Context, _ io.Reader, w io.Writer, _ io.Writer) error { _, _ = io.WriteString(w, "RESULT"); return nil }
- t.Cleanup(func(){ runFn = oldRun })
- if err := runChild(context.Background(), in, out, io.Discard, io.Discard); err != nil { t.Fatalf("runChild: %v", err) }
- b, _ := os.ReadFile(out)
- if len(b) == 0 { t.Fatalf("expected some output") }
- // Stdout mode
- r, w, _ := os.Pipe()
- if err := runChild(context.Background(), in, "", w, io.Discard); err != nil { t.Fatalf("runChild: %v", err) }
- _ = w.Close(); buf, _ := io.ReadAll(r)
- if len(buf) == 0 { t.Fatalf("expected stdout output") }
+ // Outfile mode
+ dir := t.TempDir()
+ in := filepath.Join(dir, "in.txt")
+ out := filepath.Join(dir, "out.txt")
+ _ = os.WriteFile(in, []byte("sel"), 0o600)
+ oldRun := runFn
+ runFn = func(_ context.Context, _ io.Reader, w io.Writer, _ io.Writer) error {
+ _, _ = io.WriteString(w, "RESULT")
+ return nil
+ }
+ t.Cleanup(func() { runFn = oldRun })
+ if err := runChild(context.Background(), in, out, io.Discard, io.Discard); err != nil {
+ t.Fatalf("runChild: %v", err)
+ }
+ b, _ := os.ReadFile(out)
+ if len(b) == 0 {
+ t.Fatalf("expected some output")
+ }
+ // Stdout mode
+ r, w, _ := os.Pipe()
+ if err := runChild(context.Background(), in, "", w, io.Discard); err != nil {
+ t.Fatalf("runChild: %v", err)
+ }
+ _ = w.Close()
+ buf, _ := io.ReadAll(r)
+ if len(buf) == 0 {
+ t.Fatalf("expected stdout output")
+ }
}
func TestWaitForFile_Timeout(t *testing.T) {
- dir := t.TempDir()
- p := filepath.Join(dir, "nope")
- if err := waitForFile(p, 10*time.Millisecond); err == nil { t.Fatal("expected timeout error") }
+ dir := t.TempDir()
+ p := filepath.Join(dir, "nope")
+ if err := waitForFile(p, 10*time.Millisecond); err == nil {
+ 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)) }
+ 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))
+ }
}
diff --git a/internal/hexaiaction/custom_action_test.go b/internal/hexaiaction/custom_action_test.go
index 451a313..72cfbc4 100644
--- a/internal/hexaiaction/custom_action_test.go
+++ b/internal/hexaiaction/custom_action_test.go
@@ -1,39 +1,46 @@
package hexaiaction
import (
- "bytes"
- "context"
- "testing"
+ "bytes"
+ "context"
+ "os"
+ "testing"
- "codeberg.org/snonux/hexai/internal/appconfig"
- "codeberg.org/snonux/hexai/internal/editor"
- "codeberg.org/snonux/hexai/internal/llm"
- "os"
+ "codeberg.org/snonux/hexai/internal/appconfig"
+ "codeberg.org/snonux/hexai/internal/editor"
+ "codeberg.org/snonux/hexai/internal/llm"
)
type llmFake2 struct{}
-func (llmFake2) Chat(_ context.Context, _ []llm.Message, _ ...llm.RequestOption) (string, error) { return "DONE", nil }
-func (llmFake2) Name() string { return "fake" }
+
+func (llmFake2) Chat(_ context.Context, _ []llm.Message, _ ...llm.RequestOption) (string, error) {
+ return "DONE", nil
+}
+func (llmFake2) Name() string { return "fake" }
func (llmFake2) DefaultModel() string { return "m" }
func TestActionCustom_UsesEditorPrompt(t *testing.T) {
- // Seam: choose custom, fake client, and fake editor
- oldChoose := chooseActionFn
- oldNew := newClientFromApp
- chooseActionFn = func() (ActionKind, error) { return ActionCustom, nil }
- newClientFromApp = func(_ appconfig.App) (llm.Client, error) { return llmFake2{}, nil }
- t.Cleanup(func(){ chooseActionFn = oldChoose; newClientFromApp = oldNew })
+ // Seam: choose custom, fake client, and fake editor
+ oldChoose := chooseActionFn
+ oldNew := newClientFromApp
+ chooseActionFn = func() (ActionKind, error) { return ActionCustom, nil }
+ newClientFromApp = func(_ appconfig.App) (llm.Client, error) { return llmFake2{}, nil }
+ t.Cleanup(func() { chooseActionFn = oldChoose; newClientFromApp = oldNew })
- oldRunEd := editor.RunEditor
- editor.RunEditor = func(_ string, path string) error {
- return os.WriteFile(path, []byte("make it done"), 0o600)
- }
- t.Cleanup(func(){ editor.RunEditor = oldRunEd })
- t.Setenv("HEXAI_EDITOR", "dummy")
+ oldRunEd := editor.RunEditor
+ editor.RunEditor = func(_ string, path string) error {
+ return os.WriteFile(path, []byte("make it done"), 0o600)
+ }
+ t.Cleanup(func() { editor.RunEditor = oldRunEd })
+ t.Setenv("HEXAI_EDITOR", "dummy")
- in := bytes.NewBufferString("some code")
- var out bytes.Buffer
- var errb bytes.Buffer
- if err := Run(context.Background(), in, &out, &errb); err != nil { t.Fatalf("Run: %v", err) }
- if out.String() == "" { t.Fatalf("expected output") }
+ in := bytes.NewBufferString("some code")
+ var out bytes.Buffer
+ var errb bytes.Buffer
+ if err := Run(context.Background(), in, &out, &errb); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ if out.String() == "" {
+ t.Fatalf("expected output")
+ }
}
diff --git a/internal/hexaiaction/parse.go b/internal/hexaiaction/parse.go
index 99e2b24..33fc4af 100644
--- a/internal/hexaiaction/parse.go
+++ b/internal/hexaiaction/parse.go
@@ -1,11 +1,11 @@
package hexaiaction
import (
- "bufio"
- "io"
- "strings"
+ "bufio"
+ "io"
+ "strings"
- "codeberg.org/snonux/hexai/internal/textutil"
+ "codeberg.org/snonux/hexai/internal/textutil"
)
// ParseInput splits raw stdin into optional diagnostics and selection/code.
diff --git a/internal/hexaiaction/parse_test.go b/internal/hexaiaction/parse_test.go
index f81ab54..ba5cd96 100644
--- a/internal/hexaiaction/parse_test.go
+++ b/internal/hexaiaction/parse_test.go
@@ -77,6 +77,8 @@ func (f *fakeClient) Chat(_ context.Context, msgs []llm.Message, _ ...llm.Reques
return f.out, f.err
}
+func (f *fakeClient) DefaultModel() string { return "m" }
+
func TestRuners_Prompts(t *testing.T) {
cfg := appconfig.App{
PromptCodeActionRewriteSystem: "SYS-R",
diff --git a/internal/hexaiaction/prompts_more_test.go b/internal/hexaiaction/prompts_more_test.go
index 62abc97..9f5d6cb 100644
--- a/internal/hexaiaction/prompts_more_test.go
+++ b/internal/hexaiaction/prompts_more_test.go
@@ -1,19 +1,26 @@
package hexaiaction
import (
- "context"
- "strings"
- "testing"
+ "context"
+ "strings"
+ "testing"
- "codeberg.org/snonux/hexai/internal/llm"
+ "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 (d simpleDoer) Chat(_ context.Context, _ []llm.Message, _ ...llm.RequestOption) (string, error) {
+ return d.s, nil
+}
+func (d simpleDoer) DefaultModel() string { return "m" }
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) }
+ 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
index d7ab025..1c0eb51 100644
--- a/internal/hexaiaction/run_more_test.go
+++ b/internal/hexaiaction/run_more_test.go
@@ -1,26 +1,25 @@
package hexaiaction
import (
- "bytes"
- "context"
- "os"
- "testing"
+ "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
+ // 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/hexaiaction/run_seam_test.go b/internal/hexaiaction/run_seam_test.go
index 0b8761f..bbec858 100644
--- a/internal/hexaiaction/run_seam_test.go
+++ b/internal/hexaiaction/run_seam_test.go
@@ -1,36 +1,46 @@
package hexaiaction
import (
- "bytes"
- "context"
- "testing"
+ "bytes"
+ "context"
+ "testing"
- "codeberg.org/snonux/hexai/internal/appconfig"
- "codeberg.org/snonux/hexai/internal/llm"
+ "codeberg.org/snonux/hexai/internal/appconfig"
+ "codeberg.org/snonux/hexai/internal/llm"
)
type llmFake struct{}
-func (llmFake) Chat(_ context.Context, _ []llm.Message, _ ...llm.RequestOption) (string, error) { return "OK", nil }
+func (llmFake) Chat(_ context.Context, _ []llm.Message, _ ...llm.RequestOption) (string, error) {
+ return "OK", nil
+}
func (llmFake) Name() string { return "fake" }
func (llmFake) DefaultModel() string { return "model" }
func TestRun_WithSeams_SkipAndRewrite(t *testing.T) {
- // Seam: choose action to Skip first, then Rewrite
- oldChoose := chooseActionFn
- oldNew := newClientFromApp
- t.Cleanup(func(){ chooseActionFn = oldChoose; newClientFromApp = oldNew })
- // 1) Skip -> echoes selection
- chooseActionFn = func() (ActionKind, error) { return ActionSkip, nil }
- newClientFromApp = func(_ appconfig.App) (llm.Client, error) { return llmFake{}, nil }
- var out bytes.Buffer
- in := bytes.NewBufferString("some code")
- if err := Run(context.Background(), in, &out, &out); err != nil { t.Fatalf("Run skip: %v", err) }
- if out.String() != "some code" { t.Fatalf("skip out: %q", out.String()) }
- // 2) Rewrite -> requires inline instruction
- chooseActionFn = func() (ActionKind, error) { return ActionRewrite, nil }
- out.Reset()
- in = bytes.NewBufferString(";upper;\nhello")
- if err := Run(context.Background(), in, &out, &out); err != nil { t.Fatalf("Run rewrite: %v", err) }
- if out.String() == "" { t.Fatalf("expected non-empty rewrite output") }
+ // Seam: choose action to Skip first, then Rewrite
+ oldChoose := chooseActionFn
+ oldNew := newClientFromApp
+ t.Cleanup(func() { chooseActionFn = oldChoose; newClientFromApp = oldNew })
+ // 1) Skip -> echoes selection
+ chooseActionFn = func() (ActionKind, error) { return ActionSkip, nil }
+ newClientFromApp = func(_ appconfig.App) (llm.Client, error) { return llmFake{}, nil }
+ var out bytes.Buffer
+ in := bytes.NewBufferString("some code")
+ if err := Run(context.Background(), in, &out, &out); err != nil {
+ t.Fatalf("Run skip: %v", err)
+ }
+ if out.String() != "some code" {
+ t.Fatalf("skip out: %q", out.String())
+ }
+ // 2) Rewrite -> requires inline instruction
+ chooseActionFn = func() (ActionKind, error) { return ActionRewrite, nil }
+ out.Reset()
+ in = bytes.NewBufferString(";upper;\nhello")
+ if err := Run(context.Background(), in, &out, &out); err != nil {
+ t.Fatalf("Run rewrite: %v", err)
+ }
+ if out.String() == "" {
+ t.Fatalf("expected non-empty rewrite output")
+ }
}
diff --git a/internal/hexaiaction/run_test.go b/internal/hexaiaction/run_test.go
index 87fbfa8..e28bceb 100644
--- a/internal/hexaiaction/run_test.go
+++ b/internal/hexaiaction/run_test.go
@@ -1,51 +1,51 @@
package hexaiaction
import (
- "context"
- "strings"
- "testing"
+ "context"
+ "strings"
+ "testing"
- "codeberg.org/snonux/hexai/internal/appconfig"
- "codeberg.org/snonux/hexai/internal/llm"
+ "codeberg.org/snonux/hexai/internal/appconfig"
+ "codeberg.org/snonux/hexai/internal/llm"
)
type fakeDoer struct{ out string }
func (f fakeDoer) Chat(_ context.Context, _ []llm.Message, _ ...llm.RequestOption) (string, error) {
- return f.out, nil
+ return f.out, nil
}
+func (f fakeDoer) DefaultModel() string { return "m" }
func TestExecuteAction_Skip(t *testing.T) {
- cfg := appconfig.App{}
- parts := InputParts{Selection: "data"}
- out, err := executeAction(context.Background(), ActionSkip, parts, cfg, fakeDoer{"IGN"}, nil)
- if err != nil || out != "data" {
- t.Fatalf("skip failed: %q %v", out, err)
- }
+ cfg := appconfig.App{}
+ parts := InputParts{Selection: "data"}
+ out, err := executeAction(context.Background(), ActionSkip, parts, cfg, fakeDoer{"IGN"}, nil)
+ if err != nil || out != "data" {
+ t.Fatalf("skip failed: %q %v", out, err)
+ }
}
func TestExecuteAction_Rewrite_Document_GoTest(t *testing.T) {
- cfg := appconfig.Load(nil) // defaults
- // Use fenced output to exercise StripFences
- client := fakeDoer{"```\nDONE\n```"}
-
- // rewrite with inline instruction
- sel := ";change;\ncode"
- out, err := executeAction(context.Background(), ActionRewrite, InputParts{Selection: sel}, cfg, client, nil)
- if err != nil || strings.TrimSpace(out) != "DONE" {
- t.Fatalf("rewrite failed: %q %v", out, err)
- }
-
- // document
- out, err = executeAction(context.Background(), ActionDocument, InputParts{Selection: "code"}, cfg, client, nil)
- if err != nil || strings.TrimSpace(out) != "DONE" {
- t.Fatalf("document failed: %q %v", out, err)
- }
-
- // go test
- out, err = executeAction(context.Background(), ActionGoTest, InputParts{Selection: "func A(){}"}, cfg, client, nil)
- if err != nil || strings.TrimSpace(out) != "DONE" {
- t.Fatalf("gotest failed: %q %v", out, err)
- }
+ cfg := appconfig.Load(nil) // defaults
+ // Use fenced output to exercise StripFences
+ client := fakeDoer{"```\nDONE\n```"}
+
+ // rewrite with inline instruction
+ sel := ";change;\ncode"
+ out, err := executeAction(context.Background(), ActionRewrite, InputParts{Selection: sel}, cfg, client, nil)
+ if err != nil || strings.TrimSpace(out) != "DONE" {
+ t.Fatalf("rewrite failed: %q %v", out, err)
+ }
+
+ // document
+ out, err = executeAction(context.Background(), ActionDocument, InputParts{Selection: "code"}, cfg, client, nil)
+ if err != nil || strings.TrimSpace(out) != "DONE" {
+ t.Fatalf("document failed: %q %v", out, err)
+ }
+
+ // go test
+ out, err = executeAction(context.Background(), ActionGoTest, InputParts{Selection: "func A(){}"}, cfg, client, nil)
+ if err != nil || strings.TrimSpace(out) != "DONE" {
+ t.Fatalf("gotest failed: %q %v", out, err)
+ }
}
-
diff --git a/internal/hexaiaction/tui.go b/internal/hexaiaction/tui.go
index 317a991..d07bb78 100644
--- a/internal/hexaiaction/tui.go
+++ b/internal/hexaiaction/tui.go
@@ -1,11 +1,11 @@
package hexaiaction
import (
- "fmt"
- "strings"
+ "fmt"
+ "strings"
- "github.com/charmbracelet/bubbles/list"
- tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/bubbles/list"
+ tea "github.com/charmbracelet/bubbletea"
)
// item implements list.Item
@@ -26,20 +26,20 @@ type model struct {
}
func newModel() model {
- items := []list.Item{
- item{title: "Rewrite selection", desc: "", kind: ActionRewrite, hotkey: 'r'},
- 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)
- l.SetShowTitle(false)
- l.SetShowHelp(false)
- l.SetShowStatusBar(false)
- l.SetFilteringEnabled(false)
- return model{list: l}
+ items := []list.Item{
+ item{title: "Rewrite selection", desc: "", kind: ActionRewrite, hotkey: 'r'},
+ 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)
+ l.SetShowTitle(false)
+ l.SetShowHelp(false)
+ l.SetShowStatusBar(false)
+ l.SetFilteringEnabled(false)
+ return model{list: l}
}
func (m model) Init() tea.Cmd { return nil }
@@ -57,43 +57,47 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
func handleKey(m model, msg tea.KeyMsg) (tea.Model, tea.Cmd) {
- raw := msg.String()
- low := strings.ToLower(raw)
- switch low {
- case "esc", "q":
- // Treat ESC and q as Skip/quit
- m.chosen = ActionSkip
- m.done = true
- return m, tea.Quit
- case "enter":
- if it, ok := m.list.SelectedItem().(item); ok {
- m.chosen = it.kind
- m.done = true
- return m, tea.Quit
- }
- case "j", "down":
- m.list.CursorDown()
- case "k", "up":
- m.list.CursorUp()
- case "g", "home":
- 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", "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 {
- m.list.Select(i)
- m.chosen = it.kind
- m.done = true
- return m, tea.Quit
- }
- }
- }
- if raw == "G" { // Shift+G jumps to end
- if n := len(m.list.Items()); n > 0 { m.list.Select(n - 1) }
- }
- return m, nil
+ raw := msg.String()
+ low := strings.ToLower(raw)
+ switch low {
+ case "esc", "q":
+ // Treat ESC and q as Skip/quit
+ m.chosen = ActionSkip
+ m.done = true
+ return m, tea.Quit
+ case "enter":
+ if it, ok := m.list.SelectedItem().(item); ok {
+ m.chosen = it.kind
+ m.done = true
+ return m, tea.Quit
+ }
+ case "j", "down":
+ m.list.CursorDown()
+ case "k", "up":
+ m.list.CursorUp()
+ case "g", "home":
+ 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", "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 {
+ m.list.Select(i)
+ m.chosen = it.kind
+ m.done = true
+ return m, tea.Quit
+ }
+ }
+ }
+ if raw == "G" { // Shift+G jumps to end
+ if n := len(m.list.Items()); n > 0 {
+ m.list.Select(n - 1)
+ }
+ }
+ return m, nil
}
func (m model) View() string {
diff --git a/internal/hexaiaction/tui_delegate.go b/internal/hexaiaction/tui_delegate.go
index 0e5a68c..46d40cb 100644
--- a/internal/hexaiaction/tui_delegate.go
+++ b/internal/hexaiaction/tui_delegate.go
@@ -1,35 +1,35 @@
package hexaiaction
import (
- "fmt"
- "io"
+ "fmt"
+ "io"
- "github.com/charmbracelet/bubbles/list"
- tea "github.com/charmbracelet/bubbletea"
- "github.com/charmbracelet/lipgloss"
+ "github.com/charmbracelet/bubbles/list"
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
)
// oneLineDelegate renders a single compact line per item, no spacing.
type oneLineDelegate struct{}
var (
- hotStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("205"))
- cursorStyle = lipgloss.NewStyle().Bold(true)
+ hotStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("205"))
+ cursorStyle = lipgloss.NewStyle().Bold(true)
)
func (oneLineDelegate) Height() int { return 1 }
func (oneLineDelegate) Spacing() int { return 0 }
func (oneLineDelegate) Update(tea.Msg, *list.Model) tea.Cmd { return nil }
func (oneLineDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) {
- title := listItem.FilterValue()
- hk := '?'
- if it, ok := listItem.(item); ok {
- hk = it.hotkey
- }
- hot := hotStyle.Render(fmt.Sprintf(" (%c)", hk))
- cursor := " "
- if index == m.Index() {
- cursor = cursorStyle.Render("> ")
- }
- fmt.Fprintf(w, "%s%s%s", cursor, title, hot)
+ title := listItem.FilterValue()
+ hk := '?'
+ if it, ok := listItem.(item); ok {
+ hk = it.hotkey
+ }
+ hot := hotStyle.Render(fmt.Sprintf(" (%c)", hk))
+ cursor := " "
+ if index == m.Index() {
+ cursor = cursorStyle.Render("> ")
+ }
+ fmt.Fprintf(w, "%s%s%s", cursor, title, hot)
}
diff --git a/internal/hexaiaction/tui_delegate_test.go b/internal/hexaiaction/tui_delegate_test.go
index 27881e4..4bdb359 100644
--- a/internal/hexaiaction/tui_delegate_test.go
+++ b/internal/hexaiaction/tui_delegate_test.go
@@ -1,32 +1,32 @@
package hexaiaction
import (
- "bytes"
- "regexp"
- "testing"
+ "bytes"
+ "regexp"
+ "testing"
- "github.com/charmbracelet/bubbles/list"
+ "github.com/charmbracelet/bubbles/list"
)
func stripANSI(s string) string {
- re := regexp.MustCompile(`\x1b\[[0-9;]*m`)
- return re.ReplaceAllString(s, "")
+ re := regexp.MustCompile(`\x1b\[[0-9;]*m`)
+ return re.ReplaceAllString(s, "")
}
func TestOneLineDelegate_Render(t *testing.T) {
- items := []list.Item{item{title: "Rewrite selection", kind: ActionRewrite, hotkey: 'r'}}
- m := list.New(items, oneLineDelegate{}, 0, 0)
- m.Select(0)
- var b bytes.Buffer
- oneLineDelegate{}.Render(&b, m, 0, items[0])
- out := stripANSI(b.String())
- if !regexp.MustCompile(`> \w`).MatchString(out) {
- t.Fatalf("expected cursor prefix in %q", out)
- }
- if !regexp.MustCompile(`Rewrite selection`).MatchString(out) {
- t.Fatalf("expected title in %q", out)
- }
- if !regexp.MustCompile(`\(r\)`).MatchString(out) {
- t.Fatalf("expected hotkey in %q", out)
- }
+ items := []list.Item{item{title: "Rewrite selection", kind: ActionRewrite, hotkey: 'r'}}
+ m := list.New(items, oneLineDelegate{}, 0, 0)
+ m.Select(0)
+ var b bytes.Buffer
+ oneLineDelegate{}.Render(&b, m, 0, items[0])
+ out := stripANSI(b.String())
+ if !regexp.MustCompile(`> \w`).MatchString(out) {
+ t.Fatalf("expected cursor prefix in %q", out)
+ }
+ if !regexp.MustCompile(`Rewrite selection`).MatchString(out) {
+ t.Fatalf("expected title in %q", out)
+ }
+ if !regexp.MustCompile(`\(r\)`).MatchString(out) {
+ t.Fatalf("expected hotkey in %q", out)
+ }
}
diff --git a/internal/hexaiaction/tui_test.go b/internal/hexaiaction/tui_test.go
index 6f1debc..f467e53 100644
--- a/internal/hexaiaction/tui_test.go
+++ b/internal/hexaiaction/tui_test.go
@@ -1,57 +1,57 @@
package hexaiaction
import (
- "testing"
+ "testing"
- tea "github.com/charmbracelet/bubbletea"
+ tea "github.com/charmbracelet/bubbletea"
)
func TestHandleKey_EscSkips(t *testing.T) {
- m := newModel()
- nm, _ := handleKey(m, tea.KeyMsg{Type: tea.KeyEsc})
- got, ok := nm.(model)
- if !ok || !got.done || got.chosen != ActionSkip {
- t.Fatalf("esc should skip: ok=%v done=%v chosen=%v", ok, got.done, got.chosen)
- }
+ m := newModel()
+ nm, _ := handleKey(m, tea.KeyMsg{Type: tea.KeyEsc})
+ got, ok := nm.(model)
+ if !ok || !got.done || got.chosen != ActionSkip {
+ t.Fatalf("esc should skip: ok=%v done=%v chosen=%v", ok, got.done, got.chosen)
+ }
}
func TestHandleKey_QuickHotkey(t *testing.T) {
- m := newModel()
- nm, _ := handleKey(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'r'}})
- got := nm.(model)
- if !got.done || got.chosen != ActionRewrite {
- t.Fatalf("r should choose rewrite: done=%v chosen=%v", got.done, got.chosen)
- }
+ m := newModel()
+ nm, _ := handleKey(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'r'}})
+ got := nm.(model)
+ if !got.done || got.chosen != ActionRewrite {
+ t.Fatalf("r should choose rewrite: done=%v chosen=%v", got.done, got.chosen)
+ }
}
func TestHandleKey_JumpEndWithG(t *testing.T) {
- m := newModel()
- // raw 'G' rune should jump to end (special cased)
- nm, _ := handleKey(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'G'}})
- got := nm.(model)
- if idx := got.list.Index(); idx != len(got.list.Items())-1 {
- t.Fatalf("G should jump to end, index=%d", idx)
- }
+ m := newModel()
+ // raw 'G' rune should jump to end (special cased)
+ nm, _ := handleKey(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'G'}})
+ got := nm.(model)
+ if idx := got.list.Index(); idx != len(got.list.Items())-1 {
+ t.Fatalf("G should jump to end, index=%d", idx)
+ }
}
func TestItemMethods(t *testing.T) {
- it := item{title: "T", desc: "D", kind: ActionRewrite, hotkey: 'r'}
- if it.Title() != "T" || it.Description() != "D" || it.FilterValue() != "T" {
- t.Fatalf("item methods wrong: %+v", it)
- }
+ it := item{title: "T", desc: "D", kind: ActionRewrite, hotkey: 'r'}
+ if it.Title() != "T" || it.Description() != "D" || it.FilterValue() != "T" {
+ t.Fatalf("item methods wrong: %+v", it)
+ }
}
func TestModelInitAndViewAndUpdate(t *testing.T) {
- m := newModel()
- if m.Init() != nil {
- t.Fatalf("Init should return nil cmd")
- }
- if v := m.View(); v == "" {
- t.Fatalf("View should not be empty before done")
- }
- // Window resize
- nm, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
- if _, ok := nm.(model); !ok {
- t.Fatalf("expected model after WindowSizeMsg")
- }
+ m := newModel()
+ if m.Init() != nil {
+ t.Fatalf("Init should return nil cmd")
+ }
+ if v := m.View(); v == "" {
+ t.Fatalf("View should not be empty before done")
+ }
+ // Window resize
+ nm, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
+ if _, ok := nm.(model); !ok {
+ t.Fatalf("expected model after WindowSizeMsg")
+ }
}
diff --git a/internal/hexaiaction/types.go b/internal/hexaiaction/types.go
index 7bc292e..d3cda4e 100644
--- a/internal/hexaiaction/types.go
+++ b/internal/hexaiaction/types.go
@@ -5,13 +5,13 @@ package hexaiaction
type ActionKind string
const (
- ActionSkip ActionKind = "skip"
- ActionRewrite ActionKind = "rewrite"
- ActionDiagnostics ActionKind = "diagnostics"
- ActionDocument ActionKind = "document"
- ActionGoTest ActionKind = "gotest"
- ActionSimplify ActionKind = "simplify"
- ActionCustom ActionKind = "custom"
+ ActionSkip ActionKind = "skip"
+ ActionRewrite ActionKind = "rewrite"
+ ActionDiagnostics ActionKind = "diagnostics"
+ ActionDocument ActionKind = "document"
+ ActionGoTest ActionKind = "gotest"
+ ActionSimplify ActionKind = "simplify"
+ ActionCustom ActionKind = "custom"
)
// InputParts represents parsed stdin input for actions.