summaryrefslogtreecommitdiff
path: root/internal/tmuxedit/agent.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/tmuxedit/agent.go')
-rw-r--r--internal/tmuxedit/agent.go310
1 files changed, 90 insertions, 220 deletions
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)
-}