diff options
| author | Paul Buetow <paul@buetow.org> | 2026-02-08 11:14:36 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-02-08 11:14:36 +0200 |
| commit | 5e825543dc55a2c649e68dce6341844ad71fa217 (patch) | |
| tree | f7aae1c1d130f08c383f95a23413bdde7843dc0f | |
| parent | 023ed82e612451caa38ec46106ed9d148ab9a595 (diff) | |
add hexai-tmux-edit: tmux popup editor for AI agent prompts
New tool that opens $EDITOR in a tmux popup for composing longer prompts
when working with AI CLI agents (Claude Code, Cursor, Amp, Aider, etc.).
Captures existing prompt text from the target pane, pre-fills the editor,
and sends edited text back via tmux send-keys. Config-driven agent
detection via regex patterns in [tmux_edit] config section.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
| -rw-r--r-- | Magefile.go | 50 | ||||
| -rw-r--r-- | cmd/hexai-tmux-edit/main.go | 48 | ||||
| -rw-r--r-- | config.toml.example | 32 | ||||
| -rw-r--r-- | internal/appconfig/config.go | 92 | ||||
| -rw-r--r-- | internal/appconfig/config_test.go | 98 | ||||
| -rw-r--r-- | internal/tmuxedit/agent.go | 212 | ||||
| -rw-r--r-- | internal/tmuxedit/agent_test.go | 260 | ||||
| -rw-r--r-- | internal/tmuxedit/capture.go | 17 | ||||
| -rw-r--r-- | internal/tmuxedit/capture_test.go | 51 | ||||
| -rw-r--r-- | internal/tmuxedit/pane.go | 42 | ||||
| -rw-r--r-- | internal/tmuxedit/pane_test.go | 83 | ||||
| -rw-r--r-- | internal/tmuxedit/run.go | 148 | ||||
| -rw-r--r-- | internal/tmuxedit/run_test.go | 320 | ||||
| -rw-r--r-- | internal/tmuxedit/send.go | 74 | ||||
| -rw-r--r-- | internal/tmuxedit/send_test.go | 170 |
15 files changed, 1678 insertions, 19 deletions
diff --git a/Magefile.go b/Magefile.go index 1644f08..fb43238 100644 --- a/Magefile.go +++ b/Magefile.go @@ -24,7 +24,7 @@ var ( // Build builds binaries. func Build() error { - mg.Deps(BuildHexaiLSP, BuildHexaiCLI, BuildHexaiTmuxAction) + mg.Deps(BuildHexaiLSP, BuildHexaiCLI, BuildHexaiTmuxAction, BuildHexaiTmuxEdit) printCoverage() return nil } @@ -47,6 +47,12 @@ func BuildHexaiTmuxAction() error { return sh.RunV("go", "build", "-o", "hexai-tmux-action", "cmd/hexai-tmux-action/main.go") } +// BuildHexaiTmuxEdit builds the hexai-tmux-edit popup editor binary. +func BuildHexaiTmuxEdit() error { + printCoverage() + return sh.RunV("go", "build", "-o", "hexai-tmux-edit", "cmd/hexai-tmux-edit/main.go") +} + // Dev runs tests, vet, lint, then builds with race for both binaries. func Dev() error { printCoverage() @@ -57,7 +63,10 @@ func Dev() error { if err := sh.RunV("go", "build", "-race", "-o", "hexai", "cmd/hexai/main.go"); err != nil { return err } - return sh.RunV("go", "build", "-race", "-o", "hexai-tmux-action", "cmd/hexai-tmux-action/main.go") + if err := sh.RunV("go", "build", "-race", "-o", "hexai-tmux-action", "cmd/hexai-tmux-action/main.go"); err != nil { + return err + } + return sh.RunV("go", "build", "-race", "-o", "hexai-tmux-edit", "cmd/hexai-tmux-edit/main.go") } // Run launches the LSP server via go run (useful during development). @@ -97,7 +106,10 @@ func Install() error { if err := sh.RunV("cp", "-v", "./hexai", bin+"/"); err != nil { return err } - return sh.RunV("cp", "-v", "./hexai-tmux-action", bin+"/") + if err := sh.RunV("cp", "-v", "./hexai-tmux-action", bin+"/"); err != nil { + return err + } + return sh.RunV("cp", "-v", "./hexai-tmux-edit", bin+"/") } // RunTmuxAction runs the hexai-tmux-action TUI via go run (reads stdin). @@ -109,8 +121,8 @@ func RunTmuxAction() error { // printCoverage prints a warning if an existing coverage profile shows total < coverateThreshold. func printCoverage() { - // Ensure the top-level coverage profile is refreshed at least once per day. - ensureDailyCoverage(24 * time.Hour) + // Ensure the top-level coverage profile is refreshed at least once per day. + ensureDailyCoverage(24 * time.Hour) select { case coveragePrinted <- struct{}{}: default: @@ -126,20 +138,20 @@ func printCoverage() { fmt.Println("[coverage] No coverage profile found (run 'mage cover' or 'mage coverall').") return } - pct, ok := totalCoveragePercent(profile) - if !ok { - // Attempt a one-time regen if the profile is malformed - if err := Coverage(); err == nil { - if p2, ok2 := totalCoveragePercent(profile); ok2 { - pct = p2 - ok = true - } - } - } - if !ok { - fmt.Println("[coverage] Could not parse total coverage from", profile) - return - } + pct, ok := totalCoveragePercent(profile) + if !ok { + // Attempt a one-time regen if the profile is malformed + if err := Coverage(); err == nil { + if p2, ok2 := totalCoveragePercent(profile); ok2 { + pct = p2 + ok = true + } + } + } + if !ok { + fmt.Println("[coverage] Could not parse total coverage from", profile) + return + } if pct < coverageThreshold { fmt.Printf("[coverage] WARNING: total test coverage is %.1f%% (< %.1f%%)\n", pct, coverageThreshold) } else { diff --git a/cmd/hexai-tmux-edit/main.go b/cmd/hexai-tmux-edit/main.go new file mode 100644 index 0000000..928a2cd --- /dev/null +++ b/cmd/hexai-tmux-edit/main.go @@ -0,0 +1,48 @@ +// hexai-tmux-edit opens a tmux popup with $EDITOR for composing AI agent +// prompts. It captures existing prompt text from the target pane, pre-fills +// the editor, and sends the edited text back via tmux send-keys. +// +// Usage: +// +// hexai-tmux-edit [--config <path>] [--agent <name>] [--pane <id>] +// +// Tmux keybinding (add to ~/.tmux.conf): +// +// bind e run-shell -b "hexai-tmux-edit --pane '#{pane_id}'" +package main + +import ( + "flag" + "fmt" + "os" + "strings" + + "codeberg.org/snonux/hexai/internal/appconfig" + "codeberg.org/snonux/hexai/internal/tmuxedit" +) + +func main() { + defaultPath := defaultConfigPath() + configPath := flag.String("config", "", fmt.Sprintf("path to config file (default: %s)", defaultPath)) + agent := flag.String("agent", "", "AI agent name (auto-detected if omitted)") + pane := flag.String("pane", "", "tmux target pane ID (e.g. %%5)") + flag.Parse() + + opts := tmuxedit.Options{ + ConfigPath: strings.TrimSpace(*configPath), + Agent: strings.TrimSpace(*agent), + Pane: strings.TrimSpace(*pane), + } + if err := tmuxedit.Run(opts); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func defaultConfigPath() string { + path, err := appconfig.ConfigPath() + if err != nil { + return "$XDG_CONFIG_HOME/hexai/config.toml" + } + return path +} diff --git a/config.toml.example b/config.toml.example index f732300..cc4471d 100644 --- a/config.toml.example +++ b/config.toml.example @@ -153,3 +153,35 @@ temperature = 0.2 # gitignore = true # respect .gitignore patterns (default: true) # extra_patterns = ["*.min.js", "vendor/**", "*.generated.go"] # lsp_notify_ignored = true # show "file ignored" in LSP completions (default: true) + +[tmux_edit] +# popup_width = "80%" # tmux popup width (default: 80%) +# popup_height = "80%" # tmux popup height (default: 80%) +# default_agent = "" # force agent name; skip auto-detect + +# Override or add agent definitions (merged with built-in defaults by name). +# Built-in agents: claude, cursor, amp, aider. +# Tmux keybinding (add to ~/.tmux.conf): +# bind e run-shell -b "hexai-tmux-edit --pane '#{pane_id}'" + +# [[tmux_edit.agents]] +# name = "claude" +# display_name = "Claude Code" +# detect_pattern = "(?i)(claude|anthropic)" +# prompt_pattern = '(?m)>\s*(.+)$' +# strip_patterns = [] +# clear_first = true +# clear_keys = "C-u" +# newline_keys = "S-Enter" +# submit_keys = "Enter" + +# [[tmux_edit.agents]] +# name = "cursor" +# display_name = "Cursor" +# detect_pattern = "(?i)cursor" +# prompt_pattern = '(?m)│\s*(.+)$' +# strip_patterns = ["INSERT", "Add a follow-up"] +# clear_first = true +# clear_keys = "C-u" +# newline_keys = "S-Enter" +# submit_keys = "Enter" diff --git a/internal/appconfig/config.go b/internal/appconfig/config.go index 8ec29ae..b21a4de 100644 --- a/internal/appconfig/config.go +++ b/internal/appconfig/config.go @@ -118,6 +118,12 @@ type App struct { IgnoreGitignore *bool `json:"-" toml:"-"` IgnoreExtraPatterns []string `json:"-" toml:"-"` IgnoreLSPNotify *bool `json:"-" toml:"-"` + + // TmuxEdit: popup editor settings for hexai-tmux-edit + TmuxEditPopupWidth string `json:"-" toml:"-"` + TmuxEditPopupHeight string `json:"-" toml:"-"` + TmuxEditDefaultAgent string `json:"-" toml:"-"` + TmuxEditAgents []TmuxEditAgentCfg `json:"-" toml:"-"` } // CustomAction describes a user-defined code action. @@ -132,6 +138,20 @@ type CustomAction struct { User string // optional; if set, render with available vars } +// TmuxEditAgentCfg describes an AI agent's detection and interaction patterns +// for the tmux popup editor (hexai-tmux-edit). +type TmuxEditAgentCfg struct { + Name string + DisplayName string + DetectPattern string + PromptPattern string + StripPatterns []string + ClearFirst *bool + ClearKeys string + NewlineKeys string + SubmitKeys string +} + // Constructor: defaults for App (kept first among functions) func newDefaultConfig() App { // Coding-friendly default temperature across providers @@ -281,6 +301,7 @@ type fileConfig struct { Tmux sectionTmux `toml:"tmux"` Stats sectionStats `toml:"stats"` Ignore sectionIgnore `toml:"ignore"` + TmuxEdit sectionTmuxEdit `toml:"tmux_edit"` } type sectionGeneral struct { @@ -333,6 +354,27 @@ type sectionIgnore struct { LSPNotifyIgnored *bool `toml:"lsp_notify_ignored"` } +// sectionTmuxEdit configures the tmux popup editor feature (hexai-tmux-edit). +type sectionTmuxEdit struct { + PopupWidth string `toml:"popup_width"` + PopupHeight string `toml:"popup_height"` + DefaultAgent string `toml:"default_agent"` + Agents []sectionTmuxEditAgent `toml:"agents"` +} + +// sectionTmuxEditAgent defines detection and interaction patterns for one AI agent. +type sectionTmuxEditAgent struct { + Name string `toml:"name"` + DisplayName string `toml:"display_name"` + DetectPattern string `toml:"detect_pattern"` + PromptPattern string `toml:"prompt_pattern"` + StripPatterns []string `toml:"strip_patterns"` + ClearFirst *bool `toml:"clear_first"` + ClearKeys string `toml:"clear_keys"` + NewlineKeys string `toml:"newline_keys"` + SubmitKeys string `toml:"submit_keys"` +} + type sectionOpenAI struct { Model string `toml:"model"` BaseURL string `toml:"base_url"` @@ -659,9 +701,42 @@ func (fc *fileConfig) toApp() App { out.mergeBasics(&tmp) } + // tmux_edit + fc.applyTmuxEdit(&out) + return out } +// applyTmuxEdit converts the [tmux_edit] section into App fields. +func (fc *fileConfig) applyTmuxEdit(out *App) { + te := fc.TmuxEdit + if strings.TrimSpace(te.PopupWidth) != "" { + out.TmuxEditPopupWidth = strings.TrimSpace(te.PopupWidth) + } + if strings.TrimSpace(te.PopupHeight) != "" { + out.TmuxEditPopupHeight = strings.TrimSpace(te.PopupHeight) + } + if strings.TrimSpace(te.DefaultAgent) != "" { + out.TmuxEditDefaultAgent = strings.TrimSpace(te.DefaultAgent) + } + for _, a := range te.Agents { + if strings.TrimSpace(a.Name) == "" { + continue + } + out.TmuxEditAgents = append(out.TmuxEditAgents, TmuxEditAgentCfg{ + Name: strings.TrimSpace(a.Name), + DisplayName: strings.TrimSpace(a.DisplayName), + DetectPattern: strings.TrimSpace(a.DetectPattern), + PromptPattern: strings.TrimSpace(a.PromptPattern), + StripPatterns: a.StripPatterns, + ClearFirst: a.ClearFirst, + ClearKeys: strings.TrimSpace(a.ClearKeys), + NewlineKeys: strings.TrimSpace(a.NewlineKeys), + SubmitKeys: strings.TrimSpace(a.SubmitKeys), + }) + } +} + func loadFromFile(path string, logger *log.Logger) (*App, error) { b, err := os.ReadFile(path) if err != nil { @@ -900,6 +975,7 @@ func (a *App) mergeWith(other *App) { a.mergeProviderFields(other) a.mergeSurfaceModels(other) a.mergePrompts(other) + a.mergeTmuxEdit(other) } // mergeBasics merges general (non-provider) fields. @@ -1060,6 +1136,22 @@ func (a *App) mergePrompts(other *App) { } // Validate checks custom actions and tmux settings for duplicates and consistency. +// mergeTmuxEdit copies non-empty tmux edit settings from other. +func (a *App) mergeTmuxEdit(other *App) { + if s := strings.TrimSpace(other.TmuxEditPopupWidth); s != "" { + a.TmuxEditPopupWidth = s + } + if s := strings.TrimSpace(other.TmuxEditPopupHeight); s != "" { + a.TmuxEditPopupHeight = s + } + if s := strings.TrimSpace(other.TmuxEditDefaultAgent); s != "" { + a.TmuxEditDefaultAgent = s + } + if len(other.TmuxEditAgents) > 0 { + a.TmuxEditAgents = append([]TmuxEditAgentCfg{}, other.TmuxEditAgents...) + } +} + func (a App) Validate() error { // Normalize and check duplicates for IDs and hotkeys seenID := make(map[string]struct{}) diff --git a/internal/appconfig/config_test.go b/internal/appconfig/config_test.go index b9dfe3a..6b8ee5b 100644 --- a/internal/appconfig/config_test.go +++ b/internal/appconfig/config_test.go @@ -893,3 +893,101 @@ gitignore = false t.Error("expected IgnoreLSPNotify to remain true (default)") } } + +func TestTmuxEditConfig_FromFile(t *testing.T) { + clearHexaiEnv(t) + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.toml") + writeFile(t, cfgPath, ` +[tmux_edit] +popup_width = "90%" +popup_height = "85%" +default_agent = "claude" + +[[tmux_edit.agents]] +name = "claude" +display_name = "Claude Code" +detect_pattern = "(?i)(claude|anthropic)" +prompt_pattern = '(?s)>\s*(.+?)$' +clear_first = true +clear_keys = "C-u" +newline_keys = "S-Enter" +submit_keys = "Enter" + +[[tmux_edit.agents]] +name = "cursor" +display_name = "Cursor" +detect_pattern = "(?i)cursor" +prompt_pattern = '(?s)│\s*(.+?)$' +strip_patterns = ["INSERT", "Add a follow-up"] +clear_first = true +clear_keys = "C-u" +newline_keys = "S-Enter" +submit_keys = "Enter" +`) + cfg := LoadWithOptions(newLogger(), LoadOptions{ConfigPath: cfgPath}) + if cfg.TmuxEditPopupWidth != "90%" { + t.Errorf("PopupWidth = %q, want 90%%", cfg.TmuxEditPopupWidth) + } + if cfg.TmuxEditPopupHeight != "85%" { + t.Errorf("PopupHeight = %q, want 85%%", cfg.TmuxEditPopupHeight) + } + if cfg.TmuxEditDefaultAgent != "claude" { + t.Errorf("DefaultAgent = %q, want claude", cfg.TmuxEditDefaultAgent) + } + if len(cfg.TmuxEditAgents) != 2 { + t.Fatalf("got %d agents, want 2", len(cfg.TmuxEditAgents)) + } + a := cfg.TmuxEditAgents[0] + if a.Name != "claude" || a.DisplayName != "Claude Code" { + t.Errorf("agent[0] = %q/%q, want claude/Claude Code", a.Name, a.DisplayName) + } + if a.ClearFirst == nil || !*a.ClearFirst { + t.Error("expected ClearFirst = true for claude agent") + } + b := cfg.TmuxEditAgents[1] + if b.Name != "cursor" { + t.Errorf("agent[1].Name = %q, want cursor", b.Name) + } + if len(b.StripPatterns) != 2 { + t.Errorf("agent[1].StripPatterns = %v, want 2 entries", b.StripPatterns) + } +} + +func TestTmuxEditConfig_Merge(t *testing.T) { + clearHexaiEnv(t) + a := newDefaultConfig() + b := App{ + TmuxEditPopupWidth: "70%", + TmuxEditDefaultAgent: "amp", + TmuxEditAgents: []TmuxEditAgentCfg{ + {Name: "amp", DisplayName: "Amp"}, + }, + } + a.mergeWith(&b) + if a.TmuxEditPopupWidth != "70%" { + t.Errorf("PopupWidth = %q, want 70%%", a.TmuxEditPopupWidth) + } + if a.TmuxEditDefaultAgent != "amp" { + t.Errorf("DefaultAgent = %q, want amp", a.TmuxEditDefaultAgent) + } + if len(a.TmuxEditAgents) != 1 || a.TmuxEditAgents[0].Name != "amp" { + t.Errorf("Agents = %v, want single amp", a.TmuxEditAgents) + } +} + +func TestTmuxEditConfig_SkipsEmptyName(t *testing.T) { + clearHexaiEnv(t) + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.toml") + writeFile(t, cfgPath, ` +[tmux_edit] +[[tmux_edit.agents]] +name = "" +display_name = "Empty" +`) + cfg := LoadWithOptions(newLogger(), LoadOptions{ConfigPath: cfgPath}) + if len(cfg.TmuxEditAgents) != 0 { + t.Errorf("got %d agents, want 0 (empty name should be skipped)", len(cfg.TmuxEditAgents)) + } +} diff --git a/internal/tmuxedit/agent.go b/internal/tmuxedit/agent.go new file mode 100644 index 0000000..2e07824 --- /dev/null +++ b/internal/tmuxedit/agent.go @@ -0,0 +1,212 @@ +// Package tmuxedit implements a tmux popup editor for composing AI agent prompts. +// agent.go defines agent detection, prompt extraction, and noise stripping. +package tmuxedit + +import ( + "regexp" + "strings" + + "codeberg.org/snonux/hexai/internal/appconfig" +) + +// AgentConfig describes how to detect and interact with a specific AI agent +// running in a tmux pane. All behavior is driven by regex patterns so new +// agents can be added via config without code changes. +type AgentConfig struct { + Name string // short key: "claude", "cursor", "amp" + DisplayName string // human-readable: "Claude Code" + DetectPattern string // regex matched against pane content for auto-detection + PromptPattern string // regex with capture group (1) to extract current prompt text + StripPatterns []string // substrings removed from extracted text + ClearFirst bool // whether to clear existing input before sending + ClearKeys string // tmux key sequence to clear input (e.g. "C-u") + NewlineKeys string // tmux key to insert a newline (e.g. "S-Enter") + SubmitKeys string // tmux key to submit the prompt (e.g. "Enter") +} + +// builtinAgents returns the default set of agent configurations. These are +// overridden/extended by user config in [tmux_edit.agents]. +func builtinAgents() []AgentConfig { + return []AgentConfig{ + { + Name: "claude", + DisplayName: "Claude Code", + DetectPattern: `(?i)(claude|anthropic)`, + PromptPattern: `(?m)>\s*(.+)$`, + ClearFirst: true, + ClearKeys: "C-u", + NewlineKeys: "S-Enter", + SubmitKeys: "Enter", + }, + { + Name: "cursor", + DisplayName: "Cursor", + DetectPattern: `(?i)cursor`, + PromptPattern: `(?m)│\s*(.+)$`, + StripPatterns: []string{"INSERT", "Add a follow-up"}, + ClearFirst: true, + ClearKeys: "C-u", + NewlineKeys: "S-Enter", + SubmitKeys: "Enter", + }, + { + Name: "amp", + DisplayName: "Amp", + DetectPattern: `(?i)(amp|sourcegraph)`, + PromptPattern: `(?m)>\s*(.+)$`, + ClearFirst: true, + ClearKeys: "C-u", + NewlineKeys: "S-Enter", + SubmitKeys: "Enter", + }, + { + Name: "aider", + DisplayName: "Aider", + DetectPattern: `(?i)aider`, + PromptPattern: `(?m)>\s*(.+)$`, + ClearFirst: true, + ClearKeys: "C-u", + NewlineKeys: "", + SubmitKeys: "Enter", + }, + } +} + +// genericAgent returns a fallback agent with no detection or prompt extraction. +// The user gets a blank editor and text is sent verbatim. +func genericAgent() AgentConfig { + return AgentConfig{ + Name: "generic", + DisplayName: "Generic", + NewlineKeys: "", + SubmitKeys: "Enter", + } +} + +// resolveAgents merges built-in agent defaults with user-provided overrides +// from config. Agents are matched by name (case-insensitive); user config +// wins field-by-field over builtins. +func resolveAgents(cfgAgents []appconfig.TmuxEditAgentCfg) []AgentConfig { + agents := builtinAgents() + for _, ca := range cfgAgents { + merged := false + for i, a := range agents { + if !strings.EqualFold(a.Name, ca.Name) { + continue + } + agents[i] = mergeAgentConfig(a, ca) + merged = true + break + } + if !merged { + agents = append(agents, agentFromConfig(ca)) + } + } + return agents +} + +// mergeAgentConfig overrides fields in base with non-zero values from cfg. +func mergeAgentConfig(base AgentConfig, cfg appconfig.TmuxEditAgentCfg) AgentConfig { + if s := strings.TrimSpace(cfg.DisplayName); s != "" { + base.DisplayName = s + } + if s := strings.TrimSpace(cfg.DetectPattern); s != "" { + base.DetectPattern = s + } + if s := strings.TrimSpace(cfg.PromptPattern); s != "" { + base.PromptPattern = s + } + if len(cfg.StripPatterns) > 0 { + base.StripPatterns = cfg.StripPatterns + } + if cfg.ClearFirst != nil { + base.ClearFirst = *cfg.ClearFirst + } + if s := strings.TrimSpace(cfg.ClearKeys); s != "" { + base.ClearKeys = s + } + if s := strings.TrimSpace(cfg.NewlineKeys); s != "" { + base.NewlineKeys = s + } + if s := strings.TrimSpace(cfg.SubmitKeys); s != "" { + base.SubmitKeys = s + } + return base +} + +// agentFromConfig creates a new AgentConfig from a user config entry. +func agentFromConfig(cfg appconfig.TmuxEditAgentCfg) AgentConfig { + a := AgentConfig{ + Name: strings.TrimSpace(cfg.Name), + DisplayName: strings.TrimSpace(cfg.DisplayName), + DetectPattern: strings.TrimSpace(cfg.DetectPattern), + PromptPattern: strings.TrimSpace(cfg.PromptPattern), + StripPatterns: cfg.StripPatterns, + ClearKeys: strings.TrimSpace(cfg.ClearKeys), + NewlineKeys: strings.TrimSpace(cfg.NewlineKeys), + SubmitKeys: strings.TrimSpace(cfg.SubmitKeys), + } + if cfg.ClearFirst != nil { + a.ClearFirst = *cfg.ClearFirst + } + if a.DisplayName == "" { + a.DisplayName = a.Name + } + return a +} + +// detectAgent tries each agent's DetectPattern against pane content. +// First match wins. Returns genericAgent() if no agent matches. +func detectAgent(paneContent string, agents []AgentConfig) AgentConfig { + for _, a := range agents { + if a.DetectPattern == "" { + continue + } + re, err := regexp.Compile(a.DetectPattern) + if err != nil { + continue + } + if re.MatchString(paneContent) { + return a + } + } + return genericAgent() +} + +// findAgentByName returns the agent with the given name (case-insensitive), +// falling back to genericAgent() if not found. +func findAgentByName(name string, agents []AgentConfig) AgentConfig { + for _, a := range agents { + if strings.EqualFold(a.Name, name) { + return a + } + } + return genericAgent() +} + +// extractPrompt uses the agent's PromptPattern to extract the current prompt +// text from pane content. Returns empty string if no pattern or no match. +func extractPrompt(paneContent string, agent AgentConfig) string { + if agent.PromptPattern == "" { + return "" + } + re, err := regexp.Compile(agent.PromptPattern) + if err != nil { + return "" + } + m := re.FindStringSubmatch(paneContent) + if len(m) < 2 { + return "" + } + text := m[1] + return stripNoise(text, agent.StripPatterns) +} + +// stripNoise removes each of the agent's StripPatterns from text and trims +// whitespace. +func stripNoise(text string, patterns []string) string { + for _, p := range patterns { + text = strings.ReplaceAll(text, p, "") + } + return strings.TrimSpace(text) +} diff --git a/internal/tmuxedit/agent_test.go b/internal/tmuxedit/agent_test.go new file mode 100644 index 0000000..a6bc20d --- /dev/null +++ b/internal/tmuxedit/agent_test.go @@ -0,0 +1,260 @@ +package tmuxedit + +import ( + "testing" + + "codeberg.org/snonux/hexai/internal/appconfig" +) + +func boolP(b bool) *bool { return &b } + +func TestDetectAgent(t *testing.T) { + agents := builtinAgents() + tests := []struct { + name string + content string + want string + }{ + {"claude from banner", "Welcome to Claude Code v1.2\n> ", "claude"}, + {"claude from anthropic", "Powered by Anthropic\n> ", "claude"}, + {"cursor from prompt", "cursor agent ready\n│ type here", "cursor"}, + {"amp from banner", "Amp by Sourcegraph\n> ", "amp"}, + {"aider from banner", "aider v0.50\n> /help", "aider"}, + {"no match", "some random terminal output\n$ ", "generic"}, + {"empty content", "", "generic"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := detectAgent(tt.content, agents) + if got.Name != tt.want { + t.Errorf("detectAgent() = %q, want %q", got.Name, tt.want) + } + }) + } +} + +func TestFindAgentByName(t *testing.T) { + agents := builtinAgents() + tests := []struct { + name string + want string + }{ + {"claude", "claude"}, + {"Claude", "claude"}, + {"CURSOR", "cursor"}, + {"amp", "amp"}, + {"nonexistent", "generic"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := findAgentByName(tt.name, agents) + if got.Name != tt.want { + t.Errorf("findAgentByName(%q) = %q, want %q", tt.name, got.Name, tt.want) + } + }) + } +} + +func TestExtractPrompt(t *testing.T) { + tests := []struct { + name string + content string + agent AgentConfig + want string + }{ + { + name: "claude prompt", + content: "Claude Code v1\n> hello world", + agent: builtinAgents()[0], // claude + want: "hello world", + }, + { + name: "cursor prompt with strip", + content: "Cursor Agent\n│ fix the bug INSERT", + agent: builtinAgents()[1], // cursor + want: "fix the bug", + }, + { + name: "cursor prompt strips follow-up", + content: "Cursor\n│ Add a follow-up", + agent: builtinAgents()[1], // cursor + want: "", + }, + { + name: "no pattern", + content: "some text", + agent: genericAgent(), + want: "", + }, + { + name: "no match", + content: "no prompt here", + agent: builtinAgents()[0], // claude + want: "", + }, + { + name: "invalid regex", + content: "> test", + agent: AgentConfig{PromptPattern: "[invalid"}, + want: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := extractPrompt(tt.content, tt.agent) + if got != tt.want { + t.Errorf("extractPrompt() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestStripNoise(t *testing.T) { + tests := []struct { + name string + text string + patterns []string + want string + }{ + {"no patterns", "hello world", nil, "hello world"}, + {"strip INSERT", "fix the bug INSERT", []string{"INSERT"}, "fix the bug"}, + {"strip multiple", "INSERT fix the bug Add a follow-up", []string{"INSERT", "Add a follow-up"}, "fix the bug"}, + {"strip to empty", "INSERT", []string{"INSERT"}, ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := stripNoise(tt.text, tt.patterns) + if got != tt.want { + t.Errorf("stripNoise() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestResolveAgents_MergeOverride(t *testing.T) { + cfgAgents := []appconfig.TmuxEditAgentCfg{ + { + Name: "claude", + DisplayName: "My Claude", + ClearFirst: boolP(false), + }, + } + agents := resolveAgents(cfgAgents) + var claude AgentConfig + for _, a := range agents { + if a.Name == "claude" { + claude = a + break + } + } + if claude.DisplayName != "My Claude" { + t.Errorf("DisplayName = %q, want My Claude", claude.DisplayName) + } + if claude.ClearFirst { + t.Error("ClearFirst should be false after override") + } + // DetectPattern should be preserved from builtin + if claude.DetectPattern == "" { + t.Error("DetectPattern should be preserved from builtin") + } +} + +func TestResolveAgents_MergeAllFields(t *testing.T) { + cfgAgents := []appconfig.TmuxEditAgentCfg{ + { + Name: "claude", + DisplayName: "Custom Claude", + DetectPattern: "(?i)custom-claude", + PromptPattern: `>\s+(.*)$`, + StripPatterns: []string{"NOISE"}, + ClearFirst: boolP(true), + ClearKeys: "C-k", + NewlineKeys: "C-Enter", + SubmitKeys: "C-m", + }, + } + agents := resolveAgents(cfgAgents) + var a AgentConfig + for _, ag := range agents { + if ag.Name == "claude" { + a = ag + break + } + } + if a.DetectPattern != "(?i)custom-claude" { + t.Errorf("DetectPattern = %q", a.DetectPattern) + } + if a.PromptPattern != `>\s+(.*)$` { + t.Errorf("PromptPattern = %q", a.PromptPattern) + } + if len(a.StripPatterns) != 1 || a.StripPatterns[0] != "NOISE" { + t.Errorf("StripPatterns = %v", a.StripPatterns) + } + if a.ClearKeys != "C-k" { + t.Errorf("ClearKeys = %q", a.ClearKeys) + } + if a.NewlineKeys != "C-Enter" { + t.Errorf("NewlineKeys = %q", a.NewlineKeys) + } + if a.SubmitKeys != "C-m" { + t.Errorf("SubmitKeys = %q", a.SubmitKeys) + } +} + +func TestResolveAgents_AddNew(t *testing.T) { + cfgAgents := []appconfig.TmuxEditAgentCfg{ + { + Name: "custom", + DisplayName: "Custom Agent", + DetectPattern: "(?i)custom", + PromptPattern: `>\s*(.+)$`, + ClearFirst: boolP(true), + }, + } + agents := resolveAgents(cfgAgents) + found := false + for _, a := range agents { + if a.Name == "custom" { + found = true + if a.DisplayName != "Custom Agent" { + t.Errorf("DisplayName = %q, want Custom Agent", a.DisplayName) + } + if !a.ClearFirst { + t.Error("ClearFirst should be true") + } + } + } + if !found { + t.Error("custom agent not found in resolved agents") + } +} + +func TestAgentFromConfig_DefaultDisplayName(t *testing.T) { + cfg := appconfig.TmuxEditAgentCfg{ + Name: "test", + } + a := agentFromConfig(cfg) + if a.DisplayName != "test" { + t.Errorf("DisplayName = %q, want test (defaulted from Name)", a.DisplayName) + } +} + +func TestDetectAgent_InvalidRegex(t *testing.T) { + agents := []AgentConfig{ + {Name: "bad", DetectPattern: "[invalid"}, + } + got := detectAgent("anything", agents) + if got.Name != "generic" { + t.Errorf("expected generic fallback for invalid regex, got %q", got.Name) + } +} + +func TestGenericAgent(t *testing.T) { + g := genericAgent() + if g.Name != "generic" { + t.Errorf("Name = %q, want generic", g.Name) + } + if g.SubmitKeys != "Enter" { + t.Errorf("SubmitKeys = %q, want Enter", g.SubmitKeys) + } +} diff --git a/internal/tmuxedit/capture.go b/internal/tmuxedit/capture.go new file mode 100644 index 0000000..2af5698 --- /dev/null +++ b/internal/tmuxedit/capture.go @@ -0,0 +1,17 @@ +package tmuxedit + +import ( + "fmt" + "strings" +) + +// capturePane retrieves the visible content of a tmux pane via +// `tmux capture-pane -p -t <paneID>`. The -p flag prints to stdout +// instead of to a paste buffer. +var capturePane = func(paneID string) (string, error) { + out, err := runCommand("tmux", "capture-pane", "-p", "-t", paneID) + if err != nil { + return "", fmt.Errorf("capture-pane failed for %s: %w", paneID, err) + } + return strings.TrimRight(string(out), "\n"), nil +} diff --git a/internal/tmuxedit/capture_test.go b/internal/tmuxedit/capture_test.go new file mode 100644 index 0000000..40d0e98 --- /dev/null +++ b/internal/tmuxedit/capture_test.go @@ -0,0 +1,51 @@ +package tmuxedit + +import ( + "fmt" + "testing" +) + +func TestCapturePane_Success(t *testing.T) { + old := runCommand + defer func() { runCommand = old }() + runCommand = func(name string, args ...string) ([]byte, error) { + if name == "tmux" && len(args) >= 3 && args[0] == "capture-pane" { + return []byte("Claude Code v1.0\n> hello world\n"), nil + } + return nil, fmt.Errorf("unexpected: %s %v", name, args) + } + got, err := capturePane("%5") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "Claude Code v1.0\n> hello world" { + t.Errorf("got %q, want trimmed content", got) + } +} + +func TestCapturePane_Error(t *testing.T) { + old := runCommand + defer func() { runCommand = old }() + runCommand = func(string, ...string) ([]byte, error) { + return nil, fmt.Errorf("pane not found") + } + _, err := capturePane("%999") + if err == nil { + t.Fatal("expected error for failed capture") + } +} + +func TestCapturePane_EmptyContent(t *testing.T) { + old := runCommand + defer func() { runCommand = old }() + runCommand = func(string, ...string) ([]byte, error) { + return []byte("\n\n"), nil + } + got, err := capturePane("%1") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "" { + t.Errorf("got %q, want empty string", got) + } +} diff --git a/internal/tmuxedit/pane.go b/internal/tmuxedit/pane.go new file mode 100644 index 0000000..aae2d69 --- /dev/null +++ b/internal/tmuxedit/pane.go @@ -0,0 +1,42 @@ +package tmuxedit + +import ( + "fmt" + "os" + "os/exec" + "strings" +) + +// runCommand is the seam for exec.Command().Output(). Override in tests. +var runCommand = func(name string, args ...string) ([]byte, error) { + return exec.Command(name, args...).Output() +} + +// resolveTargetPane determines which tmux pane to target using a fallback +// chain: explicit flag > HEXAI_TMUX_PANE env var > tmux query for active pane. +// Returns the pane ID (e.g. "%5") or an error. +func resolveTargetPane(flagPane string) (string, error) { + // 1. Explicit --pane flag + if p := strings.TrimSpace(flagPane); p != "" { + return p, nil + } + // 2. Environment variable + if p := strings.TrimSpace(os.Getenv("HEXAI_TMUX_PANE")); p != "" { + return p, nil + } + // 3. Query tmux for the active pane in the current window + return queryActivePane() +} + +// queryActivePane asks tmux for the active pane ID using display-message. +func queryActivePane() (string, error) { + out, err := runCommand("tmux", "display-message", "-p", "#{pane_id}") + if err != nil { + return "", fmt.Errorf("cannot determine tmux pane: %w", err) + } + pane := strings.TrimSpace(string(out)) + if pane == "" { + return "", fmt.Errorf("tmux returned empty pane ID") + } + return pane, nil +} diff --git a/internal/tmuxedit/pane_test.go b/internal/tmuxedit/pane_test.go new file mode 100644 index 0000000..5b6f1b6 --- /dev/null +++ b/internal/tmuxedit/pane_test.go @@ -0,0 +1,83 @@ +package tmuxedit + +import ( + "fmt" + "testing" +) + +func TestResolveTargetPane_FlagWins(t *testing.T) { + old := runCommand + defer func() { runCommand = old }() + runCommand = func(string, ...string) ([]byte, error) { + return []byte("%99"), nil + } + t.Setenv("HEXAI_TMUX_PANE", "%10") + got, err := resolveTargetPane("%5") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "%5" { + t.Errorf("got %q, want %%5 (flag should win)", got) + } +} + +func TestResolveTargetPane_EnvFallback(t *testing.T) { + old := runCommand + defer func() { runCommand = old }() + runCommand = func(string, ...string) ([]byte, error) { + return []byte("%99"), nil + } + t.Setenv("HEXAI_TMUX_PANE", "%10") + got, err := resolveTargetPane("") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "%10" { + t.Errorf("got %q, want %%10 (env fallback)", got) + } +} + +func TestResolveTargetPane_TmuxQuery(t *testing.T) { + old := runCommand + defer func() { runCommand = old }() + runCommand = func(name string, args ...string) ([]byte, error) { + if name == "tmux" && len(args) > 0 && args[0] == "display-message" { + return []byte("%42\n"), nil + } + return nil, fmt.Errorf("unexpected command: %s", name) + } + t.Setenv("HEXAI_TMUX_PANE", "") + got, err := resolveTargetPane("") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "%42" { + t.Errorf("got %q, want %%42 (tmux query)", got) + } +} + +func TestResolveTargetPane_TmuxError(t *testing.T) { + old := runCommand + defer func() { runCommand = old }() + runCommand = func(string, ...string) ([]byte, error) { + return nil, fmt.Errorf("tmux not available") + } + t.Setenv("HEXAI_TMUX_PANE", "") + _, err := resolveTargetPane("") + if err == nil { + t.Fatal("expected error when tmux fails") + } +} + +func TestResolveTargetPane_TmuxEmptyOutput(t *testing.T) { + old := runCommand + defer func() { runCommand = old }() + runCommand = func(string, ...string) ([]byte, error) { + return []byte(" \n"), nil + } + t.Setenv("HEXAI_TMUX_PANE", "") + _, err := resolveTargetPane("") + if err == nil { + t.Fatal("expected error for empty tmux output") + } +} diff --git a/internal/tmuxedit/run.go b/internal/tmuxedit/run.go new file mode 100644 index 0000000..173e936 --- /dev/null +++ b/internal/tmuxedit/run.go @@ -0,0 +1,148 @@ +package tmuxedit + +import ( + "fmt" + "log" + "os" + "strings" + + "codeberg.org/snonux/hexai/internal/appconfig" + "codeberg.org/snonux/hexai/internal/editor" + "codeberg.org/snonux/hexai/internal/tmux" +) + +// Options holds the parsed command-line flags for hexai-tmux-edit. +type Options struct { + ConfigPath string // --config flag + Agent string // --agent flag (explicit agent name, or auto-detect) + Pane string // --pane flag (target pane ID) +} + +// openEditorPopup is the seam for opening an editor in a tmux popup. +// It creates a temp file, opens it in a tmux popup with the user's editor, +// waits for completion, and returns the edited content. Override in tests. +var openEditorPopup = func(initial, popupW, popupH string) (string, error) { + ed, err := editor.Resolve() + if err != nil { + return "", err + } + // Create a temp file with the initial content + f, err := os.CreateTemp("", "hexai-tmux-edit-*.md") + if err != nil { + return "", fmt.Errorf("create temp file: %w", err) + } + path := f.Name() + defer func() { _ = os.Remove(path) }() + + if initial != "" { + if _, err := f.WriteString(initial); err != nil { + _ = f.Close() + return "", fmt.Errorf("write initial content: %w", err) + } + } + if err := f.Close(); err != nil { + return "", fmt.Errorf("close temp file: %w", err) + } + + // Build the tmux display-popup command to launch the editor + if err := launchPopup(ed, path, popupW, popupH); err != nil { + return "", fmt.Errorf("popup editor: %w", err) + } + + b, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("read edited file: %w", err) + } + return strings.TrimSpace(string(b)), nil +} + +// launchPopup runs `tmux display-popup` with the editor command. +// The -E flag makes the popup close when the editor exits. +func launchPopup(ed, path, width, height string) error { + args := []string{"display-popup", "-E"} + if width != "" { + args = append(args, "-w", width) + } + if height != "" { + args = append(args, "-h", height) + } + args = append(args, ed+" "+shellQuote(path)) + _, err := runCommand("tmux", args...) + return err +} + +// shellQuote wraps a path in single quotes for safe shell use. +func shellQuote(s string) string { + return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" +} + +// Run is the main orchestrator for hexai-tmux-edit. It: +// 1. Checks tmux availability +// 2. Resolves the target pane +// 3. Captures pane content +// 4. Detects or selects the agent +// 5. Extracts the current prompt +// 6. Opens the editor in a popup +// 7. Deduplicates and sends edited text back +func Run(opts Options) error { + if !tmux.Available() { + return fmt.Errorf("tmux is not available (not in a tmux session)") + } + cfg := loadConfig(opts.ConfigPath) + return runWithConfig(opts, cfg) +} + +// loadConfig loads the application config, extracting tmux_edit settings. +func loadConfig(configPath string) appconfig.App { + logger := log.New(os.Stderr, "[hexai-tmux-edit] ", log.LstdFlags) + lopts := appconfig.LoadOptions{ConfigPath: configPath} + return appconfig.LoadWithOptions(logger, lopts) +} + +// runWithConfig executes the edit workflow using the provided config. +func runWithConfig(opts Options, cfg appconfig.App) error { + paneID, err := resolveTargetPane(opts.Pane) + if err != nil { + return err + } + content, err := capturePane(paneID) + if err != nil { + return err + } + agents := resolveAgents(cfg.TmuxEditAgents) + agent := pickAgent(opts.Agent, content, agents) + + // Extract current prompt text from pane content + original := extractPrompt(content, agent) + + // Determine popup dimensions from config (with defaults) + popupW := cfg.TmuxEditPopupWidth + if popupW == "" { + popupW = "80%" + } + popupH := cfg.TmuxEditPopupHeight + if popupH == "" { + popupH = "80%" + } + + edited, err := openEditorPopup(original, popupW, popupH) + if err != nil { + return err + } + + // Deduplicate: remove the pre-filled text if the user didn't change it + text := deduplicateText(original, edited) + if text == "" { + return nil // nothing to send + } + + return sendTextToPane(paneID, text, agent) +} + +// pickAgent selects an agent by explicit name or auto-detection. +func pickAgent(name, content string, agents []AgentConfig) AgentConfig { + if name != "" { + return findAgentByName(name, agents) + } + return detectAgent(content, agents) +} diff --git a/internal/tmuxedit/run_test.go b/internal/tmuxedit/run_test.go new file mode 100644 index 0000000..88c94a2 --- /dev/null +++ b/internal/tmuxedit/run_test.go @@ -0,0 +1,320 @@ +package tmuxedit + +import ( + "fmt" + "strings" + "testing" + + "codeberg.org/snonux/hexai/internal/appconfig" +) + +func TestRunWithConfig_HappyPath(t *testing.T) { + // Save and restore all seams + oldCapture := capturePane + oldSendKeys := sendKeys + oldEditorPopup := openEditorPopup + oldRunCmd := runCommand + defer func() { + capturePane = oldCapture + sendKeys = oldSendKeys + openEditorPopup = oldEditorPopup + runCommand = oldRunCmd + }() + + // Mock: pane resolution via tmux query + runCommand = func(name string, args ...string) ([]byte, error) { + if name == "tmux" && args[0] == "display-message" { + return []byte("%5"), nil + } + return nil, nil + } + + // Mock: capture pane content with Claude agent detected + capturePane = func(paneID string) (string, error) { + return "Claude Code v1.0\n> fix the bug", nil + } + + // Mock: editor popup returns modified text + openEditorPopup = func(initial, w, h string) (string, error) { + if initial != "fix the bug" { + t.Errorf("initial = %q, want 'fix the bug'", initial) + } + if w != "80%" || h != "80%" { + t.Errorf("dimensions = %sx%s, want 80%%x80%%", w, h) + } + return "fix the bug\nalso refactor the module", nil + } + + // Track send-keys calls + var sent []string + sendKeys = func(paneID string, keys ...string) error { + sent = append(sent, strings.Join(keys, ",")) + return nil + } + + cfg := appconfig.App{} + err := runWithConfig(Options{}, cfg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Should have sent: clear (C-u), "also refactor the module" + // (since "fix the bug" was pre-filled and kept, only the appended text is sent) + if len(sent) < 1 { + t.Fatalf("no send calls recorded") + } + + // Check that the deduplication worked - only new text should be sent + allSent := strings.Join(sent, "|") + if !strings.Contains(allSent, "also refactor the module") { + t.Errorf("expected 'also refactor the module' in sent calls: %v", sent) + } +} + +func TestRunWithConfig_ExplicitAgent(t *testing.T) { + oldCapture := capturePane + oldSendKeys := sendKeys + oldEditorPopup := openEditorPopup + oldRunCmd := runCommand + defer func() { + capturePane = oldCapture + sendKeys = oldSendKeys + openEditorPopup = oldEditorPopup + runCommand = oldRunCmd + }() + + runCommand = func(name string, args ...string) ([]byte, error) { + return []byte("%1"), nil + } + capturePane = func(string) (string, error) { + return "some generic content\n> hello", nil + } + openEditorPopup = func(initial, w, h string) (string, error) { + // With cursor agent, prompt extraction uses │ pattern, so initial should be empty + if initial != "" { + t.Errorf("initial = %q, want empty (cursor agent doesn't match > pattern)", initial) + } + return "new prompt", nil + } + sendKeys = func(string, ...string) error { return nil } + + cfg := appconfig.App{} + err := runWithConfig(Options{Agent: "cursor"}, cfg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestRunWithConfig_EditorEmpty(t *testing.T) { + oldCapture := capturePane + oldSendKeys := sendKeys + oldEditorPopup := openEditorPopup + oldRunCmd := runCommand + defer func() { + capturePane = oldCapture + sendKeys = oldSendKeys + openEditorPopup = oldEditorPopup + runCommand = oldRunCmd + }() + + runCommand = func(name string, args ...string) ([]byte, error) { + return []byte("%1"), nil + } + capturePane = func(string) (string, error) { + return "Claude\n> ", nil + } + openEditorPopup = func(string, string, string) (string, error) { + return "", nil // user saved empty file + } + sendKeys = func(string, ...string) error { + t.Fatal("sendKeys should not be called when editor returns empty") + return nil + } + + cfg := appconfig.App{} + err := runWithConfig(Options{}, cfg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestRunWithConfig_CustomDimensions(t *testing.T) { + oldCapture := capturePane + oldSendKeys := sendKeys + oldEditorPopup := openEditorPopup + oldRunCmd := runCommand + defer func() { + capturePane = oldCapture + sendKeys = oldSendKeys + openEditorPopup = oldEditorPopup + runCommand = oldRunCmd + }() + + runCommand = func(name string, args ...string) ([]byte, error) { + return []byte("%1"), nil + } + capturePane = func(string) (string, error) { return "", nil } + openEditorPopup = func(initial, w, h string) (string, error) { + if w != "90%" || h != "85%" { + t.Errorf("dimensions = %sx%s, want 90%%x85%%", w, h) + } + return "test", nil + } + sendKeys = func(string, ...string) error { return nil } + + cfg := appconfig.App{ + TmuxEditPopupWidth: "90%", + TmuxEditPopupHeight: "85%", + } + err := runWithConfig(Options{}, cfg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestPickAgent_ExplicitName(t *testing.T) { + agents := builtinAgents() + got := pickAgent("cursor", "Claude Code detected", agents) + if got.Name != "cursor" { + t.Errorf("pickAgent(cursor) = %q, want cursor (explicit name should win)", got.Name) + } +} + +func TestPickAgent_AutoDetect(t *testing.T) { + agents := builtinAgents() + got := pickAgent("", "Amp by Sourcegraph", agents) + if got.Name != "amp" { + t.Errorf("pickAgent('', amp content) = %q, want amp", got.Name) + } +} + +func TestShellQuote(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"simple", "'simple'"}, + {"with space", "'with space'"}, + {"it's", "'it'\\''s'"}, + } + for _, tt := range tests { + got := shellQuote(tt.input) + if got != tt.want { + t.Errorf("shellQuote(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + +func TestLaunchPopup_CommandArgs(t *testing.T) { + oldRunCmd := runCommand + defer func() { runCommand = oldRunCmd }() + + var captured []string + runCommand = func(name string, args ...string) ([]byte, error) { + captured = append(captured, name) + captured = append(captured, args...) + return nil, nil + } + + err := launchPopup("vim", "/tmp/test.md", "90%", "85%") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // Verify command structure: tmux display-popup -E -w 90% -h 85% "vim '/tmp/test.md'" + if captured[0] != "tmux" { + t.Errorf("command = %q, want tmux", captured[0]) + } + if captured[1] != "display-popup" { + t.Errorf("args[0] = %q, want display-popup", captured[1]) + } + if captured[2] != "-E" { + t.Errorf("args[1] = %q, want -E", captured[2]) + } +} + +func TestLaunchPopup_NoDimensions(t *testing.T) { + oldRunCmd := runCommand + defer func() { runCommand = oldRunCmd }() + + var captured []string + runCommand = func(name string, args ...string) ([]byte, error) { + captured = args + return nil, nil + } + + err := launchPopup("nano", "/tmp/f.md", "", "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // Should not include -w or -h flags + for _, a := range captured { + if a == "-w" || a == "-h" { + t.Errorf("unexpected dimension flag in args: %v", captured) + } + } +} + +func TestRunWithConfig_CaptureError(t *testing.T) { + oldCapture := capturePane + oldRunCmd := runCommand + defer func() { + capturePane = oldCapture + runCommand = oldRunCmd + }() + + runCommand = func(name string, args ...string) ([]byte, error) { + return []byte("%1"), nil + } + capturePane = func(string) (string, error) { + return "", fmt.Errorf("capture failed") + } + + cfg := appconfig.App{} + err := runWithConfig(Options{Pane: "%1"}, cfg) + if err == nil || !strings.Contains(err.Error(), "capture failed") { + t.Errorf("expected capture error, got: %v", err) + } +} + +func TestRunWithConfig_EditorError(t *testing.T) { + oldCapture := capturePane + oldEditorPopup := openEditorPopup + oldRunCmd := runCommand + defer func() { + capturePane = oldCapture + openEditorPopup = oldEditorPopup + runCommand = oldRunCmd + }() + + runCommand = func(name string, args ...string) ([]byte, error) { + return []byte("%1"), nil + } + capturePane = func(string) (string, error) { + return "some content", nil + } + openEditorPopup = func(string, string, string) (string, error) { + return "", fmt.Errorf("editor crashed") + } + + cfg := appconfig.App{} + err := runWithConfig(Options{Pane: "%1"}, cfg) + if err == nil || !strings.Contains(err.Error(), "editor crashed") { + t.Errorf("expected editor error, got: %v", err) + } +} + +func TestRunWithConfig_PaneResolveError(t *testing.T) { + oldRunCmd := runCommand + defer func() { runCommand = oldRunCmd }() + + runCommand = func(string, ...string) ([]byte, error) { + return nil, fmt.Errorf("tmux unavailable") + } + t.Setenv("HEXAI_TMUX_PANE", "") + + cfg := appconfig.App{} + err := runWithConfig(Options{}, cfg) + if err == nil { + t.Fatal("expected error for pane resolution failure") + } +} diff --git a/internal/tmuxedit/send.go b/internal/tmuxedit/send.go new file mode 100644 index 0000000..b85f7d3 --- /dev/null +++ b/internal/tmuxedit/send.go @@ -0,0 +1,74 @@ +package tmuxedit + +import ( + "fmt" + "strings" +) + +// sendKeys is the seam for `tmux send-keys`. Override in tests. +var sendKeys = func(paneID string, keys ...string) error { + args := append([]string{"send-keys", "-t", paneID}, keys...) + _, err := runCommand("tmux", args...) + if err != nil { + return fmt.Errorf("send-keys failed: %w", err) + } + return nil +} + +// deduplicateText removes the original (pre-filled) text from the edited +// result. If the user kept the original and appended, only the new text is +// returned. If the user rewrote everything, the full new text is returned. +func deduplicateText(original, edited string) string { + original = strings.TrimSpace(original) + edited = strings.TrimSpace(edited) + if edited == "" { + return "" + } + if original == "" { + return edited + } + // If the edited text starts with the original, return only the appended part + if strings.HasPrefix(edited, original) { + appended := strings.TrimSpace(edited[len(original):]) + if appended != "" { + return appended + } + // User didn't change anything; return empty to signal no-op + return "" + } + // User rewrote the prompt; return the full new text + return edited +} + +// sendTextToPane sends the given text to the target pane. It optionally +// clears existing input first (using the agent's ClearKeys), then sends +// text line-by-line using the agent's NewlineKeys between lines. +func sendTextToPane(paneID, text string, agent AgentConfig) error { + if strings.TrimSpace(text) == "" { + return nil + } + // Clear existing input if configured + if agent.ClearFirst && agent.ClearKeys != "" { + if err := sendKeys(paneID, agent.ClearKeys); err != nil { + return fmt.Errorf("clear failed: %w", err) + } + } + // Send text line by line, inserting newline keys between lines + lines := strings.Split(text, "\n") + for i, line := range lines { + if err := sendKeys(paneID, line); err != nil { + return fmt.Errorf("send line %d failed: %w", i, err) + } + // Insert inter-line newline (except after the last line) + if i < len(lines)-1 { + nlKey := agent.NewlineKeys + if nlKey == "" { + nlKey = "Enter" // fallback for agents without shift-enter + } + if err := sendKeys(paneID, nlKey); err != nil { + return fmt.Errorf("newline after line %d failed: %w", i, err) + } + } + } + return nil +} diff --git a/internal/tmuxedit/send_test.go b/internal/tmuxedit/send_test.go new file mode 100644 index 0000000..eeced35 --- /dev/null +++ b/internal/tmuxedit/send_test.go @@ -0,0 +1,170 @@ +package tmuxedit + +import ( + "fmt" + "strings" + "testing" +) + +func TestDeduplicateText(t *testing.T) { + tests := []struct { + name string + original string + edited string + want string + }{ + {"empty both", "", "", ""}, + {"empty original", "", "new text", "new text"}, + {"empty edited", "original", "", ""}, + {"unchanged", "hello world", "hello world", ""}, + {"appended", "hello", "hello world", "world"}, + {"rewritten", "hello world", "goodbye world", "goodbye world"}, + {"whitespace handling", " hello ", " hello world ", "world"}, + {"prefix match with newlines", "line1\nline2", "line1\nline2\nline3", "line3"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := deduplicateText(tt.original, tt.edited) + if got != tt.want { + t.Errorf("deduplicateText(%q, %q) = %q, want %q", + tt.original, tt.edited, got, tt.want) + } + }) + } +} + +func TestSendTextToPane_SingleLine(t *testing.T) { + var calls []string + oldSend := sendKeys + defer func() { sendKeys = oldSend }() + sendKeys = func(paneID string, keys ...string) error { + calls = append(calls, fmt.Sprintf("send:%s:%s", paneID, strings.Join(keys, ","))) + return nil + } + agent := AgentConfig{ClearFirst: true, ClearKeys: "C-u", NewlineKeys: "S-Enter"} + err := sendTextToPane("%5", "hello", agent) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // Expect: clear, then single line (no newline after last line) + if len(calls) != 2 { + t.Fatalf("got %d calls, want 2: %v", len(calls), calls) + } + if calls[0] != "send:%5:C-u" { + t.Errorf("call[0] = %q, want clear", calls[0]) + } + if calls[1] != "send:%5:hello" { + t.Errorf("call[1] = %q, want text", calls[1]) + } +} + +func TestSendTextToPane_MultiLine(t *testing.T) { + var calls []string + oldSend := sendKeys + defer func() { sendKeys = oldSend }() + sendKeys = func(paneID string, keys ...string) error { + calls = append(calls, strings.Join(keys, ",")) + return nil + } + agent := AgentConfig{NewlineKeys: "S-Enter"} + err := sendTextToPane("%1", "line1\nline2\nline3", agent) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // Expect: line1, S-Enter, line2, S-Enter, line3 (no trailing newline) + want := []string{"line1", "S-Enter", "line2", "S-Enter", "line3"} + if len(calls) != len(want) { + t.Fatalf("got %d calls, want %d: %v", len(calls), len(want), calls) + } + for i, w := range want { + if calls[i] != w { + t.Errorf("call[%d] = %q, want %q", i, calls[i], w) + } + } +} + +func TestSendTextToPane_NoClear(t *testing.T) { + var calls []string + oldSend := sendKeys + defer func() { sendKeys = oldSend }() + sendKeys = func(paneID string, keys ...string) error { + calls = append(calls, strings.Join(keys, ",")) + return nil + } + agent := AgentConfig{ClearFirst: false, ClearKeys: "C-u"} + err := sendTextToPane("%1", "hello", agent) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // No clear call; just the text + if len(calls) != 1 { + t.Fatalf("got %d calls, want 1: %v", len(calls), calls) + } +} + +func TestSendTextToPane_Empty(t *testing.T) { + oldSend := sendKeys + defer func() { sendKeys = oldSend }() + sendKeys = func(string, ...string) error { + t.Fatal("sendKeys should not be called for empty text") + return nil + } + err := sendTextToPane("%1", "", AgentConfig{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestSendTextToPane_ClearError(t *testing.T) { + oldSend := sendKeys + defer func() { sendKeys = oldSend }() + sendKeys = func(paneID string, keys ...string) error { + return fmt.Errorf("tmux error") + } + agent := AgentConfig{ClearFirst: true, ClearKeys: "C-u"} + err := sendTextToPane("%1", "hello", agent) + if err == nil { + t.Fatal("expected error on clear failure") + } +} + +func TestSendTextToPane_SendError(t *testing.T) { + callCount := 0 + oldSend := sendKeys + defer func() { sendKeys = oldSend }() + sendKeys = func(paneID string, keys ...string) error { + callCount++ + if callCount == 2 { // fail on second call (first line text) + return fmt.Errorf("send failed") + } + return nil + } + agent := AgentConfig{ClearFirst: true, ClearKeys: "C-u"} + err := sendTextToPane("%1", "hello", agent) + if err == nil { + t.Fatal("expected error on send failure") + } +} + +func TestSendTextToPane_FallbackNewline(t *testing.T) { + var calls []string + oldSend := sendKeys + defer func() { sendKeys = oldSend }() + sendKeys = func(paneID string, keys ...string) error { + calls = append(calls, strings.Join(keys, ",")) + return nil + } + // Agent with empty NewlineKeys should fallback to "Enter" + agent := AgentConfig{NewlineKeys: ""} + err := sendTextToPane("%1", "a\nb", agent) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // Expect: a, Enter, b + if len(calls) != 3 { + t.Fatalf("got %d calls, want 3: %v", len(calls), calls) + } + if calls[1] != "Enter" { + t.Errorf("newline key = %q, want Enter (fallback)", calls[1]) + } +} |
