diff options
| author | Paul Buetow <paul@buetow.org> | 2026-07-02 09:38:04 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-07-02 09:38:04 +0300 |
| commit | a68228bfa12f4d8a51fe53e244fcd2e66c1ef692 (patch) | |
| tree | 94e257b21b93419fef655c9813f68190204c3d0d /internal/tmuxedit | |
| parent | 9f0e96ce62339ddefa8771891e0864ede9af5064 (diff) | |
Remove hexai-tmux-edit popup editor featurev0.42.0
The tmux popup editor and its per-agent detection (Cursor/Amp/Aider)
added maintenance surface without enough use to justify it; Codex and
Claude Code already support external-editor mode natively via Ctrl+G.
Drops internal/tmuxedit, cmd/hexai-tmux-edit, the [tmux_edit] config
schema, the Mage build target, and all related docs/README mentions.
Bump version to 0.42.0.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Diffstat (limited to 'internal/tmuxedit')
| -rw-r--r-- | internal/tmuxedit/agent.go | 149 | ||||
| -rw-r--r-- | internal/tmuxedit/agent_test.go | 157 | ||||
| -rw-r--r-- | internal/tmuxedit/agentutil.go | 183 | ||||
| -rw-r--r-- | internal/tmuxedit/agentutil_test.go | 265 | ||||
| -rw-r--r-- | internal/tmuxedit/capture.go | 23 | ||||
| -rw-r--r-- | internal/tmuxedit/capture_test.go | 45 | ||||
| -rw-r--r-- | internal/tmuxedit/config_agent.go | 135 | ||||
| -rw-r--r-- | internal/tmuxedit/config_agent_test.go | 182 | ||||
| -rw-r--r-- | internal/tmuxedit/cursor_agent.go | 58 | ||||
| -rw-r--r-- | internal/tmuxedit/cursor_agent_test.go | 183 | ||||
| -rw-r--r-- | internal/tmuxedit/history.go | 111 | ||||
| -rw-r--r-- | internal/tmuxedit/history_test.go | 326 | ||||
| -rw-r--r-- | internal/tmuxedit/pane.go | 63 | ||||
| -rw-r--r-- | internal/tmuxedit/pane_test.go | 73 | ||||
| -rw-r--r-- | internal/tmuxedit/run.go | 268 | ||||
| -rw-r--r-- | internal/tmuxedit/run_test.go | 340 | ||||
| -rw-r--r-- | internal/tmuxedit/send.go | 66 | ||||
| -rw-r--r-- | internal/tmuxedit/send_test.go | 110 |
18 files changed, 0 insertions, 2737 deletions
diff --git a/internal/tmuxedit/agent.go b/internal/tmuxedit/agent.go deleted file mode 100644 index 42213ce..0000000 --- a/internal/tmuxedit/agent.go +++ /dev/null @@ -1,149 +0,0 @@ -// Package tmuxedit implements a tmux popup editor for composing AI agent prompts. -// agent.go defines the Agent interface, the baseAgent struct with default -// implementations, and agent detection/resolution helpers. -package tmuxedit - -import ( - "regexp" - "strings" -) - -// 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 -} - -// Configurable provides access to a baseAgent's fields for config merging. -// Agent implementations that embed baseAgent automatically satisfy this. -type Configurable interface { - Base() *baseAgent -} - -// baseAgent holds configurable fields and provides default implementations -// 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 - 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 - deps tmuxEditDeps -} - -// 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. "cursor", "amp"). -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 - } - re, err := regexp.Compile(b.detectPattern) - if err != nil { - return false - } - 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 "" - } - re, err := regexp.Compile(b.promptPat) - if err != nil { - return "" - } - scoped := b.sectionPat != "" - content := scopeToLastSection(paneContent, b.sectionPat) - allMatches := matchPromptLines(re, content) - if len(allMatches) == 0 { - return "" - } - if scoped { - return joinAllMatches(allMatches, b.stripPatterns) - } - return joinLastContiguousBlock(allMatches, b.stripPatterns) -} - -// 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 err := b.deps.sendClearSequence(paneID, b.clearKeys); err != nil { - return err - } - b.deps.sleep() - 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 b.deps.sendLines(paneID, text, b.newlineKeys) -} - -func withAgentDeps(agents []Agent, deps tmuxEditDeps) []Agent { - for _, agent := range agents { - withAgentDep(agent, deps) - } - return agents -} - -func withAgentDep(agent Agent, deps tmuxEditDeps) Agent { - if c, ok := agent.(Configurable); ok { - c.Base().deps = deps - } - return agent -} - -// detectAgent tries each agent's Detect method against pane content. -// First match wins. Returns genericAgent() if no agent matches. -func detectAgent(paneContent string, agents []Agent) Agent { - for _, a := range agents { - if a.Detect(paneContent) { - return a - } - } - return genericAgent() -} - -// findAgentByName returns the agent with the given name (case-insensitive), -// falling back to genericAgent() if not found. -func findAgentByName(name string, agents []Agent) Agent { - for _, a := range agents { - if strings.EqualFold(a.Name(), name) { - return a - } - } - return genericAgent() -} diff --git a/internal/tmuxedit/agent_test.go b/internal/tmuxedit/agent_test.go deleted file mode 100644 index ff782d4..0000000 --- a/internal/tmuxedit/agent_test.go +++ /dev/null @@ -1,157 +0,0 @@ -package tmuxedit - -import ( - "fmt" - "strings" - "testing" -) - -func TestDetectAgent(t *testing.T) { - agents := builtinAgents() - tests := []struct { - name string - content string - want string - }{ - {"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"}, - {"no match", "some random terminal output\n$ ", "generic"}, - {"empty content", "", "generic"}, - } - 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) - } - }) - } -} - -func TestFindAgentByName(t *testing.T) { - agents := builtinAgents() - tests := []struct { - name string - want string - }{ - {"CURSOR", "cursor"}, - {"amp", "amp"}, - {"nonexistent", "generic"}, - } - 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 TestDetectAgent_InvalidRegex(t *testing.T) { - agents := []Agent{ - &configAgent{baseAgent{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 TestGenericAgent(t *testing.T) { - g := genericAgent() - if g.Name() != "generic" { - t.Errorf("Name = %q, want generic", g.Name()) - } -} - -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 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 TestBaseAgent_ClearInput_EmptyKeys(t *testing.T) { - // clearFirst=true but no clearKeys should be a no-op - b := &baseAgent{clearFirst: true, clearKeys: ""} - err := b.ClearInput("%1") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestBaseAgent_ClearInput_Enabled(t *testing.T) { - var calls []string - deps := noSleepDeps() - deps.sendKeys = func(paneID string, keys ...string) error { - calls = append(calls, fmt.Sprintf("send:%s:%s", paneID, strings.Join(keys, ","))) - return nil - } - - b := &baseAgent{clearFirst: true, clearKeys: "C-u", deps: deps} - err := b.ClearInput("%2") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(calls) != 1 || calls[0] != "send:%2:C-u" { - t.Errorf("expected single C-u send call, got %v", calls) - } -} - -func TestBaseAgent_ClearInput_Error(t *testing.T) { - deps := noSleepDeps() - deps.sendKeys = func(string, ...string) error { - return fmt.Errorf("send failed") - } - - b := &baseAgent{clearFirst: true, clearKeys: "C-u", deps: deps} - err := b.ClearInput("%1") - if err == nil { - t.Fatal("expected error from sendClearSequence failure") - } -} - -func TestBaseAgent_ExtractPrompt_NoPattern(t *testing.T) { - b := &baseAgent{} - got := b.ExtractPrompt("some content") - if got != "" { - t.Errorf("expected empty, got %q", got) - } -} - -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 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 deleted file mode 100644 index bf1a723..0000000 --- a/internal/tmuxedit/agentutil.go +++ /dev/null @@ -1,183 +0,0 @@ -// 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" - "time" -) - -const escapeKeyDelay = 150 * time.Millisecond - -// 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 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 { - return tmuxEditDeps{}.sendClearSequence(paneID, clearKeys) -} - -func (d tmuxEditDeps) sendClearSequence(paneID, clearKeys string) error { - for _, token := range strings.Fields(clearKeys) { - key, count := parseKeyRepeat(token) - if count > 1 { - if err := d.sendRepeated(paneID, key, count); err != nil { - return fmt.Errorf("clear key %q*%d failed: %w", key, count, err) - } - } else { - if err := d.send(paneID, key); err != nil { - return fmt.Errorf("clear key %q failed: %w", key, err) - } - } - // Add delay after Escape to let Vim-based agents exit INSERT mode - if key == "Escape" { - d.sleepEscape() - } - } - return nil -} - -func (d tmuxEditDeps) sleepEscape() { - if d.sleepAfterEscape != nil { - d.sleepAfterEscape() - return - } - time.Sleep(escapeKeyDelay) -} - -// 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 { - return tmuxEditDeps{}.sendLines(paneID, text, newlineKeys) -} - -func (d tmuxEditDeps) sendLines(paneID, text, newlineKeys string) error { - lines := strings.Split(text, "\n") - for i, line := range lines { - if err := d.send(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 := d.send(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 deleted file mode 100644 index 3cafb3b..0000000 --- a/internal/tmuxedit/agentutil_test.go +++ /dev/null @@ -1,265 +0,0 @@ -package tmuxedit - -import ( - "fmt" - "regexp" - "strings" - "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) - } - }) - } -} - -func TestSendClearSequence_EscapeKey(t *testing.T) { - var calls []string - var escapeSleeps int - deps := tmuxEditDeps{sendKeys: func(paneID string, keys ...string) error { - calls = append(calls, strings.Join(keys, ",")) - return nil - }, sleepAfterEscape: func() { - escapeSleeps++ - }} - - // sendClearSequence with "Escape" should succeed and send the key. - err := deps.sendClearSequence("%1", "Escape C-k") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - want := []string{"Escape", "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) - } - } - if escapeSleeps != 1 { - t.Fatalf("escape sleeps = %d, want 1", escapeSleeps) - } -} - -func TestSendClearSequence_SingleKeyError(t *testing.T) { - deps := tmuxEditDeps{sendKeys: func(string, ...string) error { - return fmt.Errorf("send failed") - }} - - err := deps.sendClearSequence("%1", "C-u") - if err == nil { - t.Fatal("expected error from sendKeys failure") - } - if !strings.Contains(err.Error(), "clear key") { - t.Errorf("error should mention 'clear key', got: %v", err) - } -} - -func TestSendClearSequence_RepeatedKeyError(t *testing.T) { - deps := tmuxEditDeps{sendRepeatedKey: func(string, string, int) error { - return fmt.Errorf("repeat failed") - }} - - err := deps.sendClearSequence("%1", "BSpace*200") - if err == nil { - t.Fatal("expected error from sendRepeatedKey failure") - } - if !strings.Contains(err.Error(), "clear key") { - t.Errorf("error should mention 'clear key', got: %v", err) - } -} - -// 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/capture.go b/internal/tmuxedit/capture.go deleted file mode 100644 index f4e3a67..0000000 --- a/internal/tmuxedit/capture.go +++ /dev/null @@ -1,23 +0,0 @@ -package tmuxedit - -import ( - "fmt" - "strings" -) - -func capturePane(paneID string) (string, error) { - return tmuxEditDeps{}.capture(paneID) -} - -// capture retrieves the visible content of a tmux pane via `tmux capture-pane -// -p -t <paneID>`. The -p flag prints to stdout instead of to a paste buffer. -func (d tmuxEditDeps) capture(paneID string) (string, error) { - if d.capturePane != nil { - return d.capturePane(paneID) - } - out, err := d.command("tmux", "capture-pane", "-p", "-t", paneID) - if err != nil { - return "", fmt.Errorf("capture-pane failed for %s: %w", paneID, err) - } - return strings.TrimRight(string(out), "\n"), nil -} diff --git a/internal/tmuxedit/capture_test.go b/internal/tmuxedit/capture_test.go deleted file mode 100644 index c5a6605..0000000 --- a/internal/tmuxedit/capture_test.go +++ /dev/null @@ -1,45 +0,0 @@ -package tmuxedit - -import ( - "fmt" - "testing" -) - -func TestCapturePane_Success(t *testing.T) { - deps := tmuxEditDeps{runCommand: func(name string, args ...string) ([]byte, error) { - if name == "tmux" && len(args) >= 3 && args[0] == "capture-pane" { - return []byte("Claude Code v1.0\n> hello world\n"), nil - } - return nil, fmt.Errorf("unexpected: %s %v", name, args) - }} - got, err := deps.capture("%5") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if got != "Claude Code v1.0\n> hello world" { - t.Errorf("got %q, want trimmed content", got) - } -} - -func TestCapturePane_Error(t *testing.T) { - deps := tmuxEditDeps{runCommand: func(string, ...string) ([]byte, error) { - return nil, fmt.Errorf("pane not found") - }} - _, err := deps.capture("%999") - if err == nil { - t.Fatal("expected error for failed capture") - } -} - -func TestCapturePane_EmptyContent(t *testing.T) { - deps := tmuxEditDeps{runCommand: func(string, ...string) ([]byte, error) { - return []byte("\n\n"), nil - }} - got, err := deps.capture("%1") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if got != "" { - t.Errorf("got %q, want empty string", got) - } -} diff --git a/internal/tmuxedit/config_agent.go b/internal/tmuxedit/config_agent.go deleted file mode 100644 index 0c52c3d..0000000 --- a/internal/tmuxedit/config_agent.go +++ /dev/null @@ -1,135 +0,0 @@ -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. -// 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(), - &configAgent{baseAgent{ - name: "amp", - displayName: "Amp", - detectPattern: `(?i)(amp|sourcegraph)`, - promptPat: `(?m)│\s*(.+?)\s*│\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 deleted file mode 100644 index 666525d..0000000 --- a/internal/tmuxedit/config_agent_test.go +++ /dev/null @@ -1,182 +0,0 @@ -package tmuxedit - -import ( - "testing" - - "codeberg.org/snonux/hexai/internal/appconfig" -) - -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 (detectPa |
