diff options
| author | Paul Buetow <paul@buetow.org> | 2026-02-08 16:31:40 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-02-08 16:31:40 +0200 |
| commit | c802ba5803de1a53749bb5c4ecbc0159fceb385f (patch) | |
| tree | 02e612286f36bc6c65563bc33cf53639817d2db1 | |
| parent | 887d7bc186db90c3903851b0f1db2d24df5d7a7b (diff) | |
refactor tmuxedit to Agent interface with cursor/claude/config implementations
Replace monolithic AgentConfig struct with an Agent interface backed by
baseAgent defaults and separate implementations for cursor (box-drawing
extraction, bulk backspace clearing) and claude (section-scoped extraction
with continuation lines, vim clearing). Simple agents (amp, aider) and
user-defined agents use configAgent with baseAgent defaults.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
| -rw-r--r-- | docs/tmux.md | 4 | ||||
| -rw-r--r-- | internal/appconfig/config.go | 57 | ||||
| -rw-r--r-- | internal/tmuxedit/agent.go | 310 | ||||
| -rw-r--r-- | internal/tmuxedit/agent_test.go | 284 | ||||
| -rw-r--r-- | internal/tmuxedit/agentutil.go | 160 | ||||
| -rw-r--r-- | internal/tmuxedit/agentutil_test.go | 206 | ||||
| -rw-r--r-- | internal/tmuxedit/claude_agent.go | 85 | ||||
| -rw-r--r-- | internal/tmuxedit/claude_agent_test.go | 129 | ||||
| -rw-r--r-- | internal/tmuxedit/config_agent.go | 134 | ||||
| -rw-r--r-- | internal/tmuxedit/config_agent_test.go | 178 | ||||
| -rw-r--r-- | internal/tmuxedit/cursor_agent.go | 58 | ||||
| -rw-r--r-- | internal/tmuxedit/cursor_agent_test.go | 140 | ||||
| -rw-r--r-- | internal/tmuxedit/run.go | 63 | ||||
| -rw-r--r-- | internal/tmuxedit/run_test.go | 15 | ||||
| -rw-r--r-- | internal/tmuxedit/send.go | 103 | ||||
| -rw-r--r-- | internal/tmuxedit/send_test.go | 183 |
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 |
