From a68228bfa12f4d8a51fe53e244fcd2e66c1ef692 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Thu, 2 Jul 2026 09:38:04 +0300 Subject: Remove hexai-tmux-edit popup editor feature 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 --- internal/tmuxedit/agent.go | 149 --------------- internal/tmuxedit/agent_test.go | 157 --------------- internal/tmuxedit/agentutil.go | 183 ------------------ internal/tmuxedit/agentutil_test.go | 265 ------------------------- internal/tmuxedit/capture.go | 23 --- internal/tmuxedit/capture_test.go | 45 ----- internal/tmuxedit/config_agent.go | 135 ------------- internal/tmuxedit/config_agent_test.go | 182 ------------------ internal/tmuxedit/cursor_agent.go | 58 ------ internal/tmuxedit/cursor_agent_test.go | 183 ------------------ internal/tmuxedit/history.go | 111 ----------- internal/tmuxedit/history_test.go | 326 ------------------------------- internal/tmuxedit/pane.go | 63 ------ internal/tmuxedit/pane_test.go | 73 ------- internal/tmuxedit/run.go | 268 -------------------------- internal/tmuxedit/run_test.go | 340 --------------------------------- internal/tmuxedit/send.go | 66 ------- internal/tmuxedit/send_test.go | 110 ----------- 18 files changed, 2737 deletions(-) delete mode 100644 internal/tmuxedit/agent.go delete mode 100644 internal/tmuxedit/agent_test.go delete mode 100644 internal/tmuxedit/agentutil.go delete mode 100644 internal/tmuxedit/agentutil_test.go delete mode 100644 internal/tmuxedit/capture.go delete mode 100644 internal/tmuxedit/capture_test.go delete mode 100644 internal/tmuxedit/config_agent.go delete mode 100644 internal/tmuxedit/config_agent_test.go delete mode 100644 internal/tmuxedit/cursor_agent.go delete mode 100644 internal/tmuxedit/cursor_agent_test.go delete mode 100644 internal/tmuxedit/history.go delete mode 100644 internal/tmuxedit/history_test.go delete mode 100644 internal/tmuxedit/pane.go delete mode 100644 internal/tmuxedit/pane_test.go delete mode 100644 internal/tmuxedit/run.go delete mode 100644 internal/tmuxedit/run_test.go delete mode 100644 internal/tmuxedit/send.go delete mode 100644 internal/tmuxedit/send_test.go (limited to 'internal/tmuxedit') 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 `. 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 (detectPattern) while applying user overrides (DisplayName, ClearFirst). - cfgAgents := []appconfig.TmuxEditAgentCfg{ - { - Name: "amp", - DisplayName: "My Amp", - ClearFirst: boolP(false), - }, - } - agents := resolveAgents(cfgAgents) - var amp Agent - for _, a := range agents { - if a.Name() == "amp" { - amp = a - break - } - } - if amp == nil { - t.Fatal("amp agent not found") - } - if amp.DisplayName() != "My Amp" { - t.Errorf("DisplayName = %q, want My Amp", amp.DisplayName()) - } - // ClearInput should be no-op after override to false - c := amp.(Configurable) - if c.Base().clearFirst { - t.Error("clearFirst should be false after override") - } - // DetectPattern should be preserved from builtin - if c.Base().detectPattern == "" { - t.Error("detectPattern should be preserved from builtin") - } -} - -func TestResolveAgents_MergeAllFields(t *testing.T) { - // Override the built-in "aider" agent with all fields to verify full merging. - cfgAgents := []appconfig.TmuxEditAgentCfg{ - { - Name: "aider", - DisplayName: "Custom Aider", - DetectPattern: "(?i)custom-aider", - PromptPattern: `>\s+(.*)$`, - StripPatterns: []string{"NOISE"}, - ClearFirst: boolP(true), - ClearKeys: "C-k", - NewlineKeys: "C-Enter", - SubmitKeys: "C-m", - }, - } - agents := resolveAgents(cfgAgents) - var a Agent - for _, ag := range agents { - if ag.Name() == "aider" { - a = ag - break - } - } - if a == nil { - t.Fatal("aider agent not found") - } - c := a.(Configurable) - base := c.Base() - if base.detectPattern != "(?i)custom-aider" { - t.Errorf("detectPattern = %q", base.detectPattern) - } - if base.promptPat != `>\s+(.*)$` { - t.Errorf("promptPat = %q", base.promptPat) - } - if len(base.stripPatterns) != 1 || base.stripPatterns[0] != "NOISE" { - t.Errorf("stripPatterns = %v", base.stripPatterns) - } - if base.clearKeys != "C-k" { - t.Errorf("clearKeys = %q", base.clearKeys) - } - if base.newlineKeys != "C-Enter" { - t.Errorf("newlineKeys = %q", base.newlineKeys) - } - if base.submitKeys != "C-m" { - t.Errorf("submitKeys = %q", base.submitKeys) - } -} - -func TestResolveAgents_AddNew(t *testing.T) { - cfgAgents := []appconfig.TmuxEditAgentCfg{ - { - Name: "custom", - DisplayName: "Custom Agent", - DetectPattern: "(?i)custom", - PromptPattern: `>\s*(.+)$`, - ClearFirst: boolP(true), - }, - } - agents := resolveAgents(cfgAgents) - found := false - for _, a := range agents { - if a.Name() == "custom" { - found = true - if a.DisplayName() != "Custom Agent" { - t.Errorf("DisplayName = %q, want Custom Agent", a.DisplayName()) - } - c := a.(Configurable) - if !c.Base().clearFirst { - t.Error("clearFirst should be true") - } - } - } - if !found { - t.Error("custom agent not found in resolved agents") - } -} - -func TestAgentFromConfig_DefaultDisplayName(t *testing.T) { - cfg := appconfig.TmuxEditAgentCfg{ - Name: "test", - } - a := agentFromConfig(cfg) - if a.DisplayName() != "test" { - t.Errorf("DisplayName = %q, want test (defaulted from Name)", a.DisplayName()) - } -} - -func TestConfigAgent_ExtractPrompt(t *testing.T) { - // Config agent uses baseAgent's default extraction (section-aware) - agent := &configAgent{baseAgent{ - promptPat: `(?m)>\s*(.+)$`, - }} - content := "> hello world" - got := agent.ExtractPrompt(content) - if got != "hello world" { - t.Errorf("ExtractPrompt() = %q, want %q", got, "hello world") - } -} - -func TestConfigAgent_Amp(t *testing.T) { - agents := builtinAgents() - var amp Agent - for _, a := range agents { - if a.Name() == "amp" { - amp = a - break - } - } - if amp == nil { - t.Fatal("amp agent not found") - } - if !amp.Detect("Amp by Sourcegraph") { - t.Error("amp should detect 'Amp by Sourcegraph'") - } - // Amp uses box-drawing TUI format (like cursor), not shell-style > prompt - got := amp.ExtractPrompt("│ fix the bug │") - if got != "fix the bug" { - t.Errorf("ExtractPrompt() = %q, want %q", got, "fix the bug") - } -} - -func TestConfigAgent_Aider(t *testing.T) { - agents := builtinAgents() - var aider Agent - for _, a := range agents { - if a.Name() == "aider" { - aider = a - break - } - } - if aider == nil { - t.Fatal("aider agent not found") - } - if !aider.Detect("aider v0.50") { - t.Error("aider should detect 'aider v0.50'") - } -} diff --git a/internal/tmuxedit/cursor_agent.go b/internal/tmuxedit/cursor_agent.go deleted file mode 100644 index ebea38e..0000000 --- a/internal/tmuxedit/cursor_agent.go +++ /dev/null @@ -1,58 +0,0 @@ -package tmuxedit - -import ( - "regexp" -) - -// cursorAgent handles Cursor's distinctive box-drawing │ → prompt │ UI. -// Cursor uses a text field (not vim), so clearing is done with End + bulk -// backspace. Multi-line prompts are entered with Shift-Enter within the box. -type cursorAgent struct{ baseAgent } - -// newCursorAgent returns a cursorAgent with the default configuration. -// Detect by the box structure or "/ commands" footer. Checked first because -// cursor panes often show model names like "Claude 4.5 Sonnet". -func newCursorAgent() *cursorAgent { - return &cursorAgent{baseAgent{ - name: "cursor", - displayName: "Cursor", - detectPattern: `(│\s*→|/ commands · @ files)`, - promptPat: `(?m)│\s*→?\s*(.+?)\s*│\s*$`, - stripPatterns: []string{"INSERT", "Add a follow-up", "ctrl+c to stop"}, - clearFirst: true, - clearKeys: "End BSpace*200", - newlineKeys: "S-Enter", - submitKeys: "Enter", - }} -} - -// ExtractPrompt extracts the prompt text from the last contiguous │...│ block -// in the pane. This avoids picking up earlier command-review or dialog boxes -// that also use box-drawing characters. -func (c *cursorAgent) ExtractPrompt(paneContent string) string { - if c.promptPat == "" { - return "" - } - re, err := regexp.Compile(c.promptPat) - if err != nil { - return "" - } - allMatches := matchPromptLines(re, paneContent) - if len(allMatches) == 0 { - return "" - } - return joinLastContiguousBlock(allMatches, c.stripPatterns) -} - -// ClearInput sends End + 200 backspaces to clear Cursor's text field. -// Cursor's input is a standard text field, not vim. -func (c *cursorAgent) ClearInput(paneID string) error { - if !c.clearFirst || c.clearKeys == "" { - return nil - } - if err := c.deps.sendClearSequence(paneID, c.clearKeys); err != nil { - return err - } - c.deps.sleep() - return nil -} diff --git a/internal/tmuxedit/cursor_agent_test.go b/internal/tmuxedit/cursor_agent_test.go deleted file mode 100644 index d81416b..0000000 --- a/internal/tmuxedit/cursor_agent_test.go +++ /dev/null @@ -1,183 +0,0 @@ -package tmuxedit - -import ( - "fmt" - "strings" - "testing" -) - -func TestCursorAgent_ExtractPrompt(t *testing.T) { - agent := newCursorAgent() - tests := []struct { - name string - content string - want string - }{ - { - name: "box with arrow", - content: "Cursor Agent\n │ → fix the bug INSERT │", - want: "fix the bug", - }, - { - name: "box without arrow", - content: "Cursor Agent\n │ fix the bug │", - want: "fix the bug", - }, - { - name: "strips follow-up placeholder", - content: "Cursor\n │ → Add a follow-up │", - want: "", - }, - { - name: "multi-line prompt", - content: " │ → first line of prompt │\n │ second line here │\n │ third line end │", - want: "first line of prompt\nsecond line here\nthird line end", - }, - { - name: "multi-line with noise", - content: " │ → fix the bug INSERT │\n │ also refactor tests │", - want: "fix the bug\nalso refactor tests", - }, - { - name: "multi-box takes last box only", - content: " ┌──────────────┐\n" + - " │ $ git push │\n" + - " └──────────────┘\n" + - " ┌──────────────┐\n" + - " │ Run command? │\n" + - " │ → Yes (enter) │\n" + - " │ No (esc) │\n" + - " └──────────────┘\n" + - " ┌──────────────┐\n" + - " │ → hello world │\n" + - " └──────────────┘\n", - want: "hello world", - }, - { - name: "multi-box multi-line prompt", - content: " ┌──────────────┐\n" + - " │ $ git push │\n" + - " └──────────────┘\n" + - " ┌──────────────┐\n" + - " │ → first line │\n" + - " │ second line │\n" + - " │ third line │\n" + - " └──────────────┘\n", - want: "first line\nsecond line\nthird line", - }, - { - name: "no match", - content: "no prompt here", - want: "", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := agent.ExtractPrompt(tt.content) - if got != tt.want { - t.Errorf("ExtractPrompt() = %q, want %q", got, tt.want) - } - }) - } -} - -func TestCursorAgent_ClearInput(t *testing.T) { - 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 - } - deps.sendRepeatedKey = func(paneID, key string, count int) error { - calls = append(calls, fmt.Sprintf("repeat:%s:%s*%d", paneID, key, count)) - return nil - } - - agent := newCursorAgent() - agent.deps = deps - err := agent.ClearInput("%5") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - // "End BSpace*200" should send End normally, then BSpace 200 times via -N - want := []string{ - "send:%5:End", - "repeat:%5:BSpace*200", - } - if len(calls) != len(want) { - t.Fatalf("got %d calls, want %d: %v", len(calls), len(want), calls) - } - for i, w := range want { - if calls[i] != w { - t.Errorf("call[%d] = %q, want %q", i, calls[i], w) - } - } -} - -func TestCursorAgent_ExtractPrompt_EmptyPattern(t *testing.T) { - // A cursorAgent with empty promptPat returns empty string - agent := &cursorAgent{baseAgent{promptPat: ""}} - got := agent.ExtractPrompt("│ → hello │") - if got != "" { - t.Errorf("expected empty for empty pattern, got %q", got) - } -} - -func TestCursorAgent_ExtractPrompt_InvalidRegex(t *testing.T) { - // A cursorAgent with invalid regex returns empty string - agent := &cursorAgent{baseAgent{promptPat: "[invalid"}} - got := agent.ExtractPrompt("│ → hello │") - if got != "" { - t.Errorf("expected empty for invalid regex, got %q", got) - } -} - -func TestCursorAgent_ClearInput_Disabled(t *testing.T) { - agent := &cursorAgent{baseAgent{clearFirst: false, clearKeys: "End BSpace*200"}} - err := agent.ClearInput("%1") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestCursorAgent_ClearInput_EmptyKeys(t *testing.T) { - agent := &cursorAgent{baseAgent{clearFirst: true, clearKeys: ""}} - err := agent.ClearInput("%1") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestCursorAgent_ClearInput_Error(t *testing.T) { - deps := noSleepDeps() - deps.sendKeys = func(string, ...string) error { - return fmt.Errorf("send failed") - } - - agent := newCursorAgent() - agent.deps = deps - err := agent.ClearInput("%1") - if err == nil { - t.Fatal("expected error from sendClearSequence failure") - } -} - -func TestCursorAgent_Detect(t *testing.T) { - agent := newCursorAgent() - tests := []struct { - name string - content string - want bool - }{ - {"box with arrow", "│ → type here │", true}, - {"commands footer", "/ commands · @ files", true}, - {"no match", "some text", false}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := agent.Detect(tt.content); got != tt.want { - t.Errorf("Detect() = %v, want %v", got, tt.want) - } - }) - } -} diff --git a/internal/tmuxedit/history.go b/internal/tmuxedit/history.go deleted file mode 100644 index eac3114..0000000 --- a/internal/tmuxedit/history.go +++ /dev/null @@ -1,111 +0,0 @@ -// Package tmuxedit provides JSONL-based history storage for tmux popup submissions. -package tmuxedit - -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" - "time" - - "codeberg.org/snonux/hexai/internal/appconfig" - "codeberg.org/snonux/hexai/internal/textutil" -) - -// HistoryEntry represents a single submission to the AI agent via tmux popup. -// Stored in JSONL format (one JSON object per line) for easy appending and reading. -type HistoryEntry struct { - Timestamp string `json:"timestamp"` // RFC3339 format - Agent string `json:"agent"` // AI agent name (e.g., "claude", "aider") - Cwd string `json:"cwd"` // Current working directory at submission time - Text string `json:"text"` // The submitted text -} - -// AppendHistory appends a new history entry to the history file. -// Uses atomic write pattern (write to temp file, then rename) for safety. -func AppendHistory(text, agent, cwd string) error { - stateDir, err := appconfig.StateDir() - if err != nil { - return fmt.Errorf("cannot get state directory: %w", err) - } - - historyPath := filepath.Join(stateDir, "tmux-edit-history.jsonl") - - // Create entry with current timestamp - entry := HistoryEntry{ - Timestamp: time.Now().Format(time.RFC3339), - Agent: agent, - Cwd: cwd, - Text: text, - } - - // Marshal to JSON - data, err := json.Marshal(entry) - if err != nil { - return fmt.Errorf("cannot marshal history entry: %w", err) - } - - // Append newline for JSONL format - data = append(data, '\n') - - // Open file in append mode, create if doesn't exist - f, err := os.OpenFile(historyPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) - if err != nil { - return fmt.Errorf("cannot open history file: %w", err) - } - defer func() { _ = f.Close() }() // best-effort on error paths - - // Write entry - if _, err := f.Write(data); err != nil { - return fmt.Errorf("cannot write history entry: %w", err) - } - - // Check Close error to catch deferred-write failures (e.g. disk full). - return f.Close() -} - -// GetHistory retrieves the most recent history entries (up to limit). -// Returns entries in chronological order (oldest first). -// If limit <= 0, returns all entries. -func GetHistory(limit int) ([]HistoryEntry, error) { - stateDir, err := appconfig.StateDir() - if err != nil { - return nil, fmt.Errorf("cannot get state directory: %w", err) - } - - historyPath := filepath.Join(stateDir, "tmux-edit-history.jsonl") - - // Read entire file - data, err := os.ReadFile(historyPath) - if err != nil { - if os.IsNotExist(err) { - return []HistoryEntry{}, nil // Empty history is not an error - } - return nil, fmt.Errorf("cannot read history file: %w", err) - } - - // Parse JSONL line by line - var entries []HistoryEntry - lines := textutil.SplitLinesBytes(data) - for i, line := range lines { - if len(line) == 0 { - continue // Skip empty lines - } - - var entry HistoryEntry - if err := json.Unmarshal(line, &entry); err != nil { - // Log error but continue parsing (don't fail entire history on one bad line) - fmt.Fprintf(os.Stderr, "warning: cannot parse history entry at line %d: %v\n", i+1, err) - continue - } - entries = append(entries, entry) - } - - // Apply limit if specified - if limit > 0 && len(entries) > limit { - // Return the most recent entries - entries = entries[len(entries)-limit:] - } - - return entries, nil -} diff --git a/internal/tmuxedit/history_test.go b/internal/tmuxedit/history_test.go deleted file mode 100644 index f6d6d7d..0000000 --- a/internal/tmuxedit/history_test.go +++ /dev/null @@ -1,326 +0,0 @@ -package tmuxedit - -import ( - "fmt" - "os" - "path/filepath" - "testing" - "time" - - "codeberg.org/snonux/hexai/internal/textutil" -) - -func TestAppendHistory(t *testing.T) { - // Create temp directory for test - tmpDir := t.TempDir() - t.Setenv("XDG_STATE_HOME", tmpDir) - - text := "test prompt text" - agent := "claude" - cwd := "/tmp/test" - - // Append first entry - if err := AppendHistory(text, agent, cwd); err != nil { - t.Fatalf("AppendHistory failed: %v", err) - } - - // Verify file was created - historyPath := filepath.Join(tmpDir, "state", "tmux-edit-history.jsonl") - if _, err := os.Stat(historyPath); err != nil { - t.Fatalf("history file not created: %v", err) - } - - // Read and verify content - data, err := os.ReadFile(historyPath) - if err != nil { - t.Fatalf("cannot read history file: %v", err) - } - - content := string(data) - if content == "" { - t.Fatal("history file is empty") - } - - // Verify it contains expected fields - if !containsString(content, "test prompt text") { - t.Error("history doesn't contain text") - } - if !containsString(content, "claude") { - t.Error("history doesn't contain agent") - } - if !containsString(content, "/tmp/test") { - t.Error("history doesn't contain cwd") - } -} - -func TestGetHistory(t *testing.T) { - tmpDir := t.TempDir() - t.Setenv("XDG_STATE_HOME", tmpDir) - - // Append multiple entries - entries := []struct { - text string - agent string - cwd string - }{ - {"first prompt", "claude", "/home/user"}, - {"second prompt", "aider", "/tmp/project"}, - {"third prompt", "claude", "/var/tmp"}, - } - - for _, e := range entries { - if err := AppendHistory(e.text, e.agent, e.cwd); err != nil { - t.Fatalf("AppendHistory failed: %v", err) - } - time.Sleep(10 * time.Millisecond) // Ensure different timestamps - } - - // Get all history - history, err := GetHistory(0) - if err != nil { - t.Fatalf("GetHistory failed: %v", err) - } - - if len(history) != 3 { - t.Fatalf("expected 3 entries, got %d", len(history)) - } - - // Verify first entry - if history[0].Text != "first prompt" { - t.Errorf("first entry text: got %q, want %q", history[0].Text, "first prompt") - } - if history[0].Agent != "claude" { - t.Errorf("first entry agent: got %q, want %q", history[0].Agent, "claude") - } - - // Test limit - limited, err := GetHistory(2) - if err != nil { - t.Fatalf("GetHistory with limit failed: %v", err) - } - if len(limited) != 2 { - t.Fatalf("expected 2 entries with limit, got %d", len(limited)) - } - - // Should get the most recent 2 - if limited[0].Text != "second prompt" { - t.Errorf("limited[0] should be second entry") - } - if limited[1].Text != "third prompt" { - t.Errorf("limited[1] should be third entry") - } -} - -func TestGetHistory_EmptyFile(t *testing.T) { - tmpDir := t.TempDir() - t.Setenv("XDG_STATE_HOME", tmpDir) - - // Get history when file doesn't exist - history, err := GetHistory(0) - if err != nil { - t.Fatalf("GetHistory should not error on missing file: %v", err) - } - - if len(history) != 0 { - t.Errorf("expected empty history, got %d entries", len(history)) - } -} - -func TestSplitLines(t *testing.T) { - tests := []struct { - name string - input string - want []string - }{ - { - name: "unix newlines", - input: "line1\nline2\nline3", - want: []string{"line1", "line2", "line3"}, - }, - { - name: "windows newlines", - input: "line1\r\nline2\r\nline3", - want: []string{"line1", "line2", "line3"}, - }, - { - name: "mixed newlines", - input: "line1\nline2\r\nline3", - want: []string{"line1", "line2", "line3"}, - }, - { - name: "trailing newline", - input: "line1\nline2\n", - want: []string{"line1", "line2"}, - }, - { - name: "empty string", - input: "", - want: []string{}, - }, - { - name: "single line no newline", - input: "single", - want: []string{"single"}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := textutil.SplitLinesBytes([]byte(tt.input)) - gotStr := make([]string, len(got)) - for i, b := range got { - gotStr[i] = string(b) - } - - if len(gotStr) != len(tt.want) { - t.Fatalf("got %d lines, want %d", len(gotStr), len(tt.want)) - } - - for i := range gotStr { - if gotStr[i] != tt.want[i] { - t.Errorf("line %d: got %q, want %q", i, gotStr[i], tt.want[i]) - } - } - }) - } -} - -func TestGetHistory_MalformedEntries(t *testing.T) { - tmpDir := t.TempDir() - t.Setenv("XDG_STATE_HOME", tmpDir) - - // Create state directory and write a file with some valid and invalid lines - stateDir := filepath.Join(tmpDir, "state") - if err := os.MkdirAll(stateDir, 0o755); err != nil { - t.Fatalf("cannot create state dir: %v", err) - } - historyPath := filepath.Join(stateDir, "tmux-edit-history.jsonl") - content := `{"timestamp":"2025-01-01T00:00:00Z","agent":"claude","cwd":"/tmp","text":"valid"} -not json at all -{"timestamp":"2025-01-02T00:00:00Z","agent":"aider","cwd":"/home","text":"also valid"} -` - if err := os.WriteFile(historyPath, []byte(content), 0o644); err != nil { - t.Fatalf("cannot write history file: %v", err) - } - - entries, err := GetHistory(0) - if err != nil { - t.Fatalf("GetHistory failed: %v", err) - } - // Should skip the malformed line and return the 2 valid entries - if len(entries) != 2 { - t.Fatalf("expected 2 entries, got %d", len(entries)) - } - if entries[0].Text != "valid" { - t.Errorf("entries[0].Text = %q, want 'valid'", entries[0].Text) - } - if entries[1].Text != "also valid" { - t.Errorf("entries[1].Text = %q, want 'also valid'", entries[1].Text) - } -} - -func TestGetHistory_EmptyLines(t *testing.T) { - tmpDir := t.TempDir() - t.Setenv("XDG_STATE_HOME", tmpDir) - - stateDir := filepath.Join(tmpDir, "state") - if err := os.MkdirAll(stateDir, 0o755); err != nil { - t.Fatalf("cannot create state dir: %v", err) - } - historyPath := filepath.Join(stateDir, "tmux-edit-history.jsonl") - // File with empty lines interspersed - content := "\n" + - `{"timestamp":"2025-01-01T00:00:00Z","agent":"claude","cwd":"/tmp","text":"entry1"}` + "\n" + - "\n\n" + - `{"timestamp":"2025-01-02T00:00:00Z","agent":"aider","cwd":"/home","text":"entry2"}` + "\n" - if err := os.WriteFile(historyPath, []byte(content), 0o644); err != nil { - t.Fatalf("cannot write history file: %v", err) - } - - entries, err := GetHistory(0) - if err != nil { - t.Fatalf("GetHistory failed: %v", err) - } - if len(entries) != 2 { - t.Fatalf("expected 2 entries (skipping empty lines), got %d", len(entries)) - } -} - -func TestGetHistory_LimitZeroReturnsAll(t *testing.T) { - tmpDir := t.TempDir() - t.Setenv("XDG_STATE_HOME", tmpDir) - - for i := 0; i < 5; i++ { - if err := AppendHistory(fmt.Sprintf("entry%d", i), "claude", "/tmp"); err != nil { - t.Fatalf("AppendHistory failed: %v", err) - } - } - - entries, err := GetHistory(0) - if err != nil { - t.Fatalf("GetHistory failed: %v", err) - } - if len(entries) != 5 { - t.Errorf("expected 5 entries with limit=0, got %d", len(entries)) - } -} - -func TestGetHistory_LimitLargerThanEntries(t *testing.T) { - tmpDir := t.TempDir() - t.Setenv("XDG_STATE_HOME", tmpDir) - - if err := AppendHistory("only one", "claude", "/tmp"); err != nil { - t.Fatalf("AppendHistory failed: %v", err) - } - - entries, err := GetHistory(100) - if err != nil { - t.Fatalf("GetHistory failed: %v", err) - } - if len(entries) != 1 { - t.Errorf("expected 1 entry with large limit, got %d", len(entries)) - } -} - -func TestAppendHistory_InvalidStateDir(t *testing.T) { - // Point XDG_STATE_HOME to a path that can't be created (file, not dir) - tmpDir := t.TempDir() - blockingFile := filepath.Join(tmpDir, "blocker") - if err := os.WriteFile(blockingFile, []byte("x"), 0o644); err != nil { - t.Fatalf("cannot create blocking file: %v", err) - } - // Set state home to a path under the file (impossible to mkdir) - t.Setenv("XDG_STATE_HOME", filepath.Join(blockingFile, "sub")) - - err := AppendHistory("text", "agent", "/cwd") - if err == nil { - t.Fatal("expected error when state directory cannot be created") - } -} - -func TestGetHistory_InvalidStateDir(t *testing.T) { - tmpDir := t.TempDir() - blockingFile := filepath.Join(tmpDir, "blocker") - if err := os.WriteFile(blockingFile, []byte("x"), 0o644); err != nil { - t.Fatalf("cannot create blocking file: %v", err) - } - t.Setenv("XDG_STATE_HOME", filepath.Join(blockingFile, "sub")) - - _, err := GetHistory(0) - if err == nil { - t.Fatal("expected error when state directory cannot be created") - } -} - -func containsString(s, substr string) bool { - return len(s) >= len(substr) && (s == substr || len(s) > len(substr) && findSubstring(s, substr)) -} - -func findSubstring(s, substr string) bool { - for i := 0; i <= len(s)-len(substr); i++ { - if s[i:i+len(substr)] == substr { - return true - } - } - return false -} diff --git a/internal/tmuxedit/pane.go b/internal/tmuxedit/pane.go deleted file mode 100644 index 2713994..0000000 --- a/internal/tmuxedit/pane.go +++ /dev/null @@ -1,63 +0,0 @@ -package tmuxedit - -import ( - "fmt" - "os" - "os/exec" - "strings" -) - -type tmuxEditDeps struct { - runCommand func(string, ...string) ([]byte, error) - capturePane func(string) (string, error) - openEditorPopup func(string, string, string) (string, error) - sendKeys func(string, ...string) error - sendRepeatedKey func(string, string, int) error - sleepAfterClear func() - sleepAfterEscape func() - launchPopup func(string, string, string, string) error -} - -func (d tmuxEditDeps) command(name string, args ...string) ([]byte, error) { - if d.runCommand != nil { - return d.runCommand(name, args...) - } - return exec.Command(name, args...).Output() -} - -// resolveTargetPane determines which tmux pane to target using a fallback -// chain: explicit flag > HEXAI_TMUX_PANE env var > tmux query for active pane. -// Returns the pane ID (e.g. "%5") or an error. -func resolveTargetPane(flagPane string) (string, error) { - return tmuxEditDeps{}.resolveTargetPane(flagPane) -} - -func (d tmuxEditDeps) resolveTargetPane(flagPane string) (string, error) { - // 1. Explicit --pane flag - if p := strings.TrimSpace(flagPane); p != "" { - return p, nil - } - // 2. Environment variable - if p := strings.TrimSpace(os.Getenv("HEXAI_TMUX_PANE")); p != "" { - return p, nil - } - // 3. Query tmux for the active pane in the current window - return d.queryActivePane() -} - -// queryActivePane asks tmux for the active pane ID using display-message. -func queryActivePane() (string, error) { - return tmuxEditDeps{}.queryActivePane() -} - -func (d tmuxEditDeps) queryActivePane() (string, error) { - out, err := d.command("tmux", "display-message", "-p", "#{pane_id}") - if err != nil { - return "", fmt.Errorf("cannot determine tmux pane: %w", err) - } - pane := strings.TrimSpace(string(out)) - if pane == "" { - return "", fmt.Errorf("tmux returned empty pane ID") - } - return pane, nil -} diff --git a/internal/tmuxedit/pane_test.go b/internal/tmuxedit/pane_test.go deleted file mode 100644 index d15ef55..0000000 --- a/internal/tmuxedit/pane_test.go +++ /dev/null @@ -1,73 +0,0 @@ -package tmuxedit - -import ( - "fmt" - "testing" -) - -func TestResolveTargetPane_FlagWins(t *testing.T) { - deps := tmuxEditDeps{runCommand: func(string, ...string) ([]byte, error) { - return []byte("%99"), nil - }} - t.Setenv("HEXAI_TMUX_PANE", "%10") - got, err := deps.resolveTargetPane("%5") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if got != "%5" { - t.Errorf("got %q, want %%5 (flag should win)", got) - } -} - -func TestResolveTargetPane_EnvFallback(t *testing.T) { - deps := tmuxEditDeps{runCommand: func(string, ...string) ([]byte, error) { - return []byte("%99"), nil - }} - t.Setenv("HEXAI_TMUX_PANE", "%10") - got, err := deps.resolveTargetPane("") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if got != "%10" { - t.Errorf("got %q, want %%10 (env fallback)", got) - } -} - -func TestResolveTargetPane_TmuxQuery(t *testing.T) { - deps := tmuxEditDeps{runCommand: func(name string, args ...string) ([]byte, error) { - if name == "tmux" && len(args) > 0 && args[0] == "display-message" { - return []byte("%42\n"), nil - } - return nil, fmt.Errorf("unexpected command: %s", name) - }} - t.Setenv("HEXAI_TMUX_PANE", "") - got, err := deps.resolveTargetPane("") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if got != "%42" { - t.Errorf("got %q, want %%42 (tmux query)", got) - } -} - -func TestResolveTargetPane_TmuxError(t *testing.T) { - deps := tmuxEditDeps{runCommand: func(string, ...string) ([]byte, error) { - return nil, fmt.Errorf("tmux not available") - }} - t.Setenv("HEXAI_TMUX_PANE", "") - _, err := deps.resolveTargetPane("") - if err == nil { - t.Fatal("expected error when tmux fails") - } -} - -func TestResolveTargetPane_TmuxEmptyOutput(t *testing.T) { - deps := tmuxEditDeps{runCommand: func(string, ...string) ([]byte, error) { - return []byte(" \n"), nil - }} - t.Setenv("HEXAI_TMUX_PANE", "") - _, err := deps.resolveTargetPane("") - if err == nil { - t.Fatal("expected error for empty tmux output") - } -} diff --git a/internal/tmuxedit/run.go b/internal/tmuxedit/run.go deleted file mode 100644 index ff22b5b..0000000 --- a/internal/tmuxedit/run.go +++ /dev/null @@ -1,268 +0,0 @@ -package tmuxedit - -import ( - "context" - "fmt" - "log" - "os" - "os/exec" - "path/filepath" - "strings" - - "codeberg.org/snonux/hexai/internal/appconfig" - "codeberg.org/snonux/hexai/internal/editor" - "codeberg.org/snonux/hexai/internal/tmux" -) - -// Options holds the parsed command-line flags for hexai-tmux-edit. -type Options struct { - ConfigPath string // --config flag - Agent string // --agent flag (explicit agent name, or auto-detect) - Pane string // --pane flag (target pane ID) -} - -func openEditorPopup(initial, popupW, popupH string) (string, error) { - return tmuxEditDeps{}.openEditor(initial, popupW, popupH) -} - -// openEditor creates a temp file, opens it in a tmux popup with the user's -// editor, waits for completion, and returns the edited content. -func (d tmuxEditDeps) openEditor(initial, popupW, popupH string) (string, error) { - if d.openEditorPopup != nil { - return d.openEditorPopup(initial, popupW, popupH) - } - ed, err := editor.Resolve() - if err != nil { - return "", err - } - // Create a temp file with the initial content - f, err := os.CreateTemp("", "hexai-tmux-edit-*.md") - if err != nil { - return "", fmt.Errorf("create temp file: %w", err) - } - path := f.Name() - defer func() { _ = os.Remove(path) }() - - if initial != "" { - if _, err := f.WriteString(initial); err != nil { - _ = f.Close() - return "", fmt.Errorf("write initial content: %w", err) - } - } - if err := f.Close(); err != nil { - return "", fmt.Errorf("close temp file: %w", err) - } - - // Build the tmux display-popup command to launch the editor - if err := d.launch(ed, path, popupW, popupH); err != nil { - return "", fmt.Errorf("popup editor: %w", err) - } - - b, err := os.ReadFile(path) - if err != nil { - return "", fmt.Errorf("read edited file: %w", err) - } - return strings.TrimSpace(string(b)), nil -} - -func launchPopup(ed, path, width, height string) error { - return tmuxEditDeps{}.launch(ed, path, width, height) -} - -// launch runs `tmux display-popup` with the editor. The -E flag makes the -// popup close when the editor exits. The -d flag sets the working directory -// for the popup. Uses .Run() so the popup blocks until the user closes it. -func (d tmuxEditDeps) launch(ed, path, width, height string) error { - if d.launchPopup != nil { - return d.launchPopup(ed, path, width, height) - } - args := []string{"display-popup", "-E"} - - // Get current working directory to pass to the popup - if cwd, err := os.Getwd(); err == nil && cwd != "" { - args = append(args, "-d", cwd) - } - - if width != "" { - args = append(args, "-w", width) - } - if height != "" { - args = append(args, "-h", height) - } - args = append(args, ed+" "+shellQuote(path)) - return exec.Command("tmux", args...).Run() -} - -// shellQuote wraps a path in single quotes for safe shell use. -func shellQuote(s string) string { - return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" -} - -// Run is the main orchestrator for hexai-tmux-edit. It: -// 1. Checks tmux availability -// 2. Resolves the target pane -// 3. Captures pane content -// 4. Detects or selects the agent -// 5. Extracts the current prompt -// 6. Opens the editor in a popup -// 7. Dedu