summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/appconfig/app_feature_sections.go7
-rw-r--r--internal/appconfig/app_feature_sections_test.go12
-rw-r--r--internal/appconfig/app_sections.go3
-rw-r--r--internal/appconfig/app_sections_test.go21
-rw-r--r--internal/appconfig/config_features_test.go102
-rw-r--r--internal/appconfig/config_load.go32
-rw-r--r--internal/appconfig/config_merge.go17
-rw-r--r--internal/appconfig/config_types.go38
-rw-r--r--internal/appconfig/feature_sections.go14
-rw-r--r--internal/tmuxedit/agent.go149
-rw-r--r--internal/tmuxedit/agent_test.go157
-rw-r--r--internal/tmuxedit/agentutil.go183
-rw-r--r--internal/tmuxedit/agentutil_test.go265
-rw-r--r--internal/tmuxedit/capture.go23
-rw-r--r--internal/tmuxedit/capture_test.go45
-rw-r--r--internal/tmuxedit/config_agent.go135
-rw-r--r--internal/tmuxedit/config_agent_test.go182
-rw-r--r--internal/tmuxedit/cursor_agent.go58
-rw-r--r--internal/tmuxedit/cursor_agent_test.go183
-rw-r--r--internal/tmuxedit/history.go111
-rw-r--r--internal/tmuxedit/history_test.go326
-rw-r--r--internal/tmuxedit/pane.go63
-rw-r--r--internal/tmuxedit/pane_test.go73
-rw-r--r--internal/tmuxedit/run.go268
-rw-r--r--internal/tmuxedit/run_test.go340
-rw-r--r--internal/tmuxedit/send.go66
-rw-r--r--internal/tmuxedit/send_test.go110
-rw-r--r--internal/version.go2
28 files changed, 5 insertions, 2980 deletions
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) &l