summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-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