summaryrefslogtreecommitdiff
path: root/internal/tmuxedit
diff options
context:
space:
mode:
Diffstat (limited to 'internal/tmuxedit')
-rw-r--r--internal/tmuxedit/agent.go4
-rw-r--r--internal/tmuxedit/agent_test.go6
-rw-r--r--internal/tmuxedit/agentutil.go8
-rw-r--r--internal/tmuxedit/claude_agent.go85
-rw-r--r--internal/tmuxedit/claude_agent_test.go188
-rw-r--r--internal/tmuxedit/config_agent.go3
-rw-r--r--internal/tmuxedit/config_agent_test.go35
-rw-r--r--internal/tmuxedit/run_test.go9
8 files changed, 33 insertions, 305 deletions
diff --git a/internal/tmuxedit/agent.go b/internal/tmuxedit/agent.go
index 313907a..1ae8f13 100644
--- a/internal/tmuxedit/agent.go
+++ b/internal/tmuxedit/agent.go
@@ -27,7 +27,7 @@ type Configurable interface {
}
// baseAgent holds configurable fields and provides default implementations
-// of the Agent interface. Specialized agents (cursor, claude) embed baseAgent
+// of the Agent interface. Specialized agents (e.g. cursor) embed baseAgent
// and override methods where behavior differs from the defaults.
type baseAgent struct {
name string
@@ -45,7 +45,7 @@ type baseAgent struct {
// Base returns a pointer to the baseAgent for config merging.
func (b *baseAgent) Base() *baseAgent { return b }
-// Name returns the agent's short identifier (e.g. "claude", "cursor").
+// Name returns the agent's short identifier (e.g. "cursor", "amp").
func (b *baseAgent) Name() string { return b.name }
// DisplayName returns the agent's human-readable name.
diff --git a/internal/tmuxedit/agent_test.go b/internal/tmuxedit/agent_test.go
index 8bd1ad4..1debfc4 100644
--- a/internal/tmuxedit/agent_test.go
+++ b/internal/tmuxedit/agent_test.go
@@ -13,10 +13,8 @@ func TestDetectAgent(t *testing.T) {
content string
want string
}{
- {"claude code prompt", "────\n❯ hello world\n────", "claude"},
- {"claude code banner", "claude code v1.2\n❯ ", "claude"},
- {"claude from anthropic", "Powered by Anthropic\n❯ ", "claude"},
{"cursor box ui", "│ → type here │\n/ commands · @ files", "cursor"},
+ // Cursor panes often show Claude model names; cursor's box UI must be detected first
{"cursor not false claude", "Claude 4.5 Sonnet\n│ → test │\n/ commands · @ files", "cursor"},
{"amp from banner", "Amp by Sourcegraph\n> ", "amp"},
{"aider from banner", "aider v0.50\n> /help", "aider"},
@@ -39,8 +37,6 @@ func TestFindAgentByName(t *testing.T) {
name string
want string
}{
- {"claude", "claude"},
- {"Claude", "claude"},
{"CURSOR", "cursor"},
{"amp", "amp"},
{"nonexistent", "generic"},
diff --git a/internal/tmuxedit/agentutil.go b/internal/tmuxedit/agentutil.go
index 18ece9b..67351d3 100644
--- a/internal/tmuxedit/agentutil.go
+++ b/internal/tmuxedit/agentutil.go
@@ -65,9 +65,9 @@ func joinLastContiguousBlock(matches []promptMatch, strips []string) string {
}
// 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.
+// the section delimiter pattern. This isolates the prompt area 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
@@ -118,7 +118,7 @@ func sendClearSequence(paneID, clearKeys string) error {
return fmt.Errorf("clear key %q failed: %w", key, err)
}
}
- // Add delay after Escape to let Vim/Claude exit INSERT mode
+ // Add delay after Escape to let Vim-based agents exit INSERT mode
if key == "Escape" {
time.Sleep(150 * time.Millisecond)
}
diff --git a/internal/tmuxedit/claude_agent.go b/internal/tmuxedit/claude_agent.go
deleted file mode 100644
index b84c77e..0000000
--- a/internal/tmuxedit/claude_agent.go
+++ /dev/null
@@ -1,85 +0,0 @@
-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: "C-a C-k",
- 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
deleted file mode 100644
index d8a68d9..0000000
--- a/internal/tmuxedit/claude_agent_test.go
+++ /dev/null
@@ -1,188 +0,0 @@
-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)
- }
- // "C-a C-k" (Emacs/readline style) should send each as separate send-keys call
- want := []string{
- "send:%3:C-a",
- "send:%3:C-k",
- }
- 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_ExtractPrompt_EmptyPattern(t *testing.T) {
- agent := &claudeAgent{baseAgent{promptPat: "", sectionPat: `^─{5,}`}}
- got := agent.ExtractPrompt("──────\n❯ hello\n──────")
- if got != "" {
- t.Errorf("expected empty for empty pattern, got %q", got)
- }
-}
-
-func TestClaudeAgent_ExtractPrompt_InvalidRegex(t *testing.T) {
- agent := &claudeAgent{baseAgent{promptPat: "[invalid", sectionPat: `^─{5,}`}}
- got := agent.ExtractPrompt("──────\n❯ hello\n──────")
- if got != "" {
- t.Errorf("expected empty for invalid regex, got %q", got)
- }
-}
-
-func TestClaudeAgent_ExtractPrompt_ContinuationBreaksOnEmpty(t *testing.T) {
- agent := newClaudeAgent()
- // Empty line between prompt blocks should break continuation
- content := "──────────────\n" +
- "❯ first line\n" +
- " continued\n" +
- "\n" +
- "unrelated text\n" +
- "──────────────"
- got := agent.ExtractPrompt(content)
- want := "first line\ncontinued"
- if got != want {
- t.Errorf("ExtractPrompt() = %q, want %q", got, want)
- }
-}
-
-func TestClaudeAgent_ClearInput_Disabled(t *testing.T) {
- agent := &claudeAgent{baseAgent{clearFirst: false, clearKeys: "C-a C-k"}}
- err := agent.ClearInput("%1")
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
-}
-
-func TestClaudeAgent_ClearInput_EmptyKeys(t *testing.T) {
- agent := &claudeAgent{baseAgent{clearFirst: true, clearKeys: ""}}
- err := agent.ClearInput("%1")
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
-}
-
-func TestClaudeAgent_ClearInput_Error(t *testing.T) {
- noSleep(t)
- oldSend := sendKeys
- defer func() { sendKeys = oldSend }()
- sendKeys = func(string, ...string) error {
- return fmt.Errorf("send failed")
- }
-
- agent := newClaudeAgent()
- err := agent.ClearInput("%1")
- if err == nil {
- t.Fatal("expected error from sendClearSequence failure")
- }
-}
-
-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
index e5268fa..0c52c3d 100644
--- a/internal/tmuxedit/config_agent.go
+++ b/internal/tmuxedit/config_agent.go
@@ -15,10 +15,11 @@ type configAgent struct{ baseAgent }
// 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.
+// Claude Code is not included here: it now supports opening the prompt
+// in an external editor natively via Ctrl+G (like OpenAI Codex CLI).
func builtinAgents() []Agent {
return []Agent{
newCursorAgent(),
- newClaudeAgent(),
&configAgent{baseAgent{
name: "amp",
displayName: "Amp",
diff --git a/internal/tmuxedit/config_agent_test.go b/internal/tmuxedit/config_agent_test.go
index d7ad649..666525d 100644
--- a/internal/tmuxedit/config_agent_test.go
+++ b/internal/tmuxedit/config_agent_test.go
@@ -9,29 +9,31 @@ import (
func boolP(b bool) *bool { return &b }
func TestResolveAgents_MergeOverride(t *testing.T) {
+ // Override the built-in "amp" agent to verify config merging preserves
+ // builtin fields (detectPattern) while applying user overrides (DisplayName, ClearFirst).
cfgAgents := []appconfig.TmuxEditAgentCfg{
{
- Name: "claude",
- DisplayName: "My Claude",
+ Name: "amp",
+ DisplayName: "My Amp",
ClearFirst: boolP(false),
},
}
agents := resolveAgents(cfgAgents)
- var claude Agent
+ var amp Agent
for _, a := range agents {
- if a.Name() == "claude" {
- claude = a
+ if a.Name() == "amp" {
+ amp = a
break
}
}
- if claude == nil {
- t.Fatal("claude agent not found")
+ if amp == nil {
+ t.Fatal("amp agent not found")
}
- if claude.DisplayName() != "My Claude" {
- t.Errorf("DisplayName = %q, want My Claude", claude.DisplayName())
+ if amp.DisplayName() != "My Amp" {
+ t.Errorf("DisplayName = %q, want My Amp", amp.DisplayName())
}
// ClearInput should be no-op after override to false
- c := claude.(Configurable)
+ c := amp.(Configurable)
if c.Base().clearFirst {
t.Error("clearFirst should be false after override")
}
@@ -42,11 +44,12 @@ func TestResolveAgents_MergeOverride(t *testing.T) {
}
func TestResolveAgents_MergeAllFields(t *testing.T) {
+ // Override the built-in "aider" agent with all fields to verify full merging.
cfgAgents := []appconfig.TmuxEditAgentCfg{
{
- Name: "claude",
- DisplayName: "Custom Claude",
- DetectPattern: "(?i)custom-claude",
+ Name: "aider",
+ DisplayName: "Custom Aider",
+ DetectPattern: "(?i)custom-aider",
PromptPattern: `>\s+(.*)$`,
StripPatterns: []string{"NOISE"},
ClearFirst: boolP(true),
@@ -58,17 +61,17 @@ func TestResolveAgents_MergeAllFields(t *testing.T) {
agents := resolveAgents(cfgAgents)
var a Agent
for _, ag := range agents {
- if ag.Name() == "claude" {
+ if ag.Name() == "aider" {
a = ag
break
}
}
if a == nil {
- t.Fatal("claude agent not found")
+ t.Fatal("aider agent not found")
}
c := a.(Configurable)
base := c.Base()
- if base.detectPattern != "(?i)custom-claude" {
+ if base.detectPattern != "(?i)custom-aider" {
t.Errorf("detectPattern = %q", base.detectPattern)
}
if base.promptPat != `>\s+(.*)$` {
diff --git a/internal/tmuxedit/run_test.go b/internal/tmuxedit/run_test.go
index ff36e4c..e8ca6c1 100644
--- a/internal/tmuxedit/run_test.go
+++ b/internal/tmuxedit/run_test.go
@@ -31,9 +31,9 @@ func TestRunWithConfig_HappyPath(t *testing.T) {
return nil, nil
}
- // Mock: capture pane content with Claude Code agent detected
+ // Mock: capture pane content with Aider agent detected; aider uses "> prompt" pattern
capturePane = func(paneID string) (string, error) {
- return "claude code v1.0\n──────\n❯ fix the bug\n──────", nil
+ return "aider v0.50\n> fix the bug", nil
}
// Mock: editor popup returns modified text
@@ -125,7 +125,7 @@ func TestRunWithConfig_EditorEmpty(t *testing.T) {
return []byte("%1"), nil
}
capturePane = func(string) (string, error) {
- return "claude code\n❯ ", nil
+ return "aider v0.50\n> ", nil
}
openEditorPopup = func(string, string, string) (string, error) {
return "", nil // user saved empty file
@@ -355,8 +355,9 @@ func TestRunWithConfig_ClearInputError(t *testing.T) {
runCommand = func(name string, args ...string) ([]byte, error) {
return []byte("%1"), nil
}
+ // Use Aider (clearFirst=true, clearKeys="C-u") so ClearInput is exercised
capturePane = func(string) (string, error) {
- return "claude code v1.0\n──────\n❯ fix the bug\n──────", nil
+ return "aider v0.50\n> fix the bug", nil
}
openEditorPopup = func(string, string, string) (string, error) {
return "new text", nil