summaryrefslogtreecommitdiff
path: root/internal
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 /internal
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>
Diffstat (limited to 'internal')
-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
12 files changed, 1567 insertions, 0 deletions
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