diff options
Diffstat (limited to 'internal/tmuxedit/agent.go')
| -rw-r--r-- | internal/tmuxedit/agent.go | 310 |
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) -} |
