summaryrefslogtreecommitdiff
path: root/internal/appconfig
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-02-08 11:14:36 +0200
committerPaul Buetow <paul@buetow.org>2026-02-08 11:14:36 +0200
commit5e825543dc55a2c649e68dce6341844ad71fa217 (patch)
treef7aae1c1d130f08c383f95a23413bdde7843dc0f /internal/appconfig
parent023ed82e612451caa38ec46106ed9d148ab9a595 (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.go92
-rw-r--r--internal/appconfig/config_test.go98
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))
+ }
+}