From 75cf6abd55bfb60324fc47cf91eac08dbb8b87b4 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Mon, 8 Sep 2025 12:02:40 +0300 Subject: docs: move tmux documentation to its own file --- internal/hexaiaction/cmdentry.go | 252 +++++++++++++---------- internal/hexaiaction/cmdentry_runcommand_test.go | 87 ++++---- internal/hexaiaction/cmdentry_test.go | 250 +++++++++++++--------- internal/hexaiaction/custom_action_test.go | 59 +++--- internal/hexaiaction/parse.go | 8 +- internal/hexaiaction/parse_test.go | 2 + internal/hexaiaction/prompts_more_test.go | 23 ++- internal/hexaiaction/run_more_test.go | 35 ++-- internal/hexaiaction/run_seam_test.go | 56 ++--- internal/hexaiaction/run_test.go | 70 +++---- internal/hexaiaction/tui.go | 114 +++++----- internal/hexaiaction/tui_delegate.go | 36 ++-- internal/hexaiaction/tui_delegate_test.go | 42 ++-- internal/hexaiaction/tui_test.go | 74 +++---- internal/hexaiaction/types.go | 14 +- 15 files changed, 620 insertions(+), 502 deletions(-) (limited to 'internal/hexaiaction') 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. -- cgit v1.2.3