diff options
| author | Paul Buetow <paul@buetow.org> | 2026-02-08 11:14:36 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-02-08 11:14:36 +0200 |
| commit | 5e825543dc55a2c649e68dce6341844ad71fa217 (patch) | |
| tree | f7aae1c1d130f08c383f95a23413bdde7843dc0f /internal/appconfig | |
| parent | 023ed82e612451caa38ec46106ed9d148ab9a595 (diff) | |
add hexai-tmux-edit: tmux popup editor for AI agent prompts
New tool that opens $EDITOR in a tmux popup for composing longer prompts
when working with AI CLI agents (Claude Code, Cursor, Amp, Aider, etc.).
Captures existing prompt text from the target pane, pre-fills the editor,
and sends edited text back via tmux send-keys. Config-driven agent
detection via regex patterns in [tmux_edit] config section.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'internal/appconfig')
| -rw-r--r-- | internal/appconfig/config.go | 92 | ||||
| -rw-r--r-- | internal/appconfig/config_test.go | 98 |
2 files changed, 190 insertions, 0 deletions
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)) + } +} |
