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/appconfig/app_feature_sections.go | 7 - internal/appconfig/app_feature_sections_test.go | 12 - internal/appconfig/app_sections.go | 3 - internal/appconfig/app_sections_test.go | 21 -- internal/appconfig/config_features_test.go | 102 +------ internal/appconfig/config_load.go | 32 --- internal/appconfig/config_merge.go | 17 -- internal/appconfig/config_types.go | 38 --- internal/appconfig/feature_sections.go | 14 +- 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 -------- internal/version.go | 2 +- 28 files changed, 5 insertions(+), 2980 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') diff --git a/internal/appconfig/app_feature_sections.go b/internal/appconfig/app_feature_sections.go index 2513946..da944d3 100644 --- a/internal/appconfig/app_feature_sections.go +++ b/internal/appconfig/app_feature_sections.go @@ -20,13 +20,6 @@ func (a *App) StatsSection() StatsConfig { return a.StatsConfig } -// TmuxEditSection returns a copy of the tmux popup editor settings. -func (a *App) TmuxEditSection() TmuxEditConfig { - c := a.TmuxEditConfig - c.TmuxEditAgents = append([]TmuxEditAgentCfg{}, a.TmuxEditAgents...) - return c -} - // TmuxActionSection returns a copy of the tmux action menu settings. func (a *App) TmuxActionSection() TmuxActionConfig { c := a.TmuxActionConfig diff --git a/internal/appconfig/app_feature_sections_test.go b/internal/appconfig/app_feature_sections_test.go index 1f9ac2d..b217146 100644 --- a/internal/appconfig/app_feature_sections_test.go +++ b/internal/appconfig/app_feature_sections_test.go @@ -33,18 +33,6 @@ func TestStatsSectionReads(t *testing.T) { } } -func TestTmuxEditSectionCopies(t *testing.T) { - cfg := buildFeatureApp() - got := cfg.TmuxEditSection() - if got.TmuxEditDefaultAgent != "codex" || len(got.TmuxEditAgents) != 1 { - t.Fatalf("unexpected tmux edit section: %+v", got) - } - got.TmuxEditAgents[0].Name = "mutated" - if cfg.TmuxEditAgents[0].Name == "mutated" { - t.Fatal("TmuxEditSection did not return a defensive copy") - } -} - func TestTmuxActionSectionCopies(t *testing.T) { cfg := App{} cfg.TmuxActionMenu = []TmuxActionMenuEntry{{Kind: "rewrite"}} diff --git a/internal/appconfig/app_sections.go b/internal/appconfig/app_sections.go index 5919db1..afa7bf0 100644 --- a/internal/appconfig/app_sections.go +++ b/internal/appconfig/app_sections.go @@ -111,7 +111,6 @@ type PromptConfig struct { type FeatureConfig struct { StatsConfig // usage statistics window IgnoreConfig // gitignore-aware file filtering for LSP - TmuxEditConfig // popup editor settings for hexai-tmux-edit TmuxActionConfig // configurable main menu for hexai-tmux-action MCPConfig // Model Context Protocol server settings } @@ -200,7 +199,6 @@ func (a *App) ApplyPromptSection(prompts PromptConfig) { func (a *App) FeatureSection() FeatureConfig { f := a.FeatureConfig f.IgnoreExtraPatterns = slices.Clone(a.IgnoreExtraPatterns) - f.TmuxEditAgents = append([]TmuxEditAgentCfg{}, a.TmuxEditAgents...) f.TmuxActionMenu = append([]TmuxActionMenuEntry{}, a.TmuxActionMenu...) return f } @@ -210,6 +208,5 @@ func (a *App) FeatureSection() FeatureConfig { func (a *App) ApplyFeatureSection(features FeatureConfig) { a.FeatureConfig = features a.IgnoreExtraPatterns = slices.Clone(features.IgnoreExtraPatterns) - a.TmuxEditAgents = append([]TmuxEditAgentCfg{}, features.TmuxEditAgents...) a.TmuxActionMenu = append([]TmuxActionMenuEntry{}, features.TmuxActionMenu...) } diff --git a/internal/appconfig/app_sections_test.go b/internal/appconfig/app_sections_test.go index bcd1cbe..2ff002d 100644 --- a/internal/appconfig/app_sections_test.go +++ b/internal/appconfig/app_sections_test.go @@ -28,7 +28,6 @@ func TestSectionsDefensiveCopies(t *testing.T) { sections.Providers.CLIConfigs[0].Model = "mutated" sections.Prompts.CustomActions[0].Title = "mutated" sections.Features.IgnoreExtraPatterns[0] = "mutated" - sections.Features.TmuxEditAgents[0].Name = "mutated" assertNotEqual(t, cfg.TriggerCharacters[0], "mutated", "trigger characters") assertNotEqual(t, cfg.ChatPrefixes[0], "mutated", "chat prefixes") @@ -36,7 +35,6 @@ func TestSectionsDefensiveCopies(t *testing.T) { assertNotEqual(t, cfg.CLIConfigs[0].Model, "mutated", "cli configs") assertNotEqual(t, cfg.CustomActions[0].Title, "mutated", "custom actions") assertNotEqual(t, cfg.IgnoreExtraPatterns[0], "mutated", "ignore patterns") - assertNotEqual(t, cfg.TmuxEditAgents[0].Name, "mutated", "tmux agents") out := cfg.Sections() out.Core.TriggerCharacters[0] = "mutated" @@ -45,7 +43,6 @@ func TestSectionsDefensiveCopies(t *testing.T) { out.Providers.CLIConfigs[0].Model = "mutated" out.Prompts.CustomActions[0].Title = "mutated" out.Features.IgnoreExtraPatterns[0] = "mutated" - out.Features.TmuxEditAgents[0].Name = "mutated" assertNotEqual(t, cfg.TriggerCharacters[0], "mutated", "sections trigger characters") assertNotEqual(t, cfg.ChatPrefixes[0], "mutated", "sections chat prefixes") @@ -53,7 +50,6 @@ func TestSectionsDefensiveCopies(t *testing.T) { assertNotEqual(t, cfg.CLIConfigs[0].Model, "mutated", "sections cli configs") assertNotEqual(t, cfg.CustomActions[0].Title, "mutated", "sections custom actions") assertNotEqual(t, cfg.IgnoreExtraPatterns[0], "mutated", "sections ignore patterns") - assertNotEqual(t, cfg.TmuxEditAgents[0].Name, "mutated", "sections tmux agents") } func assertNotEqual(t *testing.T, got, want, field string) { @@ -175,23 +171,6 @@ func testFeatureConfig() FeatureConfig { IgnoreExtraPatterns: []string{"vendor/**", "tmp/**"}, IgnoreLSPNotify: sectionBoolPtr(false), }, - TmuxEditConfig: TmuxEditConfig{ - TmuxEditPopupWidth: "80%", - TmuxEditPopupHeight: "75%", - TmuxEditDefaultAgent: "codex", - TmuxEditAgents: []TmuxEditAgentCfg{{ - Name: "codex", - DisplayName: "Codex", - DetectPattern: "(?i)codex", - SectionPattern: "section", - PromptPattern: "prompt", - StripPatterns: []string{"x", "y"}, - ClearFirst: sectionBoolPtr(true), - ClearKeys: "C-u", - NewlineKeys: "S-Enter", - SubmitKeys: "Enter", - }}, - }, MCPConfig: MCPConfig{ MCPPromptsDir: ".hexai/prompts", MCPSlashCommandSync: true, diff --git a/internal/appconfig/config_features_test.go b/internal/appconfig/config_features_test.go index 2b8c769..3d94c77 100644 --- a/internal/appconfig/config_features_test.go +++ b/internal/appconfig/config_features_test.go @@ -1,4 +1,4 @@ -// Tests for ignore config, tmux-edit config, and low-level parsing helpers +// Tests for ignore config and low-level parsing helpers // (temperature, model entries, surface entries, resolved model). package appconfig @@ -119,106 +119,6 @@ gitignore = false } } -func TestTmuxEditConfig_FromFile(t *testing.T) { - clearHexaiEnv(t) - dir := t.TempDir() - cfgPath := filepath.Join(dir, "config.toml") - writeFile(t, cfgPath, ` -[tmux_edit] -popup_width = "90%" -popup_height = "85%" -default_agent = "claude" - -[[tmux_edit.agents]] -name = "claude" -display_name = "Claude Code" -detect_pattern = "(?i)(claude|anthropic)" -prompt_pattern = '(?s)>\s*(.+?)$' -clear_first = true -clear_keys = "C-u" -newline_keys = "S-Enter" -submit_keys = "Enter" - -[[tmux_edit.agents]] -name = "cursor" -display_name = "Cursor" -detect_pattern = "(?i)cursor" -prompt_pattern = '(?s)│\s*(.+?)$' -strip_patterns = ["INSERT", "Add a follow-up"] -clear_first = true -clear_keys = "C-u" -newline_keys = "S-Enter" -submit_keys = "Enter" -`) - cfg := LoadWithOptions(context.Background(), newLogger(), LoadOptions{ConfigPath: cfgPath, ProjectRoot: dir}) - if cfg.TmuxEditPopupWidth != "90%" { - t.Errorf("PopupWidth = %q, want 90%%", cfg.TmuxEditPopupWidth) - } - if cfg.TmuxEditPopupHeight != "85%" { - t.Errorf("PopupHeight = %q, want 85%%", cfg.TmuxEditPopupHeight) - } - if cfg.TmuxEditDefaultAgent != "claude" { - t.Errorf("DefaultAgent = %q, want claude", cfg.TmuxEditDefaultAgent) - } - if len(cfg.TmuxEditAgents) != 2 { - t.Fatalf("got %d agents, want 2", len(cfg.TmuxEditAgents)) - } - a := cfg.TmuxEditAgents[0] - if a.Name != "claude" || a.DisplayName != "Claude Code" { - t.Errorf("agent[0] = %q/%q, want claude/Claude Code", a.Name, a.DisplayName) - } - if a.ClearFirst == nil || !*a.ClearFirst { - t.Error("expected ClearFirst = true for claude agent") - } - b := cfg.TmuxEditAgents[1] - if b.Name != "cursor" { - t.Errorf("agent[1].Name = %q, want cursor", b.Name) - } - if len(b.StripPatterns) != 2 { - t.Errorf("agent[1].StripPatterns = %v, want 2 entries", b.StripPatterns) - } -} - -func TestTmuxEditConfig_Merge(t *testing.T) { - clearHexaiEnv(t) - a := newDefaultConfig() - b := App{ - FeatureConfig: FeatureConfig{TmuxEditConfig: TmuxEditConfig{ - TmuxEditPopupWidth: "70%", - TmuxEditDefaultAgent: "amp", - TmuxEditAgents: []TmuxEditAgentCfg{ - {Name: "amp", DisplayName: "Amp"}, - }, - }}, - } - a.mergeWith(&b) - if a.TmuxEditPopupWidth != "70%" { - t.Errorf("PopupWidth = %q, want 70%%", a.TmuxEditPopupWidth) - } - if a.TmuxEditDefaultAgent != "amp" { - t.Errorf("DefaultAgent = %q, want amp", a.TmuxEditDefaultAgent) - } - if len(a.TmuxEditAgents) != 1 || a.TmuxEditAgents[0].Name != "amp" { - t.Errorf("Agents = %v, want single amp", a.TmuxEditAgents) - } -} - -func TestTmuxEditConfig_SkipsEmptyName(t *testing.T) { - clearHexaiEnv(t) - dir := t.TempDir() - cfgPath := filepath.Join(dir, "config.toml") - writeFile(t, cfgPath, ` -[tmux_edit] -[[tmux_edit.agents]] -name = "" -display_name = "Empty" -`) - cfg := LoadWithOptions(context.Background(), newLogger(), LoadOptions{ConfigPath: cfgPath, ProjectRoot: dir}) - if len(cfg.TmuxEditAgents) != 0 { - t.Errorf("got %d agents, want 0 (empty name should be skipped)", len(cfg.TmuxEditAgents)) - } -} - // --- Config Parsing Tests --- func TestParseTemperatureValue(t *testing.T) { diff --git a/internal/appconfig/config_load.go b/internal/appconfig/config_load.go index ccbf49f..052050b 100644 --- a/internal/appconfig/config_load.go +++ b/internal/appconfig/config_load.go @@ -296,7 +296,6 @@ func applyPromptSections(fc *fileConfig, out *App) { func applyFeatureSections(fc *fileConfig, out *App) { applyTmuxSection(fc, out) applyStatsSection(fc, out) - fc.applyTmuxEdit(out) applyMCPSection(fc, out) applyTmuxActionSection(fc, out) } @@ -564,37 +563,6 @@ func setIfNotBlank(dst *string, value string) { } } -// applyTmuxEdit converts the [tmux_edit] section into App fields. -func (fc *fileConfig) applyTmuxEdit(out *App) { - te := fc.TmuxEdit - if strings.TrimSpace(te.PopupWidth) != "" { - out.TmuxEditPopupWidth = strings.TrimSpace(te.PopupWidth) - } - if strings.TrimSpace(te.PopupHeight) != "" { - out.TmuxEditPopupHeight = strings.TrimSpace(te.PopupHeight) - } - if strings.TrimSpace(te.DefaultAgent) != "" { - out.TmuxEditDefaultAgent = strings.TrimSpace(te.DefaultAgent) - } - for _, a := range te.Agents { - if strings.TrimSpace(a.Name) == "" { - continue - } - out.TmuxEditAgents = append(out.TmuxEditAgents, TmuxEditAgentCfg{ - Name: strings.TrimSpace(a.Name), - DisplayName: strings.TrimSpace(a.DisplayName), - DetectPattern: strings.TrimSpace(a.DetectPattern), - SectionPattern: strings.TrimSpace(a.SectionPattern), - PromptPattern: strings.TrimSpace(a.PromptPattern), - StripPatterns: a.StripPatterns, - ClearFirst: a.ClearFirst, - ClearKeys: strings.TrimSpace(a.ClearKeys), - NewlineKeys: strings.TrimSpace(a.NewlineKeys), - SubmitKeys: strings.TrimSpace(a.SubmitKeys), - }) - } -} - func parseSurfaceModels(raw map[string]any, logger *log.Logger) *App { modelsRaw, ok := raw["models"] if !ok { diff --git a/internal/appconfig/config_merge.go b/internal/appconfig/config_merge.go index f3557c1..e5ad6a5 100644 --- a/internal/appconfig/config_merge.go +++ b/internal/appconfig/config_merge.go @@ -10,7 +10,6 @@ func (a *App) mergeWith(other *App) { a.mergeProviderFields(other) a.mergeSurfaceModels(other) a.mergePrompts(other) - a.mergeTmuxEdit(other) a.mergeTmuxAction(other) } @@ -237,19 +236,3 @@ func (a *App) mergeTmuxAction(other *App) { a.TmuxActionMenu = append([]TmuxActionMenuEntry{}, other.TmuxActionMenu...) } } - -// mergeTmuxEdit copies non-empty tmux edit settings from other. -func (a *App) mergeTmuxEdit(other *App) { - if s := strings.TrimSpace(other.TmuxEditPopupWidth); s != "" { - a.TmuxEditPopupWidth = s - } - if s := strings.TrimSpace(other.TmuxEditPopupHeight); s != "" { - a.TmuxEditPopupHeight = s - } - if s := strings.TrimSpace(other.TmuxEditDefaultAgent); s != "" { - a.TmuxEditDefaultAgent = s - } - if len(other.TmuxEditAgents) > 0 { - a.TmuxEditAgents = append([]TmuxEditAgentCfg{}, other.TmuxEditAgents...) - } -} diff --git a/internal/appconfig/config_types.go b/internal/appconfig/config_types.go index 7069b21..8b046e6 100644 --- a/internal/appconfig/config_types.go +++ b/internal/appconfig/config_types.go @@ -48,21 +48,6 @@ type TmuxActionMenuEntry struct { Hotkey string // optional single-character hotkey override } -// TmuxEditAgentCfg describes an AI agent's detection and interaction patterns -// for the tmux popup editor (hexai-tmux-edit). -type TmuxEditAgentCfg struct { - Name string - DisplayName string - DetectPattern string - SectionPattern string - PromptPattern string - StripPatterns []string - ClearFirst *bool - ClearKeys string - NewlineKeys string - SubmitKeys string -} - // LoadOptions tune how configuration is loaded at runtime. type LoadOptions struct { // IgnoreEnv skips applying environment overrides when true. @@ -170,7 +155,6 @@ type fileConfig struct { Tmux sectionTmux `toml:"tmux"` Stats sectionStats `toml:"stats"` Ignore sectionIgnore `toml:"ignore"` - TmuxEdit sectionTmuxEdit `toml:"tmux_edit"` TmuxAction sectionTmuxAction `toml:"tmux_action"` MCP sectionMCP `toml:"mcp"` } @@ -225,28 +209,6 @@ type sectionIgnore struct { LSPNotifyIgnored *bool `toml:"lsp_notify_ignored"` } -// sectionTmuxEdit configures the tmux popup editor feature (hexai-tmux-edit). -type sectionTmuxEdit struct { - PopupWidth string `toml:"popup_width"` - PopupHeight string `toml:"popup_height"` - DefaultAgent string `toml:"default_agent"` - Agents []sectionTmuxEditAgent `toml:"agents"` -} - -// sectionTmuxEditAgent defines detection and interaction patterns for one AI agent. -type sectionTmuxEditAgent struct { - Name string `toml:"name"` - DisplayName string `toml:"display_name"` - DetectPattern string `toml:"detect_pattern"` - SectionPattern string `toml:"section_pattern"` - PromptPattern string `toml:"prompt_pattern"` - StripPatterns []string `toml:"strip_patterns"` - ClearFirst *bool `toml:"clear_first"` - ClearKeys string `toml:"clear_keys"` - NewlineKeys string `toml:"newline_keys"` - SubmitKeys string `toml:"submit_keys"` -} - // sectionMCP configures the MCP server settings. type sectionMCP struct { PromptsDir string `toml:"prompts_dir"` diff --git a/internal/appconfig/feature_sections.go b/internal/appconfig/feature_sections.go index 9216400..b4010b8 100644 --- a/internal/appconfig/feature_sections.go +++ b/internal/appconfig/feature_sections.go @@ -1,9 +1,9 @@ package appconfig // This file defines the cohesive per-subsystem config structs that make up -// FeatureConfig. The old FeatureConfig was a grab-bag that mixed five unrelated -// non-LLM subsystems (ignore filtering, stats, tmux popup editor, tmux action -// menu, MCP server). Splitting them into named structs documents the seams +// FeatureConfig. The old FeatureConfig was a grab-bag that mixed four unrelated +// non-LLM subsystems (ignore filtering, stats, tmux action menu, MCP server). +// Splitting them into named structs documents the seams // between subsystems and lets consumers depend on a single subsystem's config // (via the *Section accessors on App) instead of the whole App God-struct. // @@ -29,14 +29,6 @@ type StatsConfig struct { StatsWindowMinutes int `json:"-"` } -// TmuxEditConfig configures the tmux popup editor feature (hexai-tmux-edit). -type TmuxEditConfig struct { - TmuxEditPopupWidth string `json:"-"` - TmuxEditPopupHeight string `json:"-"` - TmuxEditDefaultAgent string `json:"-"` - TmuxEditAgents []TmuxEditAgentCfg `json:"-"` -} - // TmuxActionConfig configures the main menu for hexai-tmux-action. type TmuxActionConfig struct { TmuxActionMenu []TmuxActionMenuEntry `json:"-"` 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 --g