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/appconfig/config.go | 84 ++++---- internal/appconfig/config_test.go | 38 ++-- internal/editor/editor.go | 102 ++++----- internal/editor/editor_test.go | 63 +++--- 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 +- internal/hexaicli/editor_integration_test.go | 83 ++++---- internal/hexaicli/run_more_test.go | 51 ++--- internal/hexailsp/run_more_test.go | 63 +++--- internal/llm/provider_more2_test.go | 13 +- internal/llmutils/client.go | 51 +++-- internal/llmutils/client_test.go | 33 ++- internal/logging/logging_test.go | 33 ++- internal/lsp/codeaction_gotest_int_test.go | 37 ++-- internal/lsp/coverage_add_test.go | 156 +++++++------- internal/lsp/handlers.go | 112 +++++----- internal/lsp/handlers_codeaction.go | 110 +++++----- internal/lsp/handlers_completion.go | 9 + internal/lsp/handlers_document.go | 2 +- internal/lsp/handlers_init_more_test.go | 15 +- internal/lsp/server.go | 28 +-- internal/testutil/fixtures_test.go | 9 +- internal/textutil/textutil.go | 192 +++++++++-------- internal/textutil/textutil_test.go | 130 ++++++------ internal/tmux/tmux.go | 18 +- internal/tmux/tmux_test.go | 135 ++++++------ 39 files changed, 1427 insertions(+), 1262 deletions(-) (limited to 'internal') diff --git a/internal/appconfig/config.go b/internal/appconfig/config.go index 87b5a29..2c4cee3 100644 --- a/internal/appconfig/config.go +++ b/internal/appconfig/config.go @@ -74,14 +74,14 @@ type App struct { // Code actions PromptCodeActionRewriteSystem string `json:"-" toml:"-"` PromptCodeActionDiagnosticsSystem string `json:"-" toml:"-"` - PromptCodeActionDocumentSystem string `json:"-" toml:"-"` - PromptCodeActionRewriteUser string `json:"-" toml:"-"` - PromptCodeActionDiagnosticsUser string `json:"-" toml:"-"` - PromptCodeActionDocumentUser string `json:"-" toml:"-"` - PromptCodeActionGoTestSystem string `json:"-" toml:"-"` - PromptCodeActionGoTestUser string `json:"-" toml:"-"` - PromptCodeActionSimplifySystem string `json:"-" toml:"-"` - PromptCodeActionSimplifyUser string `json:"-" toml:"-"` + PromptCodeActionDocumentSystem string `json:"-" toml:"-"` + PromptCodeActionRewriteUser string `json:"-" toml:"-"` + PromptCodeActionDiagnosticsUser string `json:"-" toml:"-"` + PromptCodeActionDocumentUser string `json:"-" toml:"-"` + PromptCodeActionGoTestSystem string `json:"-" toml:"-"` + PromptCodeActionGoTestUser string `json:"-" toml:"-"` + PromptCodeActionSimplifySystem string `json:"-" toml:"-"` + PromptCodeActionSimplifyUser string `json:"-" toml:"-"` // CLI PromptCLIDefaultSystem string `json:"-" toml:"-"` PromptCLIExplainSystem string `json:"-" toml:"-"` @@ -129,10 +129,10 @@ func newDefaultConfig() App { PromptCodeActionRewriteUser: "Instruction: {{instruction}}\n\nSelected code to transform:\n{{selection}}", PromptCodeActionDiagnosticsUser: "Diagnostics to resolve (selection only):\n{{diagnostics}}\n\nSelected code:\n{{selection}}", PromptCodeActionDocumentUser: "Add documentation comments to this code:\n{{selection}}", - PromptCodeActionGoTestSystem: "You are a precise Go unit test generator. Given a Go function, write one or more Test* functions using the testing package. Do NOT include package or imports, only the test function(s). Prefer table-driven tests. Keep it minimal and idiomatic.", - PromptCodeActionGoTestUser: "Function under test:\n{{function}}", - PromptCodeActionSimplifySystem: "You are a precise code improvement engine. Simplify and improve the given code while preserving behavior. Return only the improved code with no prose or backticks.", - PromptCodeActionSimplifyUser: "Improve this code:\n{{selection}}", + PromptCodeActionGoTestSystem: "You are a precise Go unit test generator. Given a Go function, write one or more Test* functions using the testing package. Do NOT include package or imports, only the test function(s). Prefer table-driven tests. Keep it minimal and idiomatic.", + PromptCodeActionGoTestUser: "Function under test:\n{{function}}", + PromptCodeActionSimplifySystem: "You are a precise code improvement engine. Simplify and improve the given code while preserving behavior. Return only the improved code with no prose or backticks.", + PromptCodeActionSimplifyUser: "Improve this code:\n{{selection}}", PromptCLIDefaultSystem: "You are Hexai CLI. Default to very short, concise answers. If the user asks for commands, output only the commands (one per line) with no commentary or explanation. Only when the word 'explain' appears in the prompt, produce a verbose explanation.", PromptCLIExplainSystem: "You are Hexai CLI. The user requested an explanation. Provide a clear, verbose explanation with reasoning and details. If commands are needed, include them with brief context.", @@ -260,16 +260,16 @@ type sectionPromptsChat struct { } type sectionPromptsCodeAction struct { - RewriteSystem string `toml:"rewrite_system"` - DiagnosticsSystem string `toml:"diagnostics_system"` - DocumentSystem string `toml:"document_system"` - RewriteUser string `toml:"rewrite_user"` - DiagnosticsUser string `toml:"diagnostics_user"` - DocumentUser string `toml:"document_user"` - GoTestSystem string `toml:"go_test_system"` - GoTestUser string `toml:"go_test_user"` - SimplifySystem string `toml:"simplify_system"` - SimplifyUser string `toml:"simplify_user"` + RewriteSystem string `toml:"rewrite_system"` + DiagnosticsSystem string `toml:"diagnostics_system"` + DocumentSystem string `toml:"document_system"` + RewriteUser string `toml:"rewrite_user"` + DiagnosticsUser string `toml:"diagnostics_user"` + DocumentUser string `toml:"document_user"` + GoTestSystem string `toml:"go_test_system"` + GoTestUser string `toml:"go_test_user"` + SimplifySystem string `toml:"simplify_system"` + SimplifyUser string `toml:"simplify_user"` } type sectionPromptsCLI struct { @@ -393,7 +393,7 @@ func (fc *fileConfig) toApp() App { out.PromptChatSystem = fc.Prompts.Chat.System } // code action - if (fc.Prompts.CodeAction != sectionPromptsCodeAction{}) { + if (fc.Prompts.CodeAction != sectionPromptsCodeAction{}) { if strings.TrimSpace(fc.Prompts.CodeAction.RewriteSystem) != "" { out.PromptCodeActionRewriteSystem = fc.Prompts.CodeAction.RewriteSystem } @@ -415,16 +415,16 @@ func (fc *fileConfig) toApp() App { if strings.TrimSpace(fc.Prompts.CodeAction.GoTestSystem) != "" { out.PromptCodeActionGoTestSystem = fc.Prompts.CodeAction.GoTestSystem } - if strings.TrimSpace(fc.Prompts.CodeAction.GoTestUser) != "" { - out.PromptCodeActionGoTestUser = fc.Prompts.CodeAction.GoTestUser - } - if strings.TrimSpace(fc.Prompts.CodeAction.SimplifySystem) != "" { - out.PromptCodeActionSimplifySystem = fc.Prompts.CodeAction.SimplifySystem - } - if strings.TrimSpace(fc.Prompts.CodeAction.SimplifyUser) != "" { - out.PromptCodeActionSimplifyUser = fc.Prompts.CodeAction.SimplifyUser - } - } + if strings.TrimSpace(fc.Prompts.CodeAction.GoTestUser) != "" { + out.PromptCodeActionGoTestUser = fc.Prompts.CodeAction.GoTestUser + } + if strings.TrimSpace(fc.Prompts.CodeAction.SimplifySystem) != "" { + out.PromptCodeActionSimplifySystem = fc.Prompts.CodeAction.SimplifySystem + } + if strings.TrimSpace(fc.Prompts.CodeAction.SimplifyUser) != "" { + out.PromptCodeActionSimplifyUser = fc.Prompts.CodeAction.SimplifyUser + } + } // cli if (fc.Prompts.CLI != sectionPromptsCLI{}) { if strings.TrimSpace(fc.Prompts.CLI.DefaultSystem) != "" { @@ -623,15 +623,15 @@ func (a *App) mergePrompts(other *App) { if strings.TrimSpace(other.PromptCodeActionGoTestSystem) != "" { a.PromptCodeActionGoTestSystem = other.PromptCodeActionGoTestSystem } - if strings.TrimSpace(other.PromptCodeActionGoTestUser) != "" { - a.PromptCodeActionGoTestUser = other.PromptCodeActionGoTestUser - } - if strings.TrimSpace(other.PromptCodeActionSimplifySystem) != "" { - a.PromptCodeActionSimplifySystem = other.PromptCodeActionSimplifySystem - } - if strings.TrimSpace(other.PromptCodeActionSimplifyUser) != "" { - a.PromptCodeActionSimplifyUser = other.PromptCodeActionSimplifyUser - } + if strings.TrimSpace(other.PromptCodeActionGoTestUser) != "" { + a.PromptCodeActionGoTestUser = other.PromptCodeActionGoTestUser + } + if strings.TrimSpace(other.PromptCodeActionSimplifySystem) != "" { + a.PromptCodeActionSimplifySystem = other.PromptCodeActionSimplifySystem + } + if strings.TrimSpace(other.PromptCodeActionSimplifyUser) != "" { + a.PromptCodeActionSimplifyUser = other.PromptCodeActionSimplifyUser + } // CLI if strings.TrimSpace(other.PromptCLIDefaultSystem) != "" { a.PromptCLIDefaultSystem = other.PromptCLIDefaultSystem diff --git a/internal/appconfig/config_test.go b/internal/appconfig/config_test.go index 5cb79a0..dc8c39c 100644 --- a/internal/appconfig/config_test.go +++ b/internal/appconfig/config_test.go @@ -25,15 +25,15 @@ func writeFile(t *testing.T, path, content string) { // clearHexaiEnv removes any HEXAI_* variables to prevent environment leakage // into tests that expect file-only configuration. func clearHexaiEnv(t *testing.T) { - t.Helper() - for _, e := range os.Environ() { - if strings.HasPrefix(e, "HEXAI_") { - kv := strings.SplitN(e, "=", 2) - if len(kv) > 0 { - t.Setenv(kv[0], "") - } - } - } + t.Helper() + for _, e := range os.Environ() { + if strings.HasPrefix(e, "HEXAI_") { + kv := strings.SplitN(e, "=", 2) + if len(kv) > 0 { + t.Setenv(kv[0], "") + } + } + } } func withEnv(t *testing.T, k, v string) { @@ -54,10 +54,10 @@ func TestLoad_Defaults_NoLogger(t *testing.T) { } func TestLoad_Defaults_WithLogger_NoFile_NoEnv(t *testing.T) { - clearHexaiEnv(t) - t.Setenv("XDG_CONFIG_HOME", t.TempDir()) - logger := newLogger() - cfg := Load(logger) + clearHexaiEnv(t) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + logger := newLogger() + cfg := Load(logger) def := newDefaultConfig() if cfg.MaxTokens != def.MaxTokens || cfg.ContextMode != def.ContextMode || cfg.ContextWindowLines != def.ContextWindowLines { t.Fatalf("expected defaults; got %+v want %+v", cfg, def) @@ -207,9 +207,9 @@ func TestLoadFromFile_InvalidTOML(t *testing.T) { } func TestLoad_FileTables_Sectioned(t *testing.T) { - clearHexaiEnv(t) - dir := t.TempDir() - t.Setenv("XDG_CONFIG_HOME", dir) + clearHexaiEnv(t) + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) cfgPath := filepath.Join(dir, "hexai", "config.toml") content := ` [general] @@ -290,9 +290,9 @@ temperature = 0.0 } func TestLoad_FileTables_Prompts_AllSections(t *testing.T) { - clearHexaiEnv(t) - dir := t.TempDir() - t.Setenv("XDG_CONFIG_HOME", dir) + clearHexaiEnv(t) + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) cfgPath := filepath.Join(dir, "hexai", "config.toml") content := ` [prompts.completion] diff --git a/internal/editor/editor.go b/internal/editor/editor.go index 44aa1d4..a1af1be 100644 --- a/internal/editor/editor.go +++ b/internal/editor/editor.go @@ -1,70 +1,70 @@ package editor import ( - "errors" - "os" - "os/exec" - "path/filepath" - "strings" + "errors" + "os" + "os/exec" + "path/filepath" + "strings" ) // Resolve returns the editor command from HEXAI_EDITOR or EDITOR. func Resolve() (string, error) { - ed := strings.TrimSpace(os.Getenv("HEXAI_EDITOR")) - if ed == "" { - ed = strings.TrimSpace(os.Getenv("EDITOR")) - } - if ed == "" { - return "", errors.New("no editor configured (set HEXAI_EDITOR or EDITOR)") - } - return ed, nil + ed := strings.TrimSpace(os.Getenv("HEXAI_EDITOR")) + if ed == "" { + ed = strings.TrimSpace(os.Getenv("EDITOR")) + } + if ed == "" { + return "", errors.New("no editor configured (set HEXAI_EDITOR or EDITOR)") + } + return ed, nil } // RunEditor is the seam that invokes the editor on the given file path. // Override in tests to avoid launching a real editor. var RunEditor = func(editor, path string) error { - cmd := exec.Command(editor, path) - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - return cmd.Run() + cmd := exec.Command(editor, path) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() } // OpenTempAndEdit creates a temporary .md file, writes initial content if provided, // opens it in the resolved editor, then reads the final content and removes the file. // Returns the trimmed content. func OpenTempAndEdit(initial []byte) (string, error) { - ed, err := Resolve() - if err != nil { - return "", err - } - // Create temp file under system temp dir; ensure .md suffix - dir := os.TempDir() - f, err := os.CreateTemp(dir, "hexai-*.md") - if err != nil { - return "", err - } - path := f.Name() - defer func() { _ = os.Remove(path) }() - if len(initial) > 0 { - if _, err := f.Write(initial); err != nil { - _ = f.Close() - return "", err - } - } - if err := f.Sync(); err != nil { - _ = f.Close() - return "", err - } - if err := f.Close(); err != nil { - return "", err - } - if err := RunEditor(ed, path); err != nil { - return "", err - } - b, err := os.ReadFile(filepath.Clean(path)) - if err != nil { - return "", err - } - return strings.TrimSpace(string(b)), nil + ed, err := Resolve() + if err != nil { + return "", err + } + // Create temp file under system temp dir; ensure .md suffix + dir := os.TempDir() + f, err := os.CreateTemp(dir, "hexai-*.md") + if err != nil { + return "", err + } + path := f.Name() + defer func() { _ = os.Remove(path) }() + if len(initial) > 0 { + if _, err := f.Write(initial); err != nil { + _ = f.Close() + return "", err + } + } + if err := f.Sync(); err != nil { + _ = f.Close() + return "", err + } + if err := f.Close(); err != nil { + return "", err + } + if err := RunEditor(ed, path); err != nil { + return "", err + } + b, err := os.ReadFile(filepath.Clean(path)) + if err != nil { + return "", err + } + return strings.TrimSpace(string(b)), nil } diff --git a/internal/editor/editor_test.go b/internal/editor/editor_test.go index df6dec7..06cc165 100644 --- a/internal/editor/editor_test.go +++ b/internal/editor/editor_test.go @@ -1,39 +1,44 @@ package editor import ( - "os" - "path/filepath" - "testing" + "os" + "path/filepath" + "testing" ) func TestResolve_EnvPriority(t *testing.T) { - t.Setenv("HEXAI_EDITOR", "ed1") - t.Setenv("EDITOR", "ed2") - ed, err := Resolve() - if err != nil || ed != "ed1" { - t.Fatalf("Resolve failed: %v %q", err, ed) - } - t.Setenv("HEXAI_EDITOR", "") - ed, err = Resolve() - if err != nil || ed != "ed2" { - t.Fatalf("Resolve fallback failed: %v %q", err, ed) - } + t.Setenv("HEXAI_EDITOR", "ed1") + t.Setenv("EDITOR", "ed2") + ed, err := Resolve() + if err != nil || ed != "ed1" { + t.Fatalf("Resolve failed: %v %q", err, ed) + } + t.Setenv("HEXAI_EDITOR", "") + ed, err = Resolve() + if err != nil || ed != "ed2" { + t.Fatalf("Resolve fallback failed: %v %q", err, ed) + } } func TestOpenTempAndEdit_UsesRunEditor(t *testing.T) { - old := RunEditor - t.Cleanup(func(){ RunEditor = old }) - // Ensure Resolve() succeeds - t.Setenv("HEXAI_EDITOR", "dummy") - var capturedPath string - RunEditor = func(editor, path string) error { - capturedPath = path - // simulate user writing content - return os.WriteFile(path, []byte("Hello\nWorld\n"), 0o600) - } - out, err := OpenTempAndEdit([]byte("# Start\n\n")) - if err != nil { t.Fatalf("OpenTempAndEdit: %v", err) } - if out != "Hello\nWorld" { t.Fatalf("unexpected content: %q", out) } - if filepath.Ext(capturedPath) != ".md" { t.Fatalf("expected .md suffix: %s", capturedPath) } + old := RunEditor + t.Cleanup(func() { RunEditor = old }) + // Ensure Resolve() succeeds + t.Setenv("HEXAI_EDITOR", "dummy") + var capturedPath string + RunEditor = func(editor, path string) error { + capturedPath = path + // simulate user writing content + return os.WriteFile(path, []byte("Hello\nWorld\n"), 0o600) + } + out, err := OpenTempAndEdit([]byte("# Start\n\n")) + if err != nil { + t.Fatalf("OpenTempAndEdit: %v", err) + } + if out != "Hello\nWorld" { + t.Fatalf("unexpected content: %q", out) + } + if filepath.Ext(capturedPath) != ".md" { + t.Fatalf("expected .md suffix: %s", capturedPath) + } } - 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") -