summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/tmux.md4
-rw-r--r--internal/appconfig/config.go57
-rw-r--r--internal/tmuxedit/agent.go310
-rw-r--r--internal/tmuxedit/agent_test.go284
-rw-r--r--internal/tmuxedit/agentutil.go160
-rw-r--r--internal/tmuxedit/agentutil_test.go206
-rw-r--r--internal/tmuxedit/claude_agent.go85
-rw-r--r--internal/tmuxedit/claude_agent_test.go129
-rw-r--r--internal/tmuxedit/config_agent.go134
-rw-r--r--internal/tmuxedit/config_agent_test.go178
-rw-r--r--internal/tmuxedit/cursor_agent.go58
-rw-r--r--internal/tmuxedit/cursor_agent_test.go140
-rw-r--r--internal/tmuxedit/run.go63
-rw-r--r--internal/tmuxedit/run_test.go15
-rw-r--r--internal/tmuxedit/send.go103
-rw-r--r--internal/tmuxedit/send_test.go183
16 files changed, 1354 insertions, 755 deletions
diff --git a/docs/tmux.md b/docs/tmux.md
index b6d4b68..ae84ce2 100644
--- a/docs/tmux.md
+++ b/docs/tmux.md
@@ -90,3 +90,7 @@ bind e run-shell -b "hexai-tmux-edit --pane '#{pane_id}'"
Then press `prefix + e` in any pane running an AI agent. Hexai auto-detects the agent, extracts any existing prompt text, and pre-fills the editor. After saving and closing, the edited text is sent back to the agent's pane.
See the [configuration guide](configuration.md) for customizing popup dimensions and agent patterns, or the [usage guide](usage.md) for the full workflow description.
+
+**Vim mode recommended**: For best results, run both Cursor Agent and Claude Code in vim mode. Claude Code's `C-u` clear relies on readline/vim insert-mode behavior, and Cursor's prompt clearing works most reliably in its default input mode. The popup editor uses `$EDITOR` (or `$HEXAI_EDITOR`), so your normal vim/neovim setup is used for composing prompts.
+
+**Note**: Agent detection and prompt extraction rely on regex patterns matched against each agent's terminal UI (box-drawing characters, prompt symbols, status text). When agents update their TUI layout, these patterns may need adjustment. You can override patterns per-agent in `[[tmux_edit.agents]]` config without code changes -- see the [configuration guide](configuration.md).
diff --git a/internal/appconfig/config.go b/internal/appconfig/config.go
index b21a4de..63b5ea5 100644
--- a/internal/appconfig/config.go
+++ b/internal/appconfig/config.go
@@ -141,15 +141,16 @@ type CustomAction struct {
// 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
+ Name string
+ DisplayName string
+ DetectPattern string
+ SectionPattern string
+ PromptPattern string
+ StripPatterns []string
+ ClearFirst *bool
+ ClearKeys string
+ NewlineKeys string
+ SubmitKeys string
}
// Constructor: defaults for App (kept first among functions)
@@ -364,15 +365,16 @@ type sectionTmuxEdit struct {
// 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"`
+ Name string `toml:"name"`
+ DisplayName string `toml:"display_name"`
+ DetectPattern string `toml:"detect_pattern"`
+ SectionPattern string `toml:"section_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 {
@@ -724,15 +726,16 @@ func (fc *fileConfig) applyTmuxEdit(out *App) {
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),
+ Name: strings.TrimSpace(a.Name),
+ DisplayName: strings.TrimSpace(a.DisplayName),
+ DetectPattern: strings.TrimSpace(a.DetectPattern),
+ SectionPattern: strings.TrimSpace(a.SectionPattern),
+ 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),
})
}
}
diff --git a/internal/tmuxedit/agent.go b/internal/tmuxedit/agent.go
index 7be38ed..313907a 100644
--- a/internal/tmuxedit/agent.go
+++ b/internal/tmuxedit/agent.go
@@ -1,182 +1,121 @@
// Package tmuxedit implements a tmux popup editor for composing AI agent prompts.
-// agent.go defines agent detection, prompt extraction, and noise stripping.
+// agent.go defines the Agent interface, the baseAgent struct with default
+// implementations, and agent detection/resolution helpers.
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")
+// Agent defines how to interact with a specific AI agent in a tmux pane.
+// Each implementation encapsulates its own detection, extraction, clearing,
+// and sending logic since agents differ fundamentally in their UI structure.
+type Agent interface {
+ Name() string
+ DisplayName() string
+ Detect(paneContent string) bool
+ ExtractPrompt(paneContent string) string
+ ClearInput(paneID string) error
+ SendText(paneID, text string) error
}
-// builtinAgents returns the default set of agent configurations. Order
-// matters: agents with distinctive UI elements (box-drawing, etc.) are
-// checked first to avoid false positives from model names like "Claude
-// 4.5 Sonnet" appearing in other agents' panes. Overridden/extended by
-// user config in [tmux_edit.agents].
-func builtinAgents() []AgentConfig {
- return []AgentConfig{
- {
- // Cursor Agent uses a distinctive box-drawing │ → prompt │ UI.
- // Detect by the box structure or "/ commands" footer. Checked
- // first because cursor panes show model names like "Claude 4.5".
- // Clear uses End + bulk backspace to delete all existing text.
- // The *200 suffix sends 200 backspaces via tmux send-keys -N.
- Name: "cursor",
- DisplayName: "Cursor",
- DetectPattern: `(│\s*→|/ commands · @ files)`,
- PromptPattern: `(?m)│\s*→?\s*(.+?)\s*│\s*$`,
- StripPatterns: []string{"INSERT", "Add a follow-up", "ctrl+c to stop"},
- ClearFirst: true,
- ClearKeys: "End BSpace*200",
- NewlineKeys: "S-Enter",
- SubmitKeys: "Enter",
- },
- {
- // Claude Code uses ❯ prompt between ──── horizontal rules.
- // Detect by the ❯ prompt or explicit "claude code" banner.
- Name: "claude",
- DisplayName: "Claude Code",
- DetectPattern: `(❯|claude code|anthropic)`,
- PromptPattern: `(?m)❯\s*(.+)$`,
- 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",
- },
- }
+// Configurable provides access to a baseAgent's fields for config merging.
+// Agent implementations that embed baseAgent automatically satisfy this.
+type Configurable interface {
+ Base() *baseAgent
}
-// 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",
- }
+// baseAgent holds configurable fields and provides default implementations
+// of the Agent interface. Specialized agents (cursor, claude) embed baseAgent
+// and override methods where behavior differs from the defaults.
+type baseAgent struct {
+ name string
+ displayName string
+ detectPattern string
+ sectionPat string // optional regex to delimit the prompt area
+ promptPat string // regex with capture group (1) for 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
+ newlineKeys string // tmux key to insert a newline
+ submitKeys string // tmux key to submit the prompt
}
-// 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
-}
+// Base returns a pointer to the baseAgent for config merging.
+func (b *baseAgent) Base() *baseAgent { return b }
-// 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
+// Name returns the agent's short identifier (e.g. "claude", "cursor").
+func (b *baseAgent) Name() string { return b.name }
+
+// DisplayName returns the agent's human-readable name.
+func (b *baseAgent) DisplayName() string { return b.displayName }
+
+// Detect checks whether the pane content matches this agent's detection
+// pattern. Returns false if no pattern is set or the regex is invalid.
+func (b *baseAgent) Detect(paneContent string) bool {
+ if b.detectPattern == "" {
+ return false
}
- if len(cfg.StripPatterns) > 0 {
- base.StripPatterns = cfg.StripPatterns
+ re, err := regexp.Compile(b.detectPattern)
+ if err != nil {
+ return false
}
- if cfg.ClearFirst != nil {
- base.ClearFirst = *cfg.ClearFirst
+ return re.MatchString(paneContent)
+}
+
+// ExtractPrompt uses the agent's prompt pattern to extract the current prompt
+// text from pane content. If sectionPat is set, extraction is scoped to the
+// last section between two delimiter lines and all matches are joined.
+// Without sectionPat, the last contiguous group of matched lines is used.
+// Returns empty string if no pattern or no match.
+func (b *baseAgent) ExtractPrompt(paneContent string) string {
+ if b.promptPat == "" {
+ return ""
}
- if s := strings.TrimSpace(cfg.ClearKeys); s != "" {
- base.ClearKeys = s
+ re, err := regexp.Compile(b.promptPat)
+ if err != nil {
+ return ""
}
- if s := strings.TrimSpace(cfg.NewlineKeys); s != "" {
- base.NewlineKeys = s
+ scoped := b.sectionPat != ""
+ content := scopeToLastSection(paneContent, b.sectionPat)
+ allMatches := matchPromptLines(re, content)
+ if len(allMatches) == 0 {
+ return ""
}
- if s := strings.TrimSpace(cfg.SubmitKeys); s != "" {
- base.SubmitKeys = s
+ if scoped {
+ return joinAllMatches(allMatches, b.stripPatterns)
}
- return base
+ return joinLastContiguousBlock(allMatches, b.stripPatterns)
}
-// 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),
+// ClearInput clears existing input in the pane using the configured key
+// sequence. Skipped if clearFirst is false or clearKeys is empty.
+func (b *baseAgent) ClearInput(paneID string) error {
+ if !b.clearFirst || b.clearKeys == "" {
+ return nil
}
- if cfg.ClearFirst != nil {
- a.ClearFirst = *cfg.ClearFirst
+ if err := sendClearSequence(paneID, b.clearKeys); err != nil {
+ return err
}
- if a.DisplayName == "" {
- a.DisplayName = a.Name
+ sleepAfterClear()
+ return nil
+}
+
+// SendText sends the given text to the target pane line-by-line, using the
+// agent's newline key between lines.
+func (b *baseAgent) SendText(paneID, text string) error {
+ if strings.TrimSpace(text) == "" {
+ return nil
}
- return a
+ return sendLines(paneID, text, b.newlineKeys)
}
-// detectAgent tries each agent's DetectPattern against pane content.
+// detectAgent tries each agent's Detect method against pane content.
// First match wins. Returns genericAgent() if no agent matches.
-func detectAgent(paneContent string, agents []AgentConfig) AgentConfig {
+func detectAgent(paneContent string, agents []Agent) Agent {
for _, a := range agents {
- if a.DetectPattern == "" {
- continue
- }
- re, err := regexp.Compile(a.DetectPattern)
- if err != nil {
- continue
- }
- if re.MatchString(paneContent) {
+ if a.Detect(paneContent) {
return a
}
}
@@ -185,80 +124,11 @@ func detectAgent(paneContent string, agents []AgentConfig) AgentConfig {
// findAgentByName returns the agent with the given name (case-insensitive),
// falling back to genericAgent() if not found.
-func findAgentByName(name string, agents []AgentConfig) AgentConfig {
+func findAgentByName(name string, agents []Agent) Agent {
for _, a := range agents {
- if strings.EqualFold(a.Name, name) {
+ 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. For multi-line prompts (e.g. cursor's box-drawing
-// │...│ UI) it takes only the last contiguous group of matched lines, which
-// avoids picking up command-review or dialog boxes that use the same border
-// characters. 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 ""
- }
- allMatches := matchPromptLines(re, paneContent)
- if len(allMatches) == 0 {
- return ""
- }
- return joinLastContiguousBlock(allMatches, agent.StripPatterns)
-}
-
-// promptMatch holds a regex match result with its line number in the pane.
-type promptMatch struct {
- lineNum int
- text string // capture group 1
-}
-
-// matchPromptLines runs the prompt regex against each pane line, returning
-// matches with their line numbers for contiguity analysis.
-func matchPromptLines(re *regexp.Regexp, paneContent string) []promptMatch {
- paneLines := strings.Split(paneContent, "\n")
- var matches []promptMatch
- for i, line := range paneLines {
- m := re.FindStringSubmatch(line)
- if len(m) >= 2 {
- matches = append(matches, promptMatch{lineNum: i, text: m[1]})
- }
- }
- return matches
-}
-
-// joinLastContiguousBlock takes the last group of matches on consecutive line
-// numbers, strips noise from each, and joins the non-empty results with
-// newlines. This ensures that only the bottom-most box (the input prompt)
-// is captured when multiple box-drawing sections exist in the pane.
-func joinLastContiguousBlock(matches []promptMatch, strips []string) string {
- last := len(matches) - 1
- start := last
- for start > 0 && matches[start].lineNum-matches[start-1].lineNum == 1 {
- start--
- }
- var lines []string
- for i := start; i <= last; i++ {
- line := stripNoise(matches[i].text, strips)
- if line != "" {
- lines = append(lines, line)
- }
- }
- return strings.Join(lines, "\n")
-}
-
-// 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
index 7ad1274..3673d70 100644
--- a/internal/tmuxedit/agent_test.go
+++ b/internal/tmuxedit/agent_test.go
@@ -2,12 +2,8 @@ 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 {
@@ -28,8 +24,8 @@ func TestDetectAgent(t *testing.T) {
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)
+ if got.Name() != tt.want {
+ t.Errorf("detectAgent() = %q, want %q", got.Name(), tt.want)
}
})
}
@@ -50,260 +46,74 @@ func TestFindAgentByName(t *testing.T) {
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: "────\n❯ hello world\n────",
- agent: builtinAgents()[1], // claude
- want: "hello world",
- },
- {
- name: "cursor prompt with box and arrow",
- content: "Cursor Agent\n │ → fix the bug INSERT │",
- agent: builtinAgents()[0], // cursor
- want: "fix the bug",
- },
- {
- name: "cursor prompt without arrow",
- content: "Cursor Agent\n │ fix the bug │",
- agent: builtinAgents()[0], // cursor
- want: "fix the bug",
- },
- {
- name: "cursor prompt strips follow-up",
- content: "Cursor\n │ → Add a follow-up │",
- agent: builtinAgents()[0], // cursor
- want: "",
- },
- {
- name: "cursor multi-line prompt",
- content: " │ → first line of prompt │\n │ second line here │\n │ third line end │",
- agent: builtinAgents()[0], // cursor
- want: "first line of prompt\nsecond line here\nthird line end",
- },
- {
- name: "cursor multi-line with noise",
- content: " │ → fix the bug INSERT │\n │ also refactor tests │",
- agent: builtinAgents()[0], // cursor
- want: "fix the bug\nalso refactor tests",
- },
- {
- name: "cursor multi-box takes last box only",
- content: " ┌──────────────┐\n" +
- " │ $ git push │\n" +
- " └──────────────┘\n" +
- " ┌──────────────┐\n" +
- " │ Run command? │\n" +
- " │ → Yes (enter) │\n" +
- " │ No (esc) │\n" +
- " └──────────────┘\n" +
- " ┌──────────────┐\n" +
- " │ → hello world │\n" +
- " └──────────────┘\n",
- agent: builtinAgents()[0], // cursor
- want: "hello world",
- },
- {
- name: "cursor multi-box multi-line prompt",
- content: " ┌──────────────┐\n" +
- " │ $ git push │\n" +
- " └──────────────┘\n" +
- " ┌──────────────┐\n" +
- " │ → first line │\n" +
- " │ second line │\n" +
- " │ third line │\n" +
- " └──────────────┘\n",
- agent: builtinAgents()[0], // cursor
- want: "first line\nsecond line\nthird line",
- },
- {
- name: "no pattern",
- content: "some text",
- agent: genericAgent(),
- want: "",
- },
- {
- name: "no match",
- content: "no prompt here",
- agent: builtinAgents()[1], // 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)
+ if got.Name() != tt.want {
+ t.Errorf("findAgentByName(%q) = %q, want %q", tt.name, got.Name(), 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"}, ""},
+func TestDetectAgent_InvalidRegex(t *testing.T) {
+ agents := []Agent{
+ &configAgent{baseAgent{name: "bad", detectPattern: "[invalid"}},
}
- 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)
- }
- })
+ got := detectAgent("anything", agents)
+ if got.Name() != "generic" {
+ t.Errorf("expected generic fallback for invalid regex, got %q", got.Name())
}
}
-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 TestGenericAgent(t *testing.T) {
+ g := genericAgent()
+ if g.Name() != "generic" {
+ t.Errorf("Name = %q, want generic", g.Name())
}
}
-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 TestBaseAgent_SendText_Empty(t *testing.T) {
+ b := &baseAgent{newlineKeys: "S-Enter"}
+ err := b.SendText("%1", "")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
}
}
-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 TestBaseAgent_ClearInput_Disabled(t *testing.T) {
+ b := &baseAgent{clearFirst: false, clearKeys: "C-u"}
+ err := b.ClearInput("%1")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
}
}
-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 TestBaseAgent_ExtractPrompt_NoPattern(t *testing.T) {
+ b := &baseAgent{}
+ got := b.ExtractPrompt("some content")
+ if got != "" {
+ t.Errorf("expected empty, got %q", got)
}
}
-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 TestBaseAgent_ExtractPrompt_InvalidRegex(t *testing.T) {
+ b := &baseAgent{promptPat: "[invalid"}
+ got := b.ExtractPrompt("> test")
+ if got != "" {
+ t.Errorf("expected empty for invalid regex, got %q", got)
}
}
-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)
+func TestConfigurable_Interface(t *testing.T) {
+ // Verify that all agent types implement Configurable
+ agents := builtinAgents()
+ for _, a := range agents {
+ c, ok := a.(Configurable)
+ if !ok {
+ t.Errorf("agent %q does not implement Configurable", a.Name())
+ continue
+ }
+ base := c.Base()
+ if base.name != a.Name() {
+ t.Errorf("Base().name = %q, want %q", base.name, a.Name())
+ }
}
}
diff --git a/internal/tmuxedit/agentutil.go b/internal/tmuxedit/agentutil.go
new file mode 100644
index 0000000..924a4a8
--- /dev/null
+++ b/internal/tmuxedit/agentutil.go
@@ -0,0 +1,160 @@
+// Package tmuxedit implements a tmux popup editor for composing AI agent prompts.
+// agentutil.go provides shared helpers for prompt extraction and tmux key sending
+// used by individual agent implementations.
+package tmuxedit
+
+import (
+ "fmt"
+ "regexp"
+ "strconv"
+ "strings"
+)
+
+// promptMatch holds a regex match result with its line number in the pane.
+type promptMatch struct {
+ lineNum int
+ text string // capture group 1
+}
+
+// matchPromptLines runs the prompt regex against each pane line, returning
+// matches with their line numbers for contiguity analysis.
+func matchPromptLines(re *regexp.Regexp, paneContent string) []promptMatch {
+ paneLines := strings.Split(paneContent, "\n")
+ var matches []promptMatch
+ for i, line := range paneLines {
+ m := re.FindStringSubmatch(line)
+ if len(m) >= 2 {
+ matches = append(matches, promptMatch{lineNum: i, text: m[1]})
+ }
+ }
+ return matches
+}
+
+// joinAllMatches strips noise from all matches and joins the non-empty results
+// with newlines. Used when SectionPattern has already scoped to the prompt area.
+func joinAllMatches(matches []promptMatch, strips []string) string {
+ var lines []string
+ for _, m := range matches {
+ line := stripNoise(m.text, strips)
+ if line != "" {
+ lines = append(lines, line)
+ }
+ }
+ return strings.Join(lines, "\n")
+}
+
+// joinLastContiguousBlock takes the last group of matches on consecutive line
+// numbers, strips noise from each, and joins the non-empty results with
+// newlines. This ensures that only the bottom-most box (the input prompt)
+// is captured when multiple box-drawing sections exist in the pane.
+func joinLastContiguousBlock(matches []promptMatch, strips []string) string {
+ last := len(matches) - 1
+ start := last
+ for start > 0 && matches[start].lineNum-matches[start-1].lineNum == 1 {
+ start--
+ }
+ var lines []string
+ for i := start; i <= last; i++ {
+ line := stripNoise(matches[i].text, strips)
+ if line != "" {
+ lines = append(lines, line)
+ }
+ }
+ return strings.Join(lines, "\n")
+}
+
+// scopeToLastSection extracts the content between the last two lines matching
+// the section delimiter pattern. This isolates the prompt area (e.g. Claude's
+// ─── rules) from previous conversation content. Returns the full content if
+// no pattern is set or fewer than two delimiters are found.
+func scopeToLastSection(paneContent, sectionPattern string) string {
+ if sectionPattern == "" {
+ return paneContent
+ }
+ re, err := regexp.Compile(sectionPattern)
+ if err != nil {
+ return paneContent
+ }
+ lines := strings.Split(paneContent, "\n")
+ var delimLines []int
+ for i, line := range lines {
+ if re.MatchString(line) {
+ delimLines = append(delimLines, i)
+ }
+ }
+ if len(delimLines) < 2 {
+ return paneContent
+ }
+ start := delimLines[len(delimLines)-2] + 1
+ end := delimLines[len(delimLines)-1]
+ if start >= end {
+ return paneContent
+ }
+ return strings.Join(lines[start:end], "\n")
+}
+
+// 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)
+}
+
+// sendClearSequence parses a space-separated key sequence and sends each
+// token individually. Tokens with a "*N" suffix (e.g. "BSpace*200") are
+// sent N times using tmux send-keys -N for efficient bulk repeats.
+func sendClearSequence(paneID, clearKeys string) error {
+ for _, token := range strings.Fields(clearKeys) {
+ key, count := parseKeyRepeat(token)
+ if count > 1 {
+ if err := sendRepeatedKey(paneID, key, count); err != nil {
+ return fmt.Errorf("clear key %q*%d failed: %w", key, count, err)
+ }
+ } else {
+ if err := sendKeys(paneID, key); err != nil {
+ return fmt.Errorf("clear key %q failed: %w", key, err)
+ }
+ }
+ }
+ return nil
+}
+
+// parseKeyRepeat splits "Key*N" into (Key, N). Returns (token, 1) if no
+// repeat suffix is present or the suffix is invalid.
+func parseKeyRepeat(token string) (string, int) {
+ idx := strings.LastIndex(token, "*")
+ if idx < 1 || idx >= len(token)-1 {
+ return token, 1
+ }
+ n, err := strconv.Atoi(token[idx+1:])
+ if err != nil || n < 1 {
+ return token, 1
+ }
+ return token[:idx], n
+}
+
+// sendLines sends text line-by-line to a tmux pane, inserting the specified
+// newline key between lines. If newlineKeys is empty, "Enter" is used as
+// fallback. This is the shared text-sending logic used by agent SendText
+// implementations.
+func sendLines(paneID, text, newlineKeys string) error {
+ 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 := newlineKeys
+ if nlKey == "" {
+ nlKey = "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/agentutil_test.go b/internal/tmuxedit/agentutil_test.go
new file mode 100644
index 0000000..8bf2e64
--- /dev/null
+++ b/internal/tmuxedit/agentutil_test.go
@@ -0,0 +1,206 @@
+package tmuxedit
+
+import (
+ "regexp"
+ "testing"
+)
+
+func TestScopeToLastSection(t *testing.T) {
+ tests := []struct {
+ name string
+ content string
+ pattern string
+ want string
+ }{
+ {
+ name: "no pattern returns full content",
+ content: "line1\nline2\nline3",
+ pattern: "",
+ want: "line1\nline2\nline3",
+ },
+ {
+ name: "invalid regex returns full content",
+ content: "line1\nline2",
+ pattern: "[invalid",
+ want: "line1\nline2",
+ },
+ {
+ name: "fewer than two delimiters returns full content",
+ content: "─────\nhello",
+ pattern: `^─{5,}`,
+ want: "─────\nhello",
+ },
+ {
+ name: "extracts last section between two delimiters",
+ content: "─────\nold message\n─────\n❯ prompt text\n─────",
+ pattern: `^─{5,}`,
+ want: "❯ prompt text",
+ },
+ {
+ name: "skips earlier sections",
+ content: "─────\n❯ old msg1\n─────\n" +
+ "─────\n❯ old msg2\n─────\n" +
+ "─────\n❯ current prompt\n─────",
+ pattern: `^─{5,}`,
+ want: "❯ current prompt",
+ },
+ {
+ name: "claude multi-line prompt between rules",
+ content: "previous output\n" +
+ "─────────────\n" +
+ "❯ first line\n" +
+ "\n" +
+ "❯ second line\n" +
+ "\n" +
+ "❯ third line\n" +
+ "─────────────\n" +
+ " -- INSERT --",
+ pattern: `^─{5,}`,
+ want: "❯ first line\n\n❯ second line\n\n❯ third line",
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := scopeToLastSection(tt.content, tt.pattern)
+ if got != tt.want {
+ t.Errorf("scopeToLastSection() = %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 TestMatchPromptLines(t *testing.T) {
+ tests := []struct {
+ name string
+ pattern string
+ content string
+ want int
+ }{
+ {"no matches", `❯\s*(.+)$`, "no prompt here", 0},
+ {"single match", `❯\s*(.+)$`, "❯ hello", 1},
+ {"multiple matches", `❯\s*(.+)$`, "❯ first\nother\n❯ second", 2},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ re := mustCompile(t, tt.pattern)
+ got := matchPromptLines(re, tt.content)
+ if len(got) != tt.want {
+ t.Errorf("matchPromptLines() returned %d matches, want %d", len(got), tt.want)
+ }
+ })
+ }
+}
+
+func TestJoinAllMatches(t *testing.T) {
+ matches := []promptMatch{
+ {lineNum: 0, text: "first"},
+ {lineNum: 2, text: "INSERT"},
+ {lineNum: 4, text: "third"},
+ }
+ got := joinAllMatches(matches, []string{"INSERT"})
+ if got != "first\nthird" {
+ t.Errorf("joinAllMatches() = %q, want %q", got, "first\nthird")
+ }
+}
+
+func TestJoinLastContiguousBlock(t *testing.T) {
+ tests := []struct {
+ name string
+ matches []promptMatch
+ strips []string
+ want string
+ }{
+ {
+ name: "single block",
+ matches: []promptMatch{
+ {lineNum: 5, text: "first"},
+ {lineNum: 6, text: "second"},
+ },
+ want: "first\nsecond",
+ },
+ {
+ name: "two blocks takes last",
+ matches: []promptMatch{
+ {lineNum: 1, text: "old"},
+ {lineNum: 2, text: "old2"},
+ {lineNum: 10, text: "new"},
+ {lineNum: 11, text: "new2"},
+ },
+ want: "new\nnew2",
+ },
+ {
+ name: "strips noise",
+ matches: []promptMatch{
+ {lineNum: 0, text: "fix INSERT"},
+ },
+ strips: []string{"INSERT"},
+ want: "fix",
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := joinLastContiguousBlock(tt.matches, tt.strips)
+ if got != tt.want {
+ t.Errorf("joinLastContiguousBlock() = %q, want %q", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestParseKeyRepeat(t *testing.T) {
+ tests := []struct {
+ token string
+ wantKey string
+ wantCount int
+ }{
+ {"BSpace*200", "BSpace", 200},
+ {"End", "End", 1},
+ {"C-u", "C-u", 1},
+ {"BSpace*1", "BSpace", 1},
+ {"BSpace*0", "BSpace*0", 1}, // invalid count
+ {"BSpace*abc", "BSpace*abc", 1}, // non-numeric
+ {"*200", "*200", 1}, // no key name
+ {"x*3", "x", 3},
+ }
+ for _, tt := range tests {
+ t.Run(tt.token, func(t *testing.T) {
+ key, count := parseKeyRepeat(tt.token)
+ if key != tt.wantKey || count != tt.wantCount {
+ t.Errorf("parseKeyRepeat(%q) = (%q, %d), want (%q, %d)",
+ tt.token, key, count, tt.wantKey, tt.wantCount)
+ }
+ })
+ }
+}
+
+// mustCompile is a test helper that compiles a regex or fails the test.
+func mustCompile(t *testing.T, pattern string) *regexp.Regexp {
+ t.Helper()
+ re, err := regexp.Compile(pattern)
+ if err != nil {
+ t.Fatalf("regexp.Compile(%q) failed: %v", pattern, err)
+ }
+ return re
+}
diff --git a/internal/tmuxedit/claude_agent.go b/internal/tmuxedit/claude_agent.go
new file mode 100644
index 0000000..72ba107
--- /dev/null
+++ b/internal/tmuxedit/claude_agent.go
@@ -0,0 +1,85 @@
+package tmuxedit
+
+import (
+ "regexp"
+ "strings"
+)
+
+// claudeAgent handles Claude Code's ❯ prompt between ──── horizontal rules.
+// Claude Code runs in actual vim mode, so clearing uses vim commands.
+// Wrapped text appears as indented continuation lines without ❯.
+type claudeAgent struct{ baseAgent }
+
+// newClaudeAgent returns a claudeAgent with the default configuration.
+// SectionPattern scopes extraction to the last ─── delimited area, avoiding
+// false positives from ❯ in previous messages.
+func newClaudeAgent() *claudeAgent {
+ return &claudeAgent{baseAgent{
+ name: "claude",
+ displayName: "Claude Code",
+ detectPattern: `(❯|(?i)claude code|(?i)anthropic)`,
+ sectionPat: `^─{5,}`,
+ promptPat: `(?m)❯\s*(.+)$`,
+ clearFirst: true,
+ clearKeys: "Escape gg C-v G d i",
+ newlineKeys: "S-Enter",
+ submitKeys: "Enter",
+ }}
+}
+
+// ExtractPrompt extracts the prompt text from the last section between ─────
+// rules. Within the scoped section, all non-empty lines are collected:
+// ❯-prefixed lines have the prefix stripped, and indented continuation lines
+// (wrapped text without ❯) are included as-is after trimming.
+func (c *claudeAgent) ExtractPrompt(paneContent string) string {
+ if c.promptPat == "" {
+ return ""
+ }
+ re, err := regexp.Compile(c.promptPat)
+ if err != nil {
+ return ""
+ }
+ // Scope to the last section between ───── delimiters
+ content := scopeToLastSection(paneContent, c.sectionPat)
+ // Collect ❯-prefixed lines and their continuation lines (indented
+ // wrapped text without ❯). Only include non-❯ lines that directly
+ // follow a ❯-matched line to avoid picking up unrelated content.
+ paneLines := strings.Split(content, "\n")
+ var lines []string
+ inPrompt := false
+ for _, line := range paneLines {
+ m := re.FindStringSubmatch(line)
+ if len(m) >= 2 {
+ // ❯-prefixed line: use the captured text
+ cleaned := stripNoise(m[1], c.stripPatterns)
+ if cleaned != "" {
+ lines = append(lines, cleaned)
+ }
+ inPrompt = true
+ } else if inPrompt {
+ // Non-❯ line after a prompt: include indented continuation text
+ trimmed := strings.TrimSpace(line)
+ if trimmed != "" {
+ lines = append(lines, trimmed)
+ } else {
+ // Empty line breaks the continuation
+ inPrompt = false
+ }
+ }
+ }
+ return strings.Join(lines, "\n")
+}
+
+// ClearInput sends vim commands to clear Claude Code's input:
+// Escape to ensure normal mode, gg to go to top, C-v G d to visual-block
+// select all and delete, then i to re-enter insert mode.
+func (c *claudeAgent) ClearInput(paneID string) error {
+ if !c.clearFirst || c.clearKeys == "" {
+ return nil
+ }
+ if err := sendClearSequence(paneID, c.clearKeys); err != nil {
+ return err
+ }
+ sleepAfterClear()
+ return nil
+}
diff --git a/internal/tmuxedit/claude_agent_test.go b/internal/tmuxedit/claude_agent_test.go
new file mode 100644
index 0000000..a373378
--- /dev/null
+++ b/internal/tmuxedit/claude_agent_test.go
@@ -0,0 +1,129 @@
+package tmuxedit
+
+import (
+ "fmt"
+ "strings"
+ "testing"
+)
+
+func TestClaudeAgent_ExtractPrompt(t *testing.T) {
+ agent := newClaudeAgent()
+ tests := []struct {
+ name string
+ content string
+ want string
+ }{
+ {
+ name: "single line",
+ content: "──────\n❯ hello world\n──────",
+ want: "hello world",
+ },
+ {
+ name: "multi-line between rules",
+ content: "previous output\n" +
+ "──────────────\n" +
+ "❯ first line\n" +
+ "\n" +
+ "❯ second line\n" +
+ "\n" +
+ "❯ third line\n" +
+ "──────────────\n" +
+ " -- INSERT --",
+ want: "first line\nsecond line\nthird line",
+ },
+ {
+ name: "wrapped long line",
+ content: "──────────────\n" +
+ "❯ This is a really long prompt that wraps\n" +
+ " to a second line in the terminal\n" +
+ "──────────────\n" +
+ " -- INSERT --",
+ want: "This is a really long prompt that wraps\nto a second line in the terminal",
+ },
+ {
+ name: "ignores previous messages",
+ content: "──────────────\n" +
+ "❯ old user message\n" +
+ "──────────────\n" +
+ "assistant response here\n" +
+ "──────────────\n" +
+ "❯ current prompt\n" +
+ "──────────────\n" +
+ " -- INSERT --",
+ want: "current prompt",
+ },
+ {
+ name: "no match",
+ content: "no prompt here",
+ want: "",
+ },
+ {
+ name: "no section delimiters",
+ content: "❯ hello world",
+ want: "hello world",
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := agent.ExtractPrompt(tt.content)
+ if got != tt.want {
+ t.Errorf("ExtractPrompt() = %q, want %q", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestClaudeAgent_ClearInput(t *testing.T) {
+ noSleep(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 := newClaudeAgent()
+ err := agent.ClearInput("%3")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ // "Escape gg C-v G d i" should send each as separate send-keys call
+ want := []string{
+ "send:%3:Escape",
+ "send:%3:gg",
+ "send:%3:C-v",
+ "send:%3:G",
+ "send:%3:d",
+ "send:%3:i",
+ }
+ 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 TestClaudeAgent_Detect(t *testing.T) {
+ agent := newClaudeAgent()
+ tests := []struct {
+ name string
+ content string
+ want bool
+ }{
+ {"prompt symbol", "❯ hello", true},
+ {"claude code banner", "claude code v1.0", true},
+ {"anthropic mention", "Powered by Anthropic", true},
+ {"no match", "some text", false},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := agent.Detect(tt.content); got != tt.want {
+ t.Errorf("Detect() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
diff --git a/internal/tmuxedit/config_agent.go b/internal/tmuxedit/config_agent.go
new file mode 100644
index 0000000..2773025
--- /dev/null
+++ b/internal/tmuxedit/config_agent.go
@@ -0,0 +1,134 @@
+package tmuxedit
+
+import (
+ "strings"
+
+ "codeberg.org/snonux/hexai/internal/appconfig"
+)
+
+// configAgent uses baseAgent defaults for all operations. It serves
+// user-defined agents from TOML config and simple built-ins (amp, aider)
+// that don't need specialized extraction or clearing logic.
+type configAgent struct{ baseAgent }
+
+// builtinAgents returns the default set of agent implementations. Order
+// matters: agents with distinctive UI elements (box-drawing, etc.) are
+// checked first to avoid false positives from model names like "Claude
+// 4.5 Sonnet" appearing in other agents' panes.
+func builtinAgents() []Agent {
+ return []Agent{
+ newCursorAgent(),
+ newClaudeAgent(),
+ &configAgent{baseAgent{
+ name: "amp",
+ displayName: "Amp",
+ detectPattern: `(?i)(amp|sourcegraph)`,
+ promptPat: `(?m)>\s*(.+)$`,
+ clearFirst: true,
+ clearKeys: "C-u",
+ newlineKeys: "S-Enter",
+ submitKeys: "Enter",
+ }},
+ &configAgent{baseAgent{
+ name: "aider",
+ displayName: "Aider",
+ detectPattern: `(?i)aider`,
+ promptPat: `(?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() Agent {
+ return &configAgent{baseAgent{
+ 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. The Configurable interface provides
+// access to baseAgent fields for merging.
+func resolveAgents(cfgAgents []appconfig.TmuxEditAgentCfg) []Agent {
+ agents := builtinAgents()
+ for _, ca := range cfgAgents {
+ merged := false
+ for i, a := range agents {
+ if !strings.EqualFold(a.Name(), ca.Name) {
+ continue
+ }
+ if c, ok := a.(Configurable); ok {
+ mergeAgentConfig(c.Base(), ca)
+ }
+ merged = true
+ _ = i // index not needed; we modify through the pointer
+ break
+ }
+ if !merged {
+ agents = append(agents, agentFromConfig(ca))
+ }
+ }
+ return agents
+}
+
+// mergeAgentConfig overrides fields in base with non-zero values from cfg.
+// It modifies the baseAgent in place via pointer.
+func mergeAgentConfig(base *baseAgent, cfg appconfig.TmuxEditAgentCfg) {
+ 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.SectionPattern); s != "" {
+ base.sectionPat = s
+ }
+ if s := strings.TrimSpace(cfg.PromptPattern); s != "" {
+ base.promptPat = 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
+ }
+}
+
+// agentFromConfig creates a new configAgent from a user config entry.
+func agentFromConfig(cfg appconfig.TmuxEditAgentCfg) Agent {
+ b := baseAgent{
+ name: strings.TrimSpace(cfg.Name),
+ displayName: strings.TrimSpace(cfg.DisplayName),
+ detectPattern: strings.TrimSpace(cfg.DetectPattern),
+ sectionPat: strings.TrimSpace(cfg.SectionPattern),
+ promptPat: 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 {
+ b.clearFirst = *cfg.ClearFirst
+ }
+ if b.displayName == "" {
+ b.displayName = b.name
+ }
+ return &configAgent{b}
+}
diff --git a/internal/tmuxedit/config_agent_test.go b/internal/tmuxedit/config_agent_test.go
new file mode 100644
index 0000000..7c49c42
--- /dev/null
+++ b/internal/tmuxedit/config_agent_test.go
@@ -0,0 +1,178 @@
+package tmuxedit
+
+import (
+ "testing"
+
+ "codeberg.org/snonux/hexai/internal/appconfig"
+)
+
+func boolP(b bool) *bool { return &b }
+
+func TestResolveAgents_MergeOverride(t *testing.T) {
+ cfgAgents := []appconfig.TmuxEditAgentCfg{
+ {
+ Name: "claude",
+ DisplayName: "My Claude",
+ ClearFirst: boolP(false),
+ },
+ }
+ agents := resolveAgents(cfgAgents)
+ var claude Agent
+ for _, a := range agents {
+ if a.Name() == "claude" {
+ claude = a
+ break
+ }
+ }
+ if claude == nil {
+ t.Fatal("claude agent not found")
+ }
+ if claude.DisplayName() != "My Claude" {
+ t.Errorf("DisplayName = %q, want My Claude", claude.DisplayName())
+ }
+ // ClearInput should be no-op after override to false
+ c := claude.(Configurable)
+ if c.Base().clearFirst {
+ t.Error("clearFirst should be false after override")
+ }
+ // DetectPattern should be preserved from builtin
+ if c.Base().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 Agent
+ for _, ag := range agents {
+ if ag.Name() == "claude" {
+ a = ag
+ break
+ }
+ }
+ if a == nil {
+ t.Fatal("claude agent not found")
+ }
+ c := a.(Configurable)
+ base := c.Base()
+ if base.detectPattern != "(?i)custom-claude" {
+ t.Errorf("detectPattern = %q", base.detectPattern)
+ }
+ if base.promptPat != `>\s+(.*)$` {
+ t.Errorf("promptPat = %q", base.promptPat)
+ }
+ if len(base.stripPatterns) != 1 || base.stripPatterns[0] != "NOISE" {
+ t.Errorf("stripPatterns = %v", base.stripPatterns)
+ }
+ if base.clearKeys != "C-k" {
+ t.Errorf("clearKeys = %q", base.clearKeys)
+ }
+ if base.newlineKeys != "C-Enter" {
+ t.Errorf("newlineKeys = %q", base.newlineKeys)
+ }
+ if base.submitKeys != "C-m" {
+ t.Errorf("submitKeys = %q", base.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())
+ }
+ c := a.(Configurable)
+ if !c.Base().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 TestConfigAgent_ExtractPrompt(t *testing.T) {
+ // Config agent uses baseAgent's default extraction (section-aware)
+ agent := &configAgent{baseAgent{
+ promptPat: `(?m)>\s*(.+)$`,
+ }}
+ content := "> hello world"
+ got := agent.ExtractPrompt(content)
+ if got != "hello world" {
+ t.Errorf("ExtractPrompt() = %q, want %q", got, "hello world")
+ }
+}
+
+func TestConfigAgent_Amp(t *testing.T) {
+ agents := builtinAgents()
+ var amp Agent
+ for _, a := range agents {
+ if a.Name() == "amp" {
+ amp = a
+ break
+ }
+ }
+ if amp == nil {
+ t.Fatal("amp agent not found")
+ }
+ if !amp.Detect("Amp by Sourcegraph") {
+ t.Error("amp should detect 'Amp by Sourcegraph'")
+ }
+ got := amp.ExtractPrompt("> fix the bug")
+ if got != "fix the bug" {
+ t.Errorf("ExtractPrompt() = %q, want %q", got, "fix the bug")
+ }
+}
+
+func TestConfigAgent_Aider(t *testing.T) {
+ agents := builtinAgents()
+ var aider Agent
+ for _, a := range agents {
+ if a.Name() == "aider" {
+ aider = a
+ break
+ }
+ }
+ if aider == nil {
+ t.Fatal("aider agent not found")
+ }
+ if !aider.Detect("aider v0.50") {
+ t.Error("aider should detect 'aider v0.50'")
+ }
+}
diff --git a/internal/tmuxedit/cursor_agent.go b/internal/tmuxedit/cursor_agent.go
new file mode 100644
index 0000000..1346d05
--- /dev/null
+++ b/internal/tmuxedit/cursor_agent.go
@@ -0,0 +1,58 @@
+package tmuxedit
+
+import (
+ "regexp"
+)
+
+// cursorAgent handles Cursor's distinctive box-drawing │ → prompt │ UI.
+// Cursor uses a text field (not vim), so clearing is done with End + bulk
+// backspace. Multi-line prompts are entered with Shift-Enter within the box.
+type cursorAgent struct{ baseAgent }
+
+// newCursorAgent returns a cursorAgent with the default configuration.
+// Detect by the box structure or "/ commands" footer. Checked first because
+// cursor panes often show model names like "Claude 4.5 Sonnet".
+func newCursorAgent() *cursorAgent {
+ return &cursorAgent{baseAgent{
+ name: "cursor",
+ displayName: "Cursor",
+ detectPattern: `(│\s*→|/ commands · @ files)`,
+ promptPat: `(?m)│\s*→?\s*(.+?)\s*│\s*$`,
+ stripPatterns: []string{"INSERT", "Add a follow-up", "ctrl+c to stop"},
+ clearFirst: true,
+ clearKeys: "End BSpace*200",
+ newlineKeys: "S-Enter",
+ submitKeys: "Enter",
+ }}
+}
+
+// ExtractPrompt extracts the prompt text from the last contiguous │...│ block
+// in the pane. This avoids picking up earlier command-review or dialog boxes
+// that also use box-drawing characters.
+func (c *cursorAgent) ExtractPrompt(paneContent string) string {
+ if c.promptPat == "" {
+ return ""
+ }
+ re, err := regexp.Compile(c.promptPat)
+ if err != nil {
+ return ""
+ }
+ allMatches := matchPromptLines(re, paneContent)
+ if len(allMatches) == 0 {
+ return ""
+ }
+ return joinLastContiguousBlock(allMatches, c.stripPatterns)
+}
+
+// ClearInput sends End + 200 backspaces to clear Cursor's text field.
+// Cursor's input is a standard text field, not vim.
+func (c *cursorAgent) ClearInput(paneID string) error {
+ if !c.clearFirst || c.clearKeys == "" {
+ return nil
+ }
+ if err := sendClearSequence(paneID, c.clearKeys); err != nil {
+ return err
+ }
+ sleepAfterClear()
+ return nil
+}
diff --git a/internal/tmuxedit/cursor_agent_test.go b/internal/tmuxedit/cursor_agent_test.go
new file mode 100644
index 0000000..28d7fe1
--- /dev/null
+++ b/internal/tmuxedit/cursor_agent_test.go
@@ -0,0 +1,140 @@
+package tmuxedit
+
+import (
+ "fmt"
+ "strings"
+ "testing"
+)
+
+func TestCursorAgent_ExtractPrompt(t *testing.T) {
+ agent := newCursorAgent()
+ tests := []struct {
+ name string
+ content string
+ want string
+ }{
+ {
+ name: "box with arrow",
+ content: "Cursor Agent\n │ → fix the bug INSERT │",
+ want: "fix the bug",
+ },
+ {
+ name: "box without arrow",
+ content: "Cursor Agent\n │ fix the bug │",
+ want: "fix the bug",
+ },
+ {
+ name: "strips follow-up placeholder",
+ content: "Cursor\n │ → Add a follow-up │",
+ want: "",
+ },
+ {
+ name: "multi-line prompt",
+ content: " │ → first line of prompt │\n │ second line here │\n │ third line end │",
+ want: "first line of prompt\nsecond line here\nthird line end",
+ },
+ {
+ name: "multi-line with noise",
+ content: " │ → fix the bug INSERT │\n │ also refactor tests │",
+ want: "fix the bug\nalso refactor tests",
+ },
+ {
+ name: "multi-box takes last box only",
+ content: " ┌──────────────┐\n" +
+ " │ $ git push │\n" +
+ " └──────────────┘\n" +
+ " ┌──────────────┐\n" +
+ " │ Run command? │\n" +
+ " │ → Yes (enter) │\n" +
+ " │ No (esc) │\n" +
+ " └──────────────┘\n" +
+ " ┌──────────────┐\n" +
+ " │ → hello world │\n" +
+ " └──────────────┘\n",
+ want: "hello world",
+ },
+ {
+ name: "multi-box multi-line prompt",
+ content: " ┌──────────────┐\n" +
+ " │ $ git push │\n" +
+ " └──────────────┘\n" +
+ " ┌──────────────┐\n" +
+ " │ → first line │\n" +
+ " │ second line │\n" +
+ " │ third line │\n" +
+ " └──────────────┘\n",
+ want: "first line\nsecond line\nthird line",
+ },
+ {
+ name: "no match",
+ content: "no prompt here",
+ want: "",
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := agent.ExtractPrompt(tt.content)
+ if got != tt.want {
+ t.Errorf("ExtractPrompt() = %q, want %q", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestCursorAgent_ClearInput(t *testing.T) {
+ noSleep(t)
+ var calls []string
+ oldSend := sendKeys
+ oldRepeat := sendRepeatedKey
+ defer func() {
+ sendKeys = oldSend
+ sendRepeatedKey = oldRepeat
+ }()
+ sendKeys = func(paneID string, keys ...string) error {
+ calls = append(calls, fmt.Sprintf("send:%s:%s", paneID, strings.Join(keys, ",")))
+ return nil
+ }
+ sendRepeatedKey = func(paneID, key string, count int) error {
+ calls = append(calls, fmt.Sprintf("repeat:%s:%s*%d", paneID, key, count))
+ return nil
+ }
+
+ agent := newCursorAgent()
+ err := agent.ClearInput("%5")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ // "End BSpace*200" should send End normally, then BSpace 200 times via -N
+ want := []string{
+ "send:%5:End",
+ "repeat:%5:BSpace*200",
+ }
+ 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 TestCursorAgent_Detect(t *testing.T) {
+ agent := newCursorAgent()
+ tests := []struct {
+ name string
+ content string
+ want bool
+ }{
+ {"box with arrow", "│ → type here │", true},
+ {"commands footer", "/ commands · @ files", true},
+ {"no match", "some text", false},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := agent.Detect(tt.content); got != tt.want {
+ t.Errorf("Detect() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
diff --git a/internal/tmuxedit/run.go b/internal/tmuxedit/run.go
index dde91fa..f81eb64 100644
--- a/internal/tmuxedit/run.go
+++ b/internal/tmuxedit/run.go
@@ -119,6 +119,8 @@ func dbg(format string, args ...any) {
}
// runWithConfig executes the edit workflow using the provided config.
+// It resolves the agent (by name or auto-detect), extracts the current
+// prompt, opens the editor popup, then clears and sends the result.
func runWithConfig(opts Options, cfg appconfig.App) error {
initDebugLog()
dbg("=== hexai-tmux-edit start ===")
@@ -137,29 +139,16 @@ func runWithConfig(opts Options, cfg appconfig.App) error {
return err
}
dbg("captured %d bytes from pane", len(content))
- // Log a few lines around the prompt
- for i, line := range strings.Split(content, "\n") {
- if strings.Contains(line, "│") || strings.Contains(line, "→") {
- dbg(" pane line %d: %q", i, line)
- }
- }
+ logPaneLines(content)
agents := resolveAgents(cfg.TmuxEditAgents)
agent := pickAgent(opts.Agent, content, agents)
- dbg("agent: name=%q detect=%q prompt=%q strip=%v clear=%v clearKeys=%q",
- agent.Name, agent.DetectPattern, agent.PromptPattern, agent.StripPatterns, agent.ClearFirst, agent.ClearKeys)
+ dbg("agent: name=%q", agent.Name())
- original := extractPrompt(content, agent)
+ original := agent.ExtractPrompt(content)
dbg("extractPrompt result: %q", original)
- popupW := cfg.TmuxEditPopupWidth
- if popupW == "" {
- popupW = "80%"
- }
- popupH := cfg.TmuxEditPopupHeight
- if popupH == "" {
- popupH = "80%"
- }
+ popupW, popupH := popupDimensions(cfg)
dbg("opening editor popup: w=%s h=%s initial=%q", popupW, popupH, original)
edited, err := openEditorPopup(original, popupW, popupH)
@@ -176,17 +165,45 @@ func runWithConfig(opts Options, cfg appconfig.App) error {
return nil
}
- dbg("sending to pane %q: %q", paneID, text)
- err = sendTextToPane(paneID, text, agent)
- if err != nil {
- dbg("sendTextToPane error: %v", err)
+ dbg("clearing and sending to pane %q: %q", paneID, text)
+ if err := agent.ClearInput(paneID); err != nil {
+ dbg("ClearInput error: %v", err)
+ return err
+ }
+ if err := agent.SendText(paneID, text); err != nil {
+ dbg("SendText error: %v", err)
+ return err
}
dbg("=== done ===")
- return err
+ return nil
+}
+
+// logPaneLines logs lines containing box-drawing or arrow characters for
+// debugging prompt detection.
+func logPaneLines(content string) {
+ for i, line := range strings.Split(content, "\n") {
+ if strings.Contains(line, "│") || strings.Contains(line, "→") {
+ dbg(" pane line %d: %q", i, line)
+ }
+ }
+}
+
+// popupDimensions returns the popup width and height from config, defaulting
+// to "80%" for both if not set.
+func popupDimensions(cfg appconfig.App) (string, string) {
+ w := cfg.TmuxEditPopupWidth
+ if w == "" {
+ w = "80%"
+ }
+ h := cfg.TmuxEditPopupHeight
+ if h == "" {
+ h = "80%"
+ }
+ return w, h
}
// pickAgent selects an agent by explicit name or auto-detection.
-func pickAgent(name, content string, agents []AgentConfig) AgentConfig {
+func pickAgent(name, content string, agents []Agent) Agent {
if name != "" {
return findAgentByName(name, agents)
}
diff --git a/internal/tmuxedit/run_test.go b/internal/tmuxedit/run_test.go
index 2766f6b..1b603e4 100644
--- a/internal/tmuxedit/run_test.go
+++ b/internal/tmuxedit/run_test.go
@@ -9,6 +9,7 @@ import (
)
func TestRunWithConfig_HappyPath(t *testing.T) {
+ noSleep(t)
// Save and restore all seams
oldCapture := capturePane
oldSendKeys := sendKeys
@@ -31,7 +32,7 @@ func TestRunWithConfig_HappyPath(t *testing.T) {
// Mock: capture pane content with Claude Code agent detected
capturePane = func(paneID string) (string, error) {
- return "claude code v1.0\n────\n❯ fix the bug\n────", nil
+ return "claude code v1.0\n──────\n❯ fix the bug\n──────", nil
}
// Mock: editor popup returns modified text
@@ -58,8 +59,7 @@ func TestRunWithConfig_HappyPath(t *testing.T) {
t.Fatalf("unexpected error: %v", err)
}
- // Should have sent: clear (C-u), then the full edited text (both lines)
- // since deduplicateText returns the complete text whenever anything changed.
+ // Should have sent: clear keys, then the full edited text (both lines)
if len(sent) < 2 {
t.Fatalf("expected at least 2 send calls (clear + text), got %d: %v", len(sent), sent)
}
@@ -74,6 +74,7 @@ func TestRunWithConfig_HappyPath(t *testing.T) {
}
func TestRunWithConfig_ExplicitAgent(t *testing.T) {
+ noSleep(t)
oldCapture := capturePane
oldSendKeys := sendKeys
oldEditorPopup := openEditorPopup
@@ -177,16 +178,16 @@ func TestRunWithConfig_CustomDimensions(t *testing.T) {
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)
+ 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)
+ if got.Name() != "amp" {
+ t.Errorf("pickAgent('', amp content) = %q, want amp", got.Name())
}
}
diff --git a/internal/tmuxedit/send.go b/internal/tmuxedit/send.go
index ea63057..7a6bce2 100644
--- a/internal/tmuxedit/send.go
+++ b/internal/tmuxedit/send.go
@@ -17,83 +17,6 @@ var sendKeys = func(paneID string, keys ...string) error {
return nil
}
-// deduplicateText compares the original (pre-filled) text with what the user
-// returned from the editor. Returns empty string if unchanged (no-op), or
-// the full edited text if anything changed. The caller is responsible for
-// clearing existing pane input before sending the result, so we always return
-// the complete text rather than stripping the original prefix.
-func deduplicateText(original, edited string) string {
- original = strings.TrimSpace(original)
- edited = strings.TrimSpace(edited)
- if edited == "" || edited == original {
- return ""
- }
- return edited
-}
-
-// sendTextToPane sends the given text to the target pane. It optionally
-// clears existing input first (using the agent's ClearKeys sequence), then
-// sends text line-by-line using the agent's NewlineKeys between lines.
-// ClearKeys is space-separated; tokens like "BSpace*200" repeat a key N times
-// via tmux send-keys -N. Example: "End BSpace*200" moves to end then
-// sends 200 backspaces to clear the entire prompt buffer.
-func sendTextToPane(paneID, text string, agent AgentConfig) error {
- if strings.TrimSpace(text) == "" {
- return nil
- }
- // Clear existing input using the key sequence (space-separated).
- // Each token is sent as a separate tmux send-keys call.
- // A short pause after clearing lets the TUI process all queued
- // keystrokes (e.g. 200 backspaces) before new text arrives.
- if agent.ClearFirst && agent.ClearKeys != "" {
- if err := sendClearSequence(paneID, agent.ClearKeys); err != nil {
- return err
- }
- sleepAfterClear()
- }
- // 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
-}
-
-// sleepAfterClear pauses to let the TUI drain queued keystrokes (like bulk
-// backspaces) before new text is sent. Override in tests to avoid delays.
-var sleepAfterClear = func() { time.Sleep(300 * time.Millisecond) }
-
-// sendClearSequence parses a space-separated key sequence and sends each
-// token individually. Tokens with a "*N" suffix (e.g. "BSpace*200") are
-// sent N times using tmux send-keys -N for efficient bulk repeats.
-func sendClearSequence(paneID, clearKeys string) error {
- for _, token := range strings.Fields(clearKeys) {
- key, count := parseKeyRepeat(token)
- if count > 1 {
- if err := sendRepeatedKey(paneID, key, count); err != nil {
- return fmt.Errorf("clear key %q*%d failed: %w", key, count, err)
- }
- } else {
- if err := sendKeys(paneID, key); err != nil {
- return fmt.Errorf("clear key %q failed: %w", key, err)
- }
- }
- }
- return nil
-}
-
// sendRepeatedKey is the seam for `tmux send-keys -N <count>`. Override in
// tests. Uses -N for efficient bulk key repeats (e.g. 200 backspaces).
var sendRepeatedKey = func(paneID, key string, count int) error {
@@ -105,16 +28,20 @@ var sendRepeatedKey = func(paneID, key string, count int) error {
return nil
}
-// parseKeyRepeat splits "Key*N" into (Key, N). Returns (token, 1) if no
-// repeat suffix is present or the suffix is invalid.
-func parseKeyRepeat(token string) (string, int) {
- idx := strings.LastIndex(token, "*")
- if idx < 1 || idx >= len(token)-1 {
- return token, 1
- }
- n, err := strconv.Atoi(token[idx+1:])
- if err != nil || n < 1 {
- return token, 1
+// sleepAfterClear pauses to let the TUI drain queued keystrokes (like bulk
+// backspaces) before new text is sent. Override in tests to avoid delays.
+var sleepAfterClear = func() { time.Sleep(300 * time.Millisecond) }
+
+// deduplicateText compares the original (pre-filled) text with what the user
+// returned from the editor. Returns empty string if unchanged (no-op), or
+// the full edited text if anything changed. The caller is responsible for
+// clearing existing pane input before sending the result, so we always return
+// the complete text rather than stripping the original prefix.
+func deduplicateText(original, edited string) string {
+ original = strings.TrimSpace(original)
+ edited = strings.TrimSpace(edited)
+ if edited == "" || edited == original {
+ return ""
}
- return token[:idx], n
+ return edited
}
diff --git a/internal/tmuxedit/send_test.go b/internal/tmuxedit/send_test.go
index e458282..3722d1a 100644
--- a/internal/tmuxedit/send_test.go
+++ b/internal/tmuxedit/send_test.go
@@ -6,6 +6,14 @@ import (
"testing"
)
+// noSleep disables the post-clear sleep in tests and restores it on cleanup.
+func noSleep(t *testing.T) {
+ t.Helper()
+ old := sleepAfterClear
+ sleepAfterClear = func() {}
+ t.Cleanup(func() { sleepAfterClear = old })
+}
+
func TestDeduplicateText(t *testing.T) {
tests := []struct {
name string
@@ -33,16 +41,7 @@ func TestDeduplicateText(t *testing.T) {
}
}
-// noSleep disables the post-clear sleep in tests and restores it on cleanup.
-func noSleep(t *testing.T) {
- t.Helper()
- old := sleepAfterClear
- sleepAfterClear = func() {}
- t.Cleanup(func() { sleepAfterClear = old })
-}
-
-func TestSendTextToPane_SingleLine(t *testing.T) {
- noSleep(t)
+func TestSendLines_SingleLine(t *testing.T) {
var calls []string
oldSend := sendKeys
defer func() { sendKeys = oldSend }()
@@ -50,24 +49,20 @@ func TestSendTextToPane_SingleLine(t *testing.T) {
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)
+
+ err := sendLines("%5", "hello", "S-Enter")
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 len(calls) != 1 {
+ t.Fatalf("got %d calls, want 1: %v", len(calls), calls)
}
- if calls[1] != "send:%5:hello" {
- t.Errorf("call[1] = %q, want text", calls[1])
+ if calls[0] != "send:%5:hello" {
+ t.Errorf("call[0] = %q, want text", calls[0])
}
}
-func TestSendTextToPane_MultiLine(t *testing.T) {
+func TestSendLines_MultiLine(t *testing.T) {
var calls []string
oldSend := sendKeys
defer func() { sendKeys = oldSend }()
@@ -75,12 +70,11 @@ func TestSendTextToPane_MultiLine(t *testing.T) {
calls = append(calls, strings.Join(keys, ","))
return nil
}
- agent := AgentConfig{NewlineKeys: "S-Enter"}
- err := sendTextToPane("%1", "line1\nline2\nline3", agent)
+
+ err := sendLines("%1", "line1\nline2\nline3", "S-Enter")
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)
@@ -92,7 +86,7 @@ func TestSendTextToPane_MultiLine(t *testing.T) {
}
}
-func TestSendTextToPane_NoClear(t *testing.T) {
+func TestSendLines_FallbackNewline(t *testing.T) {
var calls []string
oldSend := sendKeys
defer func() { sendKeys = oldSend }()
@@ -100,146 +94,29 @@ func TestSendTextToPane_NoClear(t *testing.T) {
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{})
+ // Empty newlineKeys should fallback to "Enter"
+ err := sendLines("%1", "a\nb", "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
-}
-
-func TestSendTextToPane_ClearError(t *testing.T) {
- noSleep(t)
- oldSend := sendKeys
- defer func() { sendKeys = oldSend }()
- sendKeys = func(paneID string, keys ...string) error {
- return fmt.Errorf("tmux error")
+ if len(calls) != 3 {
+ t.Fatalf("got %d calls, want 3: %v", len(calls), calls)
}
- agent := AgentConfig{ClearFirst: true, ClearKeys: "C-u"}
- err := sendTextToPane("%1", "hello", agent)
- if err == nil {
- t.Fatal("expected error on clear failure")
+ if calls[1] != "Enter" {
+ t.Errorf("newline key = %q, want Enter (fallback)", calls[1])
}
}
-func TestSendTextToPane_SendError(t *testing.T) {
- noSleep(t)
- callCount := 0
+func TestSendLines_Error(t *testing.T) {
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
+ sendKeys = func(string, ...string) error {
+ return fmt.Errorf("send failed")
}
- agent := AgentConfig{ClearFirst: true, ClearKeys: "C-u"}
- err := sendTextToPane("%1", "hello", agent)
+
+ err := sendLines("%1", "hello", "Enter")
if err == nil {
t.Fatal("expected error on send failure")
}
}
-
-func TestSendTextToPane_BulkClear(t *testing.T) {
- noSleep(t)
- var calls []string
- oldSend := sendKeys
- oldRepeat := sendRepeatedKey
- defer func() {
- sendKeys = oldSend
- sendRepeatedKey = oldRepeat
- }()
- sendKeys = func(paneID string, keys ...string) error {
- calls = append(calls, fmt.Sprintf("send:%s:%s", paneID, strings.Join(keys, ",")))
- return nil
- }
- sendRepeatedKey = func(paneID, key string, count int) error {
- calls = append(calls, fmt.Sprintf("repeat:%s:%s*%d", paneID, key, count))
- return nil
- }
- // "End BSpace*200" should send End normally, then BSpace 200 times via -N
- agent := AgentConfig{ClearFirst: true, ClearKeys: "End BSpace*200", NewlineKeys: "S-Enter"}
- err := sendTextToPane("%5", "new text", agent)
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
- want := []string{
- "send:%5:End",
- "repeat:%5:BSpace*200",
- "send:%5:new text",
- }
- 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 TestParseKeyRepeat(t *testing.T) {
- tests := []struct {
- token string
- wantKey string
- wantCount int
- }{
- {"BSpace*200", "BSpace", 200},
- {"End", "End", 1},
- {"C-u", "C-u", 1},
- {"BSpace*1", "BSpace", 1},
- {"BSpace*0", "BSpace*0", 1}, // invalid count
- {"BSpace*abc", "BSpace*abc", 1}, // non-numeric
- {"*200", "*200", 1}, // no key name
- {"x*3", "x", 3},
- }
- for _, tt := range tests {
- t.Run(tt.token, func(t *testing.T) {
- key, count := parseKeyRepeat(tt.token)
- if key != tt.wantKey || count != tt.wantCount {
- t.Errorf("parseKeyRepeat(%q) = (%q, %d), want (%q, %d)",
- tt.token, key, count, tt.wantKey, tt.wantCount)
- }
- })
- }
-}
-
-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])
- }
-}