diff options
| -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 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]) - } -} |
