summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/askcli/command_edit.go10
-rw-r--r--internal/askcli/command_edit_test.go31
-rw-r--r--internal/askcli/command_watch.go7
-rw-r--r--internal/askcli/command_watch_test.go33
-rw-r--r--internal/askcli/dispatch.go17
-rw-r--r--internal/editor/editor.go36
-rw-r--r--internal/editor/editor_test.go56
-rw-r--r--internal/hexaiaction/cmdentry.go54
-rw-r--r--internal/hexaiaction/cmdentry_runcommand_test.go18
-rw-r--r--internal/hexaiaction/cmdentry_test.go36
-rw-r--r--internal/hexaiaction/custom_action_test.go9
-rw-r--r--internal/hexaiaction/run.go28
-rw-r--r--internal/hexaiaction/tui.go12
-rw-r--r--internal/hexaiaction/tui_config_test.go9
-rw-r--r--internal/hexaiaction/tui_custom.go22
-rw-r--r--internal/hexaiaction/tui_custom_test.go9
-rw-r--r--internal/hexaicli/cache.go57
-rw-r--r--internal/hexaicli/cache_test.go25
-rw-r--r--internal/hexaicli/editor_integration_test.go33
-rw-r--r--internal/hexaicli/run.go8
-rw-r--r--internal/hexaicli/run_editor_behavior_test.go17
-rw-r--r--internal/hexaicli/runner.go23
-rw-r--r--internal/hexaicli/runner_test.go15
-rw-r--r--internal/stats/stats.go34
-rw-r--r--internal/stats/stats_test.go12
-rw-r--r--internal/tmux/status.go9
-rw-r--r--internal/tmux/status_coverage_test.go8
-rw-r--r--internal/tmux/tmux.go42
-rw-r--r--internal/tmux/tmux_test.go28
-rw-r--r--internal/tmuxedit/agent.go21
-rw-r--r--internal/tmuxedit/agent_test.go16
-rw-r--r--internal/tmuxedit/agentutil.go16
-rw-r--r--internal/tmuxedit/agentutil_test.go24
-rw-r--r--internal/tmuxedit/capture.go16
-rw-r--r--internal/tmuxedit/capture_test.go24
-rw-r--r--internal/tmuxedit/cursor_agent.go4
-rw-r--r--internal/tmuxedit/cursor_agent_test.go20
-rw-r--r--internal/tmuxedit/pane.go28
-rw-r--r--internal/tmuxedit/pane_test.go40
-rw-r--r--internal/tmuxedit/run.go45
-rw-r--r--internal/tmuxedit/run_test.go195
-rw-r--r--internal/tmuxedit/send.go39
-rw-r--r--internal/tmuxedit/send_test.go40
43 files changed, 648 insertions, 578 deletions
diff --git a/internal/askcli/command_edit.go b/internal/askcli/command_edit.go
index a82d575..e2c2dbf 100644
--- a/internal/askcli/command_edit.go
+++ b/internal/askcli/command_edit.go
@@ -8,11 +8,11 @@ import (
"codeberg.org/snonux/hexai/internal/editor"
)
-// captureFromEditor opens the user's editor on a temporary file pre-filled with
+// editorCapture opens the user's editor on a temporary file pre-filled with
// the given initial content and returns its trimmed contents after the editor
// exits. ctx is forwarded to the editor subprocess so it can be cancelled with
-// the surrounding command. It is a variable so tests can stub it.
-var captureFromEditor = func(ctx context.Context, initial []byte) (string, error) {
+// the surrounding command.
+func editorCapture(ctx context.Context, initial []byte) (string, error) {
return editor.OpenTempAndEdit(ctx, initial)
}
@@ -23,7 +23,7 @@ func (d *Dispatcher) handleEdit(ctx context.Context, args []string, stdout, stde
if len(args) >= 2 {
return d.editTaskDescription(ctx, args[1], stdout, stderr)
}
- description, err := captureFromEditor(ctx, nil)
+ description, err := d.capture(ctx, nil)
if err != nil {
writeInfoError(stderr, err)
return 1, nil
@@ -44,7 +44,7 @@ func (d *Dispatcher) editTaskDescription(ctx context.Context, selector string, s
return code, nil
}
- description, err := captureFromEditor(ctx, []byte(tasks[0].Description))
+ description, err := d.capture(ctx, []byte(tasks[0].Description))
if err != nil {
writeInfoError(stderr, err)
return 1, nil
diff --git a/internal/askcli/command_edit_test.go b/internal/askcli/command_edit_test.go
index 4db19c1..91569c8 100644
--- a/internal/askcli/command_edit_test.go
+++ b/internal/askcli/command_edit_test.go
@@ -9,13 +9,10 @@ import (
"testing"
)
-func stubEditorCapture(t *testing.T, content string, err error) {
- t.Helper()
- old := captureFromEditor
- captureFromEditor = func(_ context.Context, initial []byte) (string, error) {
+func stubEditorCapture(d *Dispatcher, content string, err error) {
+ d.capture = func(_ context.Context, initial []byte) (string, error) {
return content, err
}
- t.Cleanup(func() { captureFromEditor = old })
}
func TestHandleEdit_Success(t *testing.T) {
@@ -26,15 +23,14 @@ func TestHandleEdit_Success(t *testing.T) {
{UUID: "existing-uuid", Alias: "0", CreatedAt: now},
},
})
- // editor.OpenTempAndEdit trims content, so mimic that here.
- stubEditorCapture(t, "Multi line\ntask description", nil)
-
var capturedArgs []string
d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) {
capturedArgs = args
_, _ = io.WriteString(stdout, "Created task abc-123-def.")
return 0, nil
}})
+ // editor.OpenTempAndEdit trims content, so mimic that here.
+ stubEditorCapture(d, "Multi line\ntask description", nil)
var stdout, stderr bytes.Buffer
code, _ := d.Dispatch(context.Background(), []string{"edit"}, nil, &stdout, &stderr)
@@ -58,15 +54,8 @@ func TestHandleEdit_ExistingTaskModifiesDescription(t *testing.T) {
},
})
- var initialContent []byte
- old := captureFromEditor
- captureFromEditor = func(_ context.Context, initial []byte) (string, error) {
- initialContent = initial
- return "updated description", nil
- }
- t.Cleanup(func() { captureFromEditor = old })
-
var capturedArgs []string
+ var initialContent []byte
d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) {
// First call: export to resolve selector. Subsequent: modify.
if len(args) >= 2 && args[1] == "export" {
@@ -76,6 +65,10 @@ func TestHandleEdit_ExistingTaskModifiesDescription(t *testing.T) {
capturedArgs = args
return 0, nil
}})
+ d.capture = func(_ context.Context, initial []byte) (string, error) {
+ initialContent = initial
+ return "updated description", nil
+ }
var stdout, stderr bytes.Buffer
code, _ := d.Dispatch(context.Background(), []string{"edit", "0"}, nil, &stdout, &stderr)
@@ -97,12 +90,11 @@ func TestHandleEdit_ExistingTaskModifiesDescription(t *testing.T) {
}
func TestHandleEdit_EmptyContentAborts(t *testing.T) {
- stubEditorCapture(t, "", nil)
-
d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) {
t.Fatalf("runner should not be called on empty content")
return 0, nil
}})
+ stubEditorCapture(d, "", nil)
var stdout, stderr bytes.Buffer
code, _ := d.Dispatch(context.Background(), []string{"edit"}, nil, &stdout, &stderr)
@@ -115,12 +107,11 @@ func TestHandleEdit_EmptyContentAborts(t *testing.T) {
}
func TestHandleEdit_EditorError(t *testing.T) {
- stubEditorCapture(t, "", errors.New("boom"))
-
d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) {
t.Fatalf("runner should not be called when editor fails")
return 0, nil
}})
+ stubEditorCapture(d, "", errors.New("boom"))
var stdout, stderr bytes.Buffer
code, _ := d.Dispatch(context.Background(), []string{"edit"}, nil, &stdout, &stderr)
diff --git a/internal/askcli/command_watch.go b/internal/askcli/command_watch.go
index 8d8e35f..0e0a678 100644
--- a/internal/askcli/command_watch.go
+++ b/internal/askcli/command_watch.go
@@ -30,7 +30,10 @@ func (t realWatchTicker) Stop() {
t.ticker.Stop()
}
-var newWatchTicker = func(interval time.Duration) watchTicker {
+// newRealWatchTicker is the default watchTicker factory wired into a
+// Dispatcher by NewDispatcher. It is injected as Dispatcher.newTicker so tests
+// can substitute a fake ticker instead of mutating package state.
+func newRealWatchTicker(interval time.Duration) watchTicker {
return realWatchTicker{ticker: time.NewTicker(interval)}
}
@@ -63,7 +66,7 @@ func (d *Dispatcher) handleWatch(ctx context.Context, args []string, stdout, std
return 1, nil
}
- ticker := newWatchTicker(watchInterval)
+ ticker := d.newTicker(watchInterval)
defer ticker.Stop()
var lastOutput []byte
diff --git a/internal/askcli/command_watch_test.go b/internal/askcli/command_watch_test.go
index abd74ff..7618060 100644
--- a/internal/askcli/command_watch_test.go
+++ b/internal/askcli/command_watch_test.go
@@ -27,14 +27,11 @@ func (t *fakeWatchTicker) Stop() {
func TestHandleWatch_ForwardsNonZeroCode(t *testing.T) {
ticks := make(chan time.Time)
- oldTicker := newWatchTicker
- newWatchTicker = func(time.Duration) watchTicker { return &fakeWatchTicker{ch: ticks} }
- t.Cleanup(func() { newWatchTicker = oldTicker })
-
ctx := context.Background()
d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) {
return 1, nil
}})
+ d.newTicker = func(time.Duration) watchTicker { return &fakeWatchTicker{ch: ticks} }
var stdout, stderr bytes.Buffer
code, err := d.Dispatch(ctx, []string{"watch", "urgency"}, nil, &stdout, &stderr)
@@ -70,10 +67,6 @@ func TestHandleWatch_ForwardsInnerError(t *testing.T) {
func TestHandleWatch_DrawsStderrOnNonZero(t *testing.T) {
ticks := make(chan time.Time)
- oldTicker := newWatchTicker
- newWatchTicker = func(time.Duration) watchTicker { return &fakeWatchTicker{ch: ticks} }
- t.Cleanup(func() { newWatchTicker = oldTicker })
-
ctx, cancel := context.WithCancel(context.Background())
callCount := 0
d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) {
@@ -87,6 +80,7 @@ func TestHandleWatch_DrawsStderrOnNonZero(t *testing.T) {
_, _ = io.WriteString(stdout, "[]")
return 0, nil
}})
+ d.newTicker = func(time.Duration) watchTicker { return &fakeWatchTicker{ch: ticks} }
var out bytes.Buffer
code, err := d.Dispatch(ctx, []string{"watch", "info"}, nil, &out, &bytes.Buffer{})
@@ -118,14 +112,6 @@ func TestHandleWatch_DefaultsToListAndRedrawsOnChange(t *testing.T) {
ticks := make(chan time.Time, 1)
ticks <- time.Now()
fakeTicker := &fakeWatchTicker{ch: ticks}
- oldTicker := newWatchTicker
- newWatchTicker = func(interval time.Duration) watchTicker {
- if interval != watchInterval {
- t.Fatalf("watch interval = %s, want %s", interval, watchInterval)
- }
- return fakeTicker
- }
- t.Cleanup(func() { newWatchTicker = oldTicker })
ctx, cancel := context.WithCancel(context.Background())
var calls [][]string
@@ -139,6 +125,12 @@ func TestHandleWatch_DefaultsToListAndRedrawsOnChange(t *testing.T) {
cancel()
return 0, nil
}})
+ d.newTicker = func(interval time.Duration) watchTicker {
+ if interval != watchInterval {
+ t.Fatalf("watch interval = %s, want %s", interval, watchInterval)
+ }
+ return fakeTicker
+ }
var stdout, stderr bytes.Buffer
code, err := d.Dispatch(ctx, []string{"watch"}, nil, &stdout, &stderr)
@@ -176,9 +168,6 @@ func TestHandleWatch_DoesNotRedrawUnchangedOutput(t *testing.T) {
ticks := make(chan time.Time, 1)
ticks <- time.Now()
- oldTicker := newWatchTicker
- newWatchTicker = func(time.Duration) watchTicker { return &fakeWatchTicker{ch: ticks} }
- t.Cleanup(func() { newWatchTicker = oldTicker })
ctx, cancel := context.WithCancel(context.Background())
runCount := 0
@@ -190,6 +179,7 @@ func TestHandleWatch_DoesNotRedrawUnchangedOutput(t *testing.T) {
}
return 0, nil
}})
+ d.newTicker = func(time.Duration) watchTicker { return &fakeWatchTicker{ch: ticks} }
var stdout, stderr bytes.Buffer
code, err := d.Dispatch(ctx, []string{"watch", "list"}, nil, &stdout, &stderr)
@@ -206,10 +196,6 @@ func TestHandleWatch_DoesNotRedrawUnchangedOutput(t *testing.T) {
func TestHandleWatch_ForwardsSubcommandArgs(t *testing.T) {
ticks := make(chan time.Time)
- oldTicker := newWatchTicker
- newWatchTicker = func(time.Duration) watchTicker { return &fakeWatchTicker{ch: ticks} }
- t.Cleanup(func() { newWatchTicker = oldTicker })
-
ctx, cancel := context.WithCancel(context.Background())
var gotArgs []string
d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) {
@@ -218,6 +204,7 @@ func TestHandleWatch_ForwardsSubcommandArgs(t *testing.T) {
_, _ = io.WriteString(stdout, `[]`)
return 0, nil
}})
+ d.newTicker = func(time.Duration) watchTicker { return &fakeWatchTicker{ch: ticks} }
var stdout, stderr bytes.Buffer
code, err := d.Dispatch(ctx, []string{"watch", "ready", "limit:2"}, nil, &stdout, &stderr)
diff --git a/internal/askcli/dispatch.go b/internal/askcli/dispatch.go
index 924ca67..cdfc1d8 100644
--- a/internal/askcli/dispatch.go
+++ b/internal/askcli/dispatch.go
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"io"
+ "time"
)
// Runner performs CLI work that would otherwise be handled by the ask CLI itself.
@@ -14,18 +15,30 @@ type Runner interface {
}
// Dispatcher translates CLI arguments into concrete subcommands and presents the output.
+//
+// newTicker is the injected factory for the watch-loop ticker. Production code
+// uses the real time.Ticker-backed factory installed by NewDispatcher; tests
+// inject a fake ticker so they can drive the watch loop without real delays.
type Dispatcher struct {
runner Runner
jsonOutput bool
+ newTicker func(time.Duration) watchTicker
+ capture func(context.Context, []byte) (string, error)
}
-// NewDispatcher creates a Dispatcher backed by the provided Runner or a default executor when nil.
+// NewDispatcher creates a Dispatcher backed by the provided Runner or a default
+// executor when nil. It wires the default real-ticker factory used by the
+// `ask watch` loop.
func NewDispatcher(runner Runner) *Dispatcher {
if runner == nil {
e := NewExecutor("ask")
runner = &e
}
- return &Dispatcher{runner: runner}
+ return &Dispatcher{
+ runner: runner,
+ newTicker: newRealWatchTicker,
+ capture: editorCapture,
+ }
}
func parseGlobalFlags(args []string) ([]string, bool) {
diff --git a/internal/editor/editor.go b/internal/editor/editor.go
index 9a9e737..aad7047 100644
--- a/internal/editor/editor.go
+++ b/internal/editor/editor.go
@@ -21,11 +21,25 @@ func Resolve() (string, error) {
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. It uses
-// exec.CommandContext so a cancelled ctx (e.g. process shutdown) kills the
-// editor subprocess instead of leaving it blocking on terminal input.
-var RunEditor = func(ctx context.Context, editor, path string) error {
+type Runner struct {
+ runEditor func(context.Context, string, string) error
+}
+
+func (r Runner) edit(ctx context.Context, editor, path string) error {
+ if r.runEditor != nil {
+ return r.runEditor(ctx, editor, path)
+ }
+ return runEditor(ctx, editor, path)
+}
+
+// RunEditor invokes the editor on the given file path. It uses
+// exec.CommandContext so a cancelled ctx kills the editor subprocess instead
+// of leaving it blocking on terminal input.
+func RunEditor(ctx context.Context, editor, path string) error {
+ return runEditor(ctx, editor, path)
+}
+
+func runEditor(ctx context.Context, editor, path string) error {
cmd := exec.CommandContext(ctx, editor, path)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
@@ -38,6 +52,10 @@ var RunEditor = func(ctx context.Context, editor, path string) error {
// Returns the trimmed content. ctx is forwarded to the editor subprocess so it
// can be cancelled along with the surrounding command.
func OpenTempAndEdit(ctx context.Context, initial []byte) (string, error) {
+ return Runner{}.OpenTempAndEdit(ctx, initial)
+}
+
+func (r Runner) OpenTempAndEdit(ctx context.Context, initial []byte) (string, error) {
ed, err := Resolve()
if err != nil {
return "", err
@@ -63,7 +81,7 @@ func OpenTempAndEdit(ctx context.Context, initial []byte) (string, error) {
if err := f.Close(); err != nil {
return "", err
}
- if err := RunEditor(ctx, ed, path); err != nil {
+ if err := r.edit(ctx, ed, path); err != nil {
return "", err
}
b, err := os.ReadFile(filepath.Clean(path))
@@ -77,6 +95,10 @@ func OpenTempAndEdit(ctx context.Context, initial []byte) (string, error) {
// from Resolve() (HEXAI_EDITOR or EDITOR). ctx is forwarded to the editor
// subprocess so it can be cancelled with the surrounding command.
func OpenFile(ctx context.Context, path string) error {
+ return Runner{}.OpenFile(ctx, path)
+}
+
+func (r Runner) OpenFile(ctx context.Context, path string) error {
ed, err := Resolve()
if err != nil {
return err
@@ -91,5 +113,5 @@ func OpenFile(ctx context.Context, path string) error {
return mkErr
}
}
- return RunEditor(ctx, ed, path)
+ return r.edit(ctx, ed, path)
}
diff --git a/internal/editor/editor_test.go b/internal/editor/editor_test.go
index f2c1e22..543235e 100644
--- a/internal/editor/editor_test.go
+++ b/internal/editor/editor_test.go
@@ -75,17 +75,15 @@ func TestResolve_WhitespaceOnly(t *testing.T) {
}
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(_ context.Context, editor, path string) error {
+ r := Runner{runEditor: func(_ context.Context, editor, path string) error {
capturedPath = path
// simulate user writing content
return os.WriteFile(path, []byte("Hello\nWorld\n"), 0o600)
- }
- out, err := OpenTempAndEdit(context.Background(), []byte("# Start\n\n"))
+ }}
+ out, err := r.OpenTempAndEdit(context.Background(), []byte("# Start\n\n"))
if err != nil {
t.Fatalf("OpenTempAndEdit: %v", err)
}
@@ -109,14 +107,12 @@ func TestOpenTempAndEdit_NoEditor(t *testing.T) {
// TestOpenTempAndEdit_NilInitial verifies that nil initial content works (empty file).
func TestOpenTempAndEdit_NilInitial(t *testing.T) {
- old := RunEditor
- t.Cleanup(func() { RunEditor = old })
t.Setenv("HEXAI_EDITOR", "dummy")
- RunEditor = func(_ context.Context, editor, path string) error {
+ r := Runner{runEditor: func(_ context.Context, editor, path string) error {
// simulate user writing content into a file that started empty
return os.WriteFile(path, []byte("result"), 0o600)
- }
- out, err := OpenTempAndEdit(context.Background(), nil)
+ }}
+ out, err := r.OpenTempAndEdit(context.Background(), nil)
if err != nil {
t.Fatalf("OpenTempAndEdit with nil initial: %v", err)
}
@@ -128,13 +124,11 @@ func TestOpenTempAndEdit_NilInitial(t *testing.T) {
// TestOpenTempAndEdit_EmptyInitial verifies that empty (zero-length) initial content
// skips the write branch but still works end-to-end.
func TestOpenTempAndEdit_EmptyInitial(t *testing.T) {
- old := RunEditor
- t.Cleanup(func() { RunEditor = old })
t.Setenv("HEXAI_EDITOR", "dummy")
- RunEditor = func(_ context.Context, editor, path string) error {
+ r := Runner{runEditor: func(_ context.Context, editor, path string) error {
return os.WriteFile(path, []byte(" trimmed "), 0o600)
- }
- out, err := OpenTempAndEdit(context.Background(), []byte{})
+ }}
+ out, err := r.OpenTempAndEdit(context.Background(), []byte{})
if err != nil {
t.Fatalf("OpenTempAndEdit with empty initial: %v", err)
}
@@ -145,14 +139,12 @@ func TestOpenTempAndEdit_EmptyInitial(t *testing.T) {
// TestOpenTempAndEdit_EditorError verifies that an editor failure propagates the error.
func TestOpenTempAndEdit_EditorError(t *testing.T) {
- old := RunEditor
- t.Cleanup(func() { RunEditor = old })
t.Setenv("HEXAI_EDITOR", "dummy")
editorErr := errors.New("editor crashed")
- RunEditor = func(_ context.Context, editor, path string) error {
+ r := Runner{runEditor: func(_ context.Context, editor, path string) error {
return editorErr
- }
- _, err := OpenTempAndEdit(context.Background(), []byte("some content"))
+ }}
+ _, err := r.OpenTempAndEdit(context.Background(), []byte("some content"))
if err == nil {
t.Fatal("expected error when editor fails")
}
@@ -163,14 +155,12 @@ func TestOpenTempAndEdit_EditorError(t *testing.T) {
// TestOpenTempAndEdit_EditorDeletesFile verifies error when the editor removes the temp file.
func TestOpenTempAndEdit_EditorDeletesFile(t *testing.T) {
- old := RunEditor
- t.Cleanup(func() { RunEditor = old })
t.Setenv("HEXAI_EDITOR", "dummy")
- RunEditor = func(_ context.Context, editor, path string) error {
+ r := Runner{runEditor: func(_ context.Context, editor, path string) error {
// simulate the editor deleting the file
return os.Remove(path)
- }
- _, err := OpenTempAndEdit(context.Background(), []byte("content"))
+ }}
+ _, err := r.OpenTempAndEdit(context.Background(), []byte("content"))
if err == nil {
t.Fatal("expected error when temp file is deleted by editor")
}
@@ -178,15 +168,13 @@ func TestOpenTempAndEdit_EditorDeletesFile(t *testing.T) {
// TestOpenTempAndEdit_TempFileCleanup verifies the temp file is removed after success.
func TestOpenTempAndEdit_TempFileCleanup(t *testing.T) {
- old := RunEditor
- t.Cleanup(func() { RunEditor = old })
t.Setenv("HEXAI_EDITOR", "dummy")
var capturedPath string
- RunEditor = func(_ context.Context, editor, path string) error {
+ r := Runner{runEditor: func(_ context.Context, editor, path string) error {
capturedPath = path
return os.WriteFile(path, []byte("done"), 0o600)
- }
- _, err := OpenTempAndEdit(context.Background(), nil)
+ }}
+ _, err := r.OpenTempAndEdit(context.Background(), nil)
if err != nil {
t.Fatalf("OpenTempAndEdit: %v", err)
}
@@ -197,17 +185,15 @@ func TestOpenTempAndEdit_TempFileCleanup(t *testing.T) {
}
func TestOpenFile_CreatesParentAndInvokesEditor(t *testing.T) {
- old := RunEditor
- t.Cleanup(func() { RunEditor = old })
t.Setenv("HEXAI_EDITOR", "dummy")
target := filepath.Join(t.TempDir(), "nested", "config.toml")
var gotEditor, gotPath string
- RunEditor = func(_ context.Context, editorCmd, path string) error {
+ r := Runner{runEditor: func(_ context.Context, editorCmd, path string) error {
gotEditor = editorCmd
gotPath = path
return nil
- }
- if err := OpenFile(context.Background(), target); err != nil {
+ }}
+ if err := r.OpenFile(context.Background(), target); err != nil {
t.Fatalf("OpenFile: %v", err)
}
if gotEditor != "dummy" {
diff --git a/internal/hexaiaction/cmdentry.go b/internal/hexaiaction/cmdentry.go
index 85e9059..ec4970c 100644
--- a/internal/hexaiaction/cmdentry.go
+++ b/internal/hexaiaction/cmdentry.go
@@ -25,23 +25,47 @@ type Options struct {
// 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 {
+ return commandRunner{}.RunCommand(ctx, opts, stdin, stdout, stderr)
+}
+
+type commandRunner struct {
+ popupRun func(tmux.PopupOpts, []string) error
+ osExecutable func() (string, error)
+ run func(context.Context, io.Reader, io.Writer, io.Writer) error
+}
+
+func (r commandRunner) popup(opts tmux.PopupOpts, argv []string) error {
+ if r.popupRun != nil {
+ return r.popupRun(opts, argv)
+ }
+ return tmux.PopupRun(opts, argv)
+}
+
+func (r commandRunner) executable() (string, error) {
+ if r.osExecutable != nil {
+ return r.osExecutable()
+ }
+ return os.Executable()
+}
+
+func (r commandRunner) runAction(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer) error {
+ if r.run != nil {
+ return r.run(ctx, stdin, stdout, stderr)
+ }
+ return Run(ctx, stdin, stdout, stderr)
+}
+
+func (r commandRunner) RunCommand(ctx context.Context, opts Options, stdin io.Reader, stdout, stderr io.Writer) error {
if err := llm.RegisterAllProviders(); err != nil {
return fmt.Errorf("failed to register LLM providers: %w", err)
}
if opts.UIChild {
- return runChild(ctx, opts.Infile, opts.Outfile, stdout, stderr)
+ return r.runChild(ctx, opts.Infile, opts.Outfile, stdout, stderr)
}
// Always use tmux popup path
- return runInTmuxParent(ctx, stdin, stdout, opts.TmuxTarget, opts.TmuxPopupWidth, opts.TmuxPopupHeight)
+ return r.runInTmuxParent(ctx, stdin, stdout, opts.TmuxTarget, opts.TmuxPopupWidth, opts.TmuxPopupHeight)
}
-// seams for unit tests
-var (
- popupRunFn = tmux.PopupRun
- 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)
@@ -68,7 +92,7 @@ func openIO(infile, outfile string) (io.Reader, io.Writer, func(), func(), error
}
// 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 {
+func (r commandRunner) 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
@@ -80,7 +104,7 @@ func runChild(ctx context.Context, infile, outfile string, stdout, stderr io.Wri
defer func() { _ = f.Close() }()
in = f
}
- return runFn(ctx, in, stdout, stderr)
+ return r.runAction(ctx, in, stdout, stderr)
}
tmp := outfile + ".tmp"
in, out, closeIn, closeOut, err := openIO(infile, tmp)
@@ -88,7 +112,7 @@ func runChild(ctx context.Context, infile, outfile string, stdout, stderr io.Wri
return err
}
defer closeIn()
- if err := runFn(ctx, in, out, stderr); err != nil {
+ if err := r.runAction(ctx, in, out, stderr); err != nil {
closeOut()
if copyErr := echoThrough(infile, tmp, os.Stdin, stdout); copyErr != nil {
// Wrap the primary child error with %w so callers can inspect it
@@ -102,7 +126,7 @@ func runChild(ctx context.Context, infile, outfile string, stdout, stderr io.Wri
return os.Rename(tmp, outfile)
}
-func runInTmuxParent(ctx context.Context, stdin io.Reader, stdout io.Writer, target, popupWidth, popupHeight string) error {
+func (r commandRunner) runInTmuxParent(ctx context.Context, stdin io.Reader, stdout io.Writer, target, popupWidth, popupHeight string) error {
dir, err := os.MkdirTemp("", "hexai-tmux-action-")
if err != nil {
return err
@@ -113,13 +137,13 @@ func runInTmuxParent(ctx context.Context, stdin io.Reader, stdout io.Writer, tar
if err := persistStdin(inPath, stdin); err != nil {
return err
}
- exe, err := osExecutableFn()
+ exe, err := r.executable()
if err != nil {
return err
}
argv := []string{exe, "-ui-child", "-infile", inPath, "-outfile", outPath}
opts := tmux.PopupOpts{Target: target, Width: popupWidth, Height: popupHeight}
- if err := popupRunFn(opts, argv); err != nil {
+ if err := r.popup(opts, argv); err != nil {
return err
}
if err := waitForFile(ctx, outPath, 60*time.Second); err != nil {
diff --git a/internal/hexaiaction/cmdentry_runcommand_test.go b/internal/hexaiaction/cmdentry_runcommand_test.go
index ac6106d..b71a0f2 100644
--- a/internal/hexaiaction/cmdentry_runcommand_test.go
+++ b/internal/hexaiaction/cmdentry_runcommand_test.go
@@ -16,14 +16,12 @@ func TestRunCommand_UIChild(t *testing.T) {
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 {
+ r := commandRunner{run: 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 {
+ if err := r.RunCommand(context.Background(), opts, bytes.NewBuffer(nil), io.Discard, io.Discard); err != nil {
t.Fatalf("RunCommand UIChild: %v", err)
}
b, _ := os.ReadFile(out)
@@ -33,10 +31,9 @@ func TestRunCommand_UIChild(t *testing.T) {