summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-02-08 11:14:36 +0200
committerPaul Buetow <paul@buetow.org>2026-02-08 11:14:36 +0200
commit5e825543dc55a2c649e68dce6341844ad71fa217 (patch)
treef7aae1c1d130f08c383f95a23413bdde7843dc0f
parent023ed82e612451caa38ec46106ed9d148ab9a595 (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.go50
-rw-r--r--cmd/hexai-tmux-edit/main.go48
-rw-r--r--config.toml.example32
-rw-r--r--internal/appconfig/config.go92
-rw-r--r--internal/appconfig/config_test.go98
-rw-r--r--internal/tmuxedit/agent.go212
-rw-r--r--internal/tmuxedit/agent_test.go260
-rw-r--r--internal/tmuxedit/capture.go17
-rw-r--r--internal/tmuxedit/capture_test.go51
-rw-r--r--internal/tmuxedit/pane.go42
-rw-r--r--internal/tmuxedit/pane_test.go83
-rw-r--r--internal/tmuxedit/run.go148
-rw-r--r--internal/tmuxedit/run_test.go320
-rw-r--r--internal/tmuxedit/send.go74
-rw-r--r--internal/tmuxedit/send_test.go170
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])
+ }
+}