diff options
| -rw-r--r-- | Magefile.go | 50 | ||||
| -rw-r--r-- | cmd/hexai-tmux-edit/main.go | 48 | ||||
| -rw-r--r-- | config.toml.example | 32 | ||||
| -rw-r--r-- | internal/appconfig/config.go | 92 | ||||
| -rw-r--r-- | internal/appconfig/config_test.go | 98 | ||||
| -rw-r--r-- | internal/tmuxedit/agent.go | 212 | ||||
| -rw-r--r-- | internal/tmuxedit/agent_test.go | 260 | ||||
| -rw-r--r-- | internal/tmuxedit/capture.go | 17 | ||||
| -rw-r--r-- | internal/tmuxedit/capture_test.go | 51 | ||||
| -rw-r--r-- | internal/tmuxedit/pane.go | 42 | ||||
| -rw-r--r-- | internal/tmuxedit/pane_test.go | 83 | ||||
| -rw-r--r-- | internal/tmuxedit/run.go | 148 | ||||
| -rw-r--r-- | internal/tmuxedit/run_test.go | 320 | ||||
| -rw-r--r-- | internal/tmuxedit/send.go | 74 | ||||
| -rw-r--r-- | internal/tmuxedit/send_test.go | 170 |
15 files changed, 1678 insertions, 19 deletions
diff --git a/Magefile.go b/Magefile.go index 1644f08..fb43238 100644 --- a/Magefile.go +++ b/Magefile.go @@ -24,7 +24,7 @@ var ( // Build builds binaries. func Build() error { - mg.Deps(BuildHexaiLSP, BuildHexaiCLI, BuildHexaiTmuxAction) + mg.Deps(BuildHexaiLSP, BuildHexaiCLI, BuildHexaiTmuxAction, BuildHexaiTmuxEdit) printCoverage() return nil } @@ -47,6 +47,12 @@ func BuildHexaiTmuxAction() error { return sh.RunV("go", "build", "-o", "hexai-tmux-action", "cmd/hexai-tmux-action/main.go") } +// BuildHexaiTmuxEdit builds the hexai-tmux-edit popup editor binary. +func BuildHexaiTmuxEdit() error { + printCoverage() + return sh.RunV("go", "build", "-o", "hexai-tmux-edit", "cmd/hexai-tmux-edit/main.go") +} + // Dev runs tests, vet, lint, then builds with race for both binaries. func Dev() error { printCoverage() @@ -57,7 +63,10 @@ func Dev() error { if err := sh.RunV("go", "build", "-race", "-o", "hexai", "cmd/hexai/main.go"); err != nil { return err } - return sh.RunV("go", "build", "-race", "-o", "hexai-tmux-action", "cmd/hexai-tmux-action/main.go") + if err := sh.RunV("go", "build", "-race", "-o", "hexai-tmux-action", "cmd/hexai-tmux-action/main.go"); err != nil { + return err + } + return sh.RunV("go", "build", "-race", "-o", "hexai-tmux-edit", "cmd/hexai-tmux-edit/main.go") } // Run launches the LSP server via go run (useful during development). @@ -97,7 +106,10 @@ func Install() error { if err := sh.RunV("cp", "-v", "./hexai", bin+"/"); err != nil { return err } - return sh.RunV("cp", "-v", "./hexai-tmux-action", bin+"/") + if err := sh.RunV("cp", "-v", "./hexai-tmux-action", bin+"/"); err != nil { + return err + } + return sh.RunV("cp", "-v", "./hexai-tmux-edit", bin+"/") } // RunTmuxAction runs the hexai-tmux-action TUI via go run (reads stdin). @@ -109,8 +121,8 @@ func RunTmuxAction() error { // printCoverage prints a warning if an existing coverage profile shows total < coverateThreshold. func printCoverage() { - // Ensure the top-level coverage profile is refreshed at least once per day. - ensureDailyCoverage(24 * time.Hour) + // Ensure the top-level coverage profile is refreshed at least once per day. + ensureDailyCoverage(24 * time.Hour) select { case coveragePrinted <- struct{}{}: default: @@ -126,20 +138,20 @@ func printCoverage() { fmt.Println("[coverage] No coverage profile found (run 'mage cover' or 'mage coverall').") return } - pct, ok := totalCoveragePercent(profile) - if !ok { - // Attempt a one-time regen if the profile is malformed - if err := Coverage(); err == nil { - if p2, ok2 := totalCoveragePercent(profile); ok2 { - pct = p2 - ok = true - } - } - } - if !ok { - fmt.Println("[coverage] Could not parse total coverage from", profile) - return - } + pct, ok := totalCoveragePercent(profile) + if !ok { + // Attempt a one-time regen if the profile is malformed + if err := Coverage(); err == nil { + if p2, ok2 := totalCoveragePercent(profile); ok2 { + pct = p2 + ok = true + } + } + } + if !ok { + fmt.Println("[coverage] Could not parse total coverage from", profile) + return + } if pct < coverageThreshold { fmt.Printf("[coverage] WARNING: total test coverage is %.1f%% (< %.1f%%)\n", pct, coverageThreshold) } else { diff --git a/cmd/hexai-tmux-edit/main.go b/cmd/hexai-tmux-edit/main.go new file mode 100644 index 0000000..928a2cd --- /dev/null +++ b/cmd/hexai-tmux-edit/main.go @@ -0,0 +1,48 @@ +// hexai-tmux-edit opens a tmux popup with $EDITOR for composing AI agent +// prompts. It captures existing prompt text from the target pane, pre-fills +// the editor, and sends the edited text back via tmux send-keys. +// +// Usage: +// +// hexai-tmux-edit [--config <path>] [--agent <name>] [--pane <id>] +// +// Tmux keybinding (add to ~/.tmux.conf): +// +// bind e run-shell -b "hexai-tmux-edit --pane '#{pane_id}'" +package main + +import ( + "flag" + "fmt" + "os" + "strings" + + "codeberg.org/snonux/hexai/internal/appconfig" + "codeberg.org/snonux/hexai/internal/tmuxedit" +) + +func main() { + defaultPath := defaultConfigPath() + configPath := flag.String("config", "", fmt.Sprintf("path to config file (default: %s)", defaultPath)) + agent := flag.String("agent", "", "AI agent name (auto-detected if omitted)") + pane := flag.String("pane", "", "tmux target pane ID (e.g. %%5)") + flag.Parse() + + opts := tmuxedit.Options{ + ConfigPath: strings.TrimSpace(*configPath), + Agent: strings.TrimSpace(*agent), + Pane: strings.TrimSpace(*pane), + } + if err := tmuxedit.Run(opts); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func defaultConfigPath() string { + path, err := appconfig.ConfigPath() + if err != nil { + return "$XDG_CONFIG_HOME/hexai/config.toml" + } + return path +} diff --git a/config.toml.example b/config.toml.example index f732300..cc4471d 100644 --- a/config.toml.example +++ b/config.toml.example @@ -153,3 +153,35 @@ temperature = 0.2 # gitignore = true # respect .gitignore patterns (default: true) # extra_patterns = ["*.min.js", "vendor/**", "*.generated.go"] # lsp_notify_ignored = true # show "file ignored" in LSP completions (default: true) + +[tmux_edit] +# popup_width = "80%" # tmux popup width (default: 80%) +# popup_height = "80%" # tmux popup height (default: 80%) +# default_agent = "" # force agent name; skip auto-detect + +# Override or add agent definitions (merged with built-in defaults by name). +# Built-in agents: claude, cursor, amp, aider. +# Tmux keybinding (add to ~/.tmux.conf): +# bind e run-shell -b "hexai-tmux-edit --pane '#{pane_id}'" + +# [[tmux_edit.agents]] +# name = "claude" +# display_name = "Claude Code" +# detect_pattern = "(?i)(claude|anthropic)" +# prompt_pattern = '(?m)>\s*(.+)$' +# strip_patterns = [] +# 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 = '(?m)│\s*(.+)$' +# strip_patterns = ["INSERT", "Add a follow-up"] +# clear_first = true +# clear_keys = "C-u" +# newline_keys = "S-Enter" +# submit_keys = "Enter" diff --git a/internal/appconfig/config.go b/internal/appconfig/config.go index 8ec29ae..b21a4de 100644 --- a/internal/appconfig/config.go +++ b/internal/appconfig/config.go @@ -118,6 +118,12 @@ type App struct { IgnoreGitignore *bool `json:"-" toml:"-"` IgnoreExtraPatterns []string `json:"-" toml:"-"` IgnoreLSPNotify *bool `json:"-" toml:"-"` + + // TmuxEdit: popup editor settings for hexai-tmux-edit + TmuxEditPopupWidth string `json:"-" toml:"-"` + TmuxEditPopupHeight string `json:"-" toml:"-"` + TmuxEditDefaultAgent string `json:"-" toml:"-"` + TmuxEditAgents []TmuxEditAgentCfg `json:"-" toml:"-"` } // CustomAction describes a user-defined code action. @@ -132,6 +138,20 @@ type CustomAction struct { User string // optional; if set, render with available vars } +// 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 + PromptPattern string + StripPatterns []string + ClearFirst *bool + ClearKeys string + NewlineKeys string + SubmitKeys string +} + // Constructor: defaults for App (kept first among functions) func newDefaultConfig() App { // Coding-friendly default temperature across providers @@ -281,6 +301,7 @@ type fileConfig struct { Tmux sectionTmux `toml:"tmux"` Stats sectionStats `toml:"stats"` Ignore sectionIgnore `toml:"ignore"` + TmuxEdit sectionTmuxEdit `toml:"tmux_edit"` } type sectionGeneral struct { @@ -333,6 +354,27 @@ 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"` + 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"` +} + type sectionOpenAI struct { Model string `toml:"model"` BaseURL string `toml:"base_url"` @@ -659,9 +701,42 @@ func (fc *fileConfig) toApp() App { out.mergeBasics(&tmp) } + // tmux_edit + fc.applyTmuxEdit(&out) + return out } +// 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), + 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 loadFromFile(path string, logger *log.Logger) (*App, error) { b, err := os.ReadFile(path) if err != nil { @@ -900,6 +975,7 @@ func (a *App) mergeWith(other *App) { a.mergeProviderFields(other) a.mergeSurfaceModels(other) a.mergePrompts(other) + a.mergeTmuxEdit(other) } // mergeBasics merges general (non-provider) fields. @@ -1060,6 +1136,22 @@ func (a *App) mergePrompts(other *App) { } // Validate checks custom actions and tmux settings for duplicates and consistency. +// 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...) + } +} + func (a App) Validate() error { // Normalize and check duplicates for IDs and hotkeys seenID := make(map[string]struct{}) diff --git a/internal/appconfig/config_test.go b/internal/appconfig/config_test.go index b9dfe3a..6b8ee5b 100644 --- a/internal/appconfig/config_test.go +++ b/internal/appconfig/config_test.go @@ -893,3 +893,101 @@ gitignore = false t.Error("expected IgnoreLSPNotify to remain true (default)") } } + +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(newLogger(), LoadOptions{ConfigPath: cfgPath}) + 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{ + 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(newLogger(), LoadOptions{ConfigPath: cfgPath}) + if len(cfg.TmuxEditAgents) != 0 { + t.Errorf("got %d agents, want 0 (empty name should be skipped)", len(cfg.TmuxEditAgents)) + } +} diff --git a/internal/tmuxedit/agent.go b/internal/tmuxedit/agent.go new file mode 100644 index 0000000..2e07824 --- /dev/null +++ b/internal/tmuxedit/agent.go @@ -0,0 +1,212 @@ +// Package tmuxedit implements a tmux popup editor for composing AI agent prompts. +// agent.go defines agent detection, prompt extraction, and noise stripping. +package tmuxedit + +import ( + "regexp" + "strings" + + "codeberg.org/snonux/hexai/internal/appconfig" +) + +// AgentConfig describes how to detect and interact with a specific AI agent +// running in a tmux pane. All behavior is driven by regex patterns so new +// agents can be added via config without code changes. +type AgentConfig struct { + Name string // short key: "claude", "cursor", "amp" + DisplayName string // human-readable: "Claude Code" + DetectPattern string // regex matched against pane content for auto-detection + PromptPattern string // regex with capture group (1) to extract current 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 (e.g. "C-u") + NewlineKeys string // tmux key to insert a newline (e.g. "S-Enter") + SubmitKeys string // tmux key to submit the prompt (e.g. "Enter") +} + +// builtinAgents returns the default set of agent configurations. These are +// overridden/extended by user config in [tmux_edit.agents]. +func builtinAgents() []AgentConfig { + return []AgentConfig{ + { + Name: "claude", + DisplayName: "Claude Code", + DetectPattern: `(?i)(claude|anthropic)`, + PromptPattern: `(?m)>\s*(.+)$`, + ClearFirst: true, + ClearKeys: "C-u", + NewlineKeys: "S-Enter", + SubmitKeys: "Enter", + }, + { + Name: "cursor", + DisplayName: "Cursor", + DetectPattern: `(?i)cursor`, + PromptPattern: `(?m)│\s*(.+)$`, + StripPatterns: []string{"INSERT", "Add a follow-up"}, + ClearFirst: true, + ClearKeys: "C-u", + NewlineKeys: "S-Enter", + SubmitKeys: "Enter", + }, + { + Name: "amp", + DisplayName: "Amp", + DetectPattern: `(?i)(amp|sourcegraph)`, + PromptPattern: `(?m)>\s*(.+)$`, + ClearFirst: true, + ClearKeys: "C-u", + NewlineKeys: "S-Enter", + SubmitKeys: "Enter", + }, + { + Name: "aider", + DisplayName: "Aider", + DetectPattern: `(?i)aider`, + PromptPattern: `(?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() AgentConfig { + return AgentConfig{ + 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. +func resolveAgents(cfgAgents []appconfig.TmuxEditAgentCfg) []AgentConfig { + agents := builtinAgents() + for _, ca := range cfgAgents { + merged := false + for i, a := range agents { + if !strings.EqualFold(a.Name, ca.Name) { + continue + } + agents[i] = mergeAgentConfig(a, ca) + merged = true + break + } + if !merged { + agents = append(agents, agentFromConfig(ca)) + } + } + return agents +} + +// mergeAgentConfig overrides fields in base with non-zero values from cfg. +func mergeAgentConfig(base AgentConfig, cfg appconfig.TmuxEditAgentCfg) AgentConfig { + 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.PromptPattern); s != "" { + base.PromptPattern = 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 + } + return base +} + +// agentFromConfig creates a new AgentConfig from a user config entry. +func agentFromConfig(cfg appconfig.TmuxEditAgentCfg) AgentConfig { + a := AgentConfig{ + Name: strings.TrimSpace(cfg.Name), + DisplayName: strings.TrimSpace(cfg.DisplayName), + DetectPattern: strings.TrimSpace(cfg.DetectPattern), + PromptPattern: 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 { + a.ClearFirst = *cfg.ClearFirst + } + if a.DisplayName == "" { + a.DisplayName = a.Name + } + return a +} + +// detectAgent tries each agent's DetectPattern against pane content. +// First match wins. Returns genericAgent() if no agent matches. +func detectAgent(paneContent string, agents []AgentConfig) AgentConfig { + for _, a := range agents { + if a.DetectPattern == "" { + continue + } + re, err := regexp.Compile(a.DetectPattern) + if err != nil { + continue + } + if re.MatchString(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 []AgentConfig) AgentConfig { + for _, a := range agents { + if strings.EqualFold(a.Name, name) { + return a + } + } + return genericAgent() +} + +// extractPrompt uses the agent's PromptPattern to extract the current prompt +// text from pane content. Returns empty string if no pattern or no match. +func extractPrompt(paneContent string, agent AgentConfig) string { + if agent.PromptPattern == "" { + return "" + } + re, err := regexp.Compile(agent.PromptPattern) + if err != nil { + return "" + } + m := re.FindStringSubmatch(paneContent) + if len(m) < 2 { + return "" + } + text := m[1] + return stripNoise(text, agent.StripPatterns) +} + +// 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) +} diff --git a/internal/tmuxedit/agent_test.go b/internal/tmuxedit/agent_test.go new file mode 100644 index 0000000..a6bc20d --- /dev/null +++ b/internal/tmuxedit/agent_test.go @@ -0,0 +1,260 @@ +package tmuxedit + +import ( + "testing" + + "codeberg.org/snonux/hexai/internal/appconfig" +) + +func boolP(b bool) *bool { return &b } + +func TestDetectAgent(t *testing.T) { + agents := builtinAgents() + tests := []struct { + name string + content string + want string + }{ + {"claude from banner", "Welcome to Claude Code v1.2\n> ", "claude"}, + {"claude from anthropic", "Powered by Anthropic\n> ", "claude"}, + {"cursor from prompt", "cursor agent ready\n│ type here", "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 + }{ + {"claude", "claude"}, + {"Claude", "claude"}, + {"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 TestExtractPrompt(t *testing.T) { + tests := []struct { + name string + content string + agent AgentConfig + want string + }{ + { + name: "claude prompt", + content: "Claude Code v1\n> hello world", + agent: builtinAgents()[0], // claude + want: "hello world", + }, + { + name: "cursor prompt with strip", + content: "Cursor Agent\n│ fix the bug INSERT", + agent: builtinAgents()[1], // cursor + want: "fix the bug", + }, + { + name: "cursor prompt strips follow-up", + content: "Cursor\n│ Add a follow-up", + agent: builtinAgents()[1], // cursor + want: "", + }, + { + name: "no pattern", + content: "some text", + agent: genericAgent(), + want: "", + }, + { + name: "no match", + content: "no prompt here", + agent: builtinAgents()[0], // claude + want: "", + }, + { + name: "invalid regex", + content: "> test", + agent: AgentConfig{PromptPattern: "[invalid"}, + want: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := extractPrompt(tt.content, tt.agent) + if got != tt.want { + t.Errorf("extractPrompt() = %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 TestResolveAgents_MergeOverride(t *testing.T) { + cfgAgents := []appconfig.TmuxEditAgentCfg{ + { + Name: "claude", + DisplayName: "My Claude", + ClearFirst: boolP(false), + }, + } + agents := resolveAgents(cfgAgents) + var claude AgentConfig + for _, a := range agents { + if a.Name == "claude" { + claude = a + break + } + } + if claude.DisplayName != "My Claude" { + t.Errorf("DisplayName = %q, want My Claude", claude.DisplayName) + } + if claude.ClearFirst { + t.Error("ClearFirst should be false after override") + } + // DetectPattern should be preserved from builtin + if claude.DetectPattern == "" { + t.Error("DetectPattern should be preserved from builtin") + } +} + +func TestResolveAgents_MergeAllFields(t *testing.T) { + cfgAgents := []appconfig.TmuxEditAgentCfg{ + { + Name: "claude", + DisplayName: "Custom Claude", + DetectPattern: "(?i)custom-claude", + PromptPattern: `>\s+(.*)$`, + StripPatterns: []string{"NOISE"}, + ClearFirst: boolP(true), + ClearKeys: "C-k", + NewlineKeys: "C-Enter", + SubmitKeys: "C-m", + }, + } + agents := resolveAgents(cfgAgents) + var a AgentConfig + for _, ag := range agents { + if ag.Name == "claude" { + a = ag + break + } + } + if a.DetectPattern != "(?i)custom-claude" { + t.Errorf("DetectPattern = %q", a.DetectPattern) + } + if a.PromptPattern != `>\s+(.*)$` { + t.Errorf("PromptPattern = %q", a.PromptPattern) + } + if len(a.StripPatterns) != 1 || a.StripPatterns[0] != "NOISE" { + t.Errorf("StripPatterns = %v", a.StripPatterns) + } + if a.ClearKeys != "C-k" { + t.Errorf("ClearKeys = %q", a.ClearKeys) + } + if a.NewlineKeys != "C-Enter" { + t.Errorf("NewlineKeys = %q", a.NewlineKeys) + } + if a.SubmitKeys != "C-m" { + t.Errorf("SubmitKeys = %q", a.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) + } + if !a.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 TestDetectAgent_InvalidRegex(t *testing.T) { + agents := []AgentConfig{ + {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) + } + if g.SubmitKeys != "Enter" { + t.Errorf("SubmitKeys = %q, want Enter", g.SubmitKeys) + } +} diff --git a/internal/tmuxedit/capture.go b/internal/tmuxedit/capture.go new file mode 100644 index 0000000..2af5698 --- /dev/null +++ b/internal/tmuxedit/capture.go @@ -0,0 +1,17 @@ +package tmuxedit + +import ( + "fmt" + "strings" +) + +// capturePane retrieves the visible content of a tmux pane via +// `tmux capture-pane -p -t <paneID>`. The -p flag prints to stdout +// instead of to a paste buffer. +var capturePane = func(paneID string) (string, error) { + out, err := runCommand("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 new file mode 100644 index 0000000..40d0e98 --- /dev/null +++ b/internal/tmuxedit/capture_test.go @@ -0,0 +1,51 @@ +package tmuxedit + +import ( + "fmt" + "testing" +) + +func TestCapturePane_Success(t *testing.T) { + old := runCommand + defer func() { runCommand = old }() + 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 := capturePane("%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) { + old := runCommand + defer func() { runCommand = old }() + runCommand = func(string, ...string) ([]byte, error) { + return nil, fmt.Errorf("pane not found") + } + _, err := capturePane("%999") + if err == nil { + t.Fatal("expected error for failed capture") + } +} + +func TestCapturePane_EmptyContent(t *testing.T) { + old := runCommand + defer func() { runCommand = old }() + runCommand = func(string, ...string) ([]byte, error) { + return []byte("\n\n"), nil + } + got, err := capturePane("%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/pane.go b/internal/tmuxedit/pane.go new file mode 100644 index 0000000..aae2d69 --- /dev/null +++ b/internal/tmuxedit/pane.go @@ -0,0 +1,42 @@ +package tmuxedit + +import ( + "fmt" + "os" + "os/exec" + "strings" +) + +// runCommand is the seam for exec.Command().Output(). Override in tests. +var runCommand = func(name string, args ...string) ([]byte, error) { + 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) { + // 1. Explicit --pane flag + if p := strings.TrimSpace(flagPane); p != "" { + return p, nil |
