summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-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
15 files changed, 1350 insertions, 755 deletions
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: "─