summaryrefslogtreecommitdiff
path: root/internal/tmuxedit
diff options
context:
space:
mode:
Diffstat (limited to 'internal/tmuxedit')
-rw-r--r--internal/tmuxedit/agent.go149
-rw-r--r--internal/tmuxedit/agent_test.go157
-rw-r--r--internal/tmuxedit/agentutil.go183
-rw-r--r--internal/tmuxedit/agentutil_test.go265
-rw-r--r--internal/tmuxedit/capture.go23
-rw-r--r--internal/tmuxedit/capture_test.go45
-rw-r--r--internal/tmuxedit/config_agent.go135
-rw-r--r--internal/tmuxedit/config_agent_test.go182
-rw-r--r--internal/tmuxedit/cursor_agent.go58
-rw-r--r--internal/tmuxedit/cursor_agent_test.go183
-rw-r--r--internal/tmuxedit/history.go111
-rw-r--r--internal/tmuxedit/history_test.go326
-rw-r--r--internal/tmuxedit/pane.go63
-rw-r--r--internal/tmuxedit/pane_test.go73
-rw-r--r--internal/tmuxedit/run.go268
-rw-r--r--internal/tmuxedit/run_test.go340
-rw-r--r--internal/tmuxedit/send.go66
-rw-r--r--internal/tmuxedit/send_test.go110
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/