diff options
Diffstat (limited to 'internal/tmuxedit')
| -rw-r--r-- | internal/tmuxedit/agent.go | 4 | ||||
| -rw-r--r-- | internal/tmuxedit/agent_test.go | 6 | ||||
| -rw-r--r-- | internal/tmuxedit/agentutil.go | 8 | ||||
| -rw-r--r-- | internal/tmuxedit/claude_agent.go | 85 | ||||
| -rw-r--r-- | internal/tmuxedit/claude_agent_test.go | 188 | ||||
| -rw-r--r-- | internal/tmuxedit/config_agent.go | 3 | ||||
| -rw-r--r-- | internal/tmuxedit/config_agent_test.go | 35 | ||||
| -rw-r--r-- | internal/tmuxedit/run_test.go | 9 |
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 |
