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 --- .gitignore | 2 - Magefile.go | 14 +- README.md | 7 - cmd/hexai-tmux-edit/main.go | 63 ---- cmd/hexai-tmux-edit/main_test.go | 111 ------ config.toml.example | 26 -- docs/buildandinstall.md | 3 +- docs/configuration.md | 30 -- docs/tmux-edit-popup.png | Bin 66443 -> 0 bytes docs/tmux-edit-result.png | Bin 79061 -> 0 bytes docs/tmux.md | 33 -- docs/usage.md | 56 --- 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 +- prompts/tmux-edit-integration-tests.md | 445 ------------------------ 41 files changed, 8 insertions(+), 3767 deletions(-) delete mode 100644 cmd/hexai-tmux-edit/main.go delete mode 100644 cmd/hexai-tmux-edit/main_test.go delete mode 100644 docs/tmux-edit-popup.png delete mode 100644 docs/tmux-edit-result.png 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 delete mode 100644 prompts/tmux-edit-integration-tests.md diff --git a/.gitignore b/.gitignore index 3b8714e..1c4e03b 100644 --- a/.gitignore +++ b/.gitignore @@ -5,7 +5,6 @@ /hexai-lsp-server /hexai-mcp-server /hexai-tmux-action -/hexai-tmux-edit /bin/ # Coverage artifacts (mage coverage, mage covercheck) @@ -14,7 +13,6 @@ docs/coverage.html docs/coverage/ # Temp/scratch files -.tmux-edit-send.*.md /.local/ /.gomodcache/ /*.out diff --git a/Magefile.go b/Magefile.go index 9fc5be7..4c636ef 100644 --- a/Magefile.go +++ b/Magefile.go @@ -39,7 +39,7 @@ func Default() error { // Build builds binaries. func Build() error { printCoverage() - mg.Deps(BuildAsk, BuildHexaiLSP, BuildHexaiCLI, BuildHexaiTmuxAction, BuildHexaiTmuxEdit) + mg.Deps(BuildAsk, BuildHexaiLSP, BuildHexaiCLI, BuildHexaiTmuxAction) return nil } @@ -67,12 +67,6 @@ func BuildHexaiTmuxAction() error { return sh.RunV("go", "build", "-o", "hexai-tmux-action", "./cmd/hexai-tmux-action") } -// 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") -} - // BuildHexaiMCPServer builds the MCP server binary (DEPRECATED - experimental, not actively maintained). func BuildHexaiMCPServer() error { printCoverage() @@ -92,10 +86,7 @@ func Dev() error { if err := sh.RunV("go", "build", "-race", "-o", binaryName, "./cmd/hexai"); err != nil { return err } - if err := sh.RunV("go", "build", "-race", "-o", "hexai-tmux-action", "./cmd/hexai-tmux-action"); err != nil { - return err - } - return sh.RunV("go", "build", "-race", "-o", "hexai-tmux-edit", "./cmd/hexai-tmux-edit") + return sh.RunV("go", "build", "-race", "-o", "hexai-tmux-action", "./cmd/hexai-tmux-action") } // Run launches the LSP server via go run (useful during development). @@ -134,7 +125,6 @@ func Install() error { "hexai-lsp-server", binaryName, "hexai-tmux-action", - "hexai-tmux-edit", } { if err := atomicInstallBinary(filepath.Join(".", name), bin); err != nil { return err diff --git a/README.md b/README.md index 8f468fa..3c10092 100644 --- a/README.md +++ b/README.md @@ -37,11 +37,6 @@ It has got improved capabilities for Go code understanding (for example, create - Fully configurable menu via `[[tmux_action.menu]]` — reorder, remove, rename, rebind hotkeys, embed custom actions directly in main menu - All action prompts overridable via `[prompts.code_action]` in `config.toml` - Custom prompt action opens your editor (`$HEXAI_EDITOR` or `$EDITOR`) on a temporary Markdown file -* Tmux popup editor (`hexai-tmux-edit`) for composing longer AI agent prompts - - Opens `$EDITOR` in a tmux popup, pre-filled with the current prompt text - - Auto-detects Cursor, Amp, Aider (WIP), and other agents - - OpenAI Codex CLI and Claude Code CLI have native external-editor support via `Ctrl+G` - - Config-driven: add new agents via `[tmux_edit]` in config.toml * Support for Ollama (local + Ollama Cloud), OpenAI, OpenRouter, Anthropic, and You.com (YouSearch Research API) — Ollama Cloud (`kimi-k2.6` at `https://ollama.com`) is the default > **Note on hexai-mcp-server:** This component is currently experimental and not actively maintained. The author manages prompts through slash commands and meta-commands in the hexai agent system, making the MCP server redundant for its original purpose. The code is preserved for potential future enhancements with different functionality beyond prompt management. See the [MCP documentation](docs/mcp-setup.md) for reference only. @@ -71,9 +66,7 @@ hexai follows the XDG Base Directory Specification: - `stats.json` - LLM usage tracking (regenerable) - `stats.lock` - File lock for stats access - **State & Logs:** `~/.local/hexai/state/` (or `$XDG_STATE_HOME/state/`) - - `tmux-edit-history.jsonl` - History of text submitted via tmux popup - `hexai-lsp-server.log` - LSP server debug logs - - `hexai-tmux-edit.log` - Tmux edit debug logs - `hexai-mcp-server.log` - MCP server debug logs - **Data:** `~/.local/hexai/data/` (or `$XDG_DATA_HOME/`) - `prompts/user.jsonl` - User-created custom prompts (built-in prompts are compiled into the binary) diff --git a/cmd/hexai-tmux-edit/main.go b/cmd/hexai-tmux-edit/main.go deleted file mode 100644 index d61f68a..0000000 --- a/cmd/hexai-tmux-edit/main.go +++ /dev/null @@ -1,63 +0,0 @@ -// 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 ] [--agent ] [--pane ] -// -// Tmux keybinding (add to ~/.tmux.conf): -// -// bind e run-shell -b "cd '#{pane_current_path}' && hexai-tmux-edit --pane '#{pane_id}'" -package main - -import ( - "flag" - "fmt" - "io" - "os" - "strings" - - "codeberg.org/snonux/hexai/internal/appconfig" - "codeberg.org/snonux/hexai/internal/tmuxedit" -) - -type app struct { - runTmuxEdit func(tmuxedit.Options) error -} - -func newApp() *app { return &app{runTmuxEdit: tmuxedit.Run} } - -func main() { os.Exit(newApp().runMain(os.Args[1:], os.Stderr)) } - -// runMain parses flags from args and runs the tmux edit popup. It returns -// the process exit code; flag errors return 2 (matching stdlib convention), -// runtime failures return 1. -func (a *app) runMain(args []string, stderr io.Writer) int { - defaultPath := appconfig.DefaultConfigPath() - fs := flag.NewFlagSet("hexai-tmux-edit", flag.ContinueOnError) - fs.SetOutput(stderr) - configPath := fs.String("config", "", fmt.Sprintf("path to config file (default: %s)", defaultPath)) - agent := fs.String("agent", "", "AI agent name (auto-detected if omitted)") - pane := fs.String("pane", "", "tmux target pane ID (e.g. %5)") - if err := fs.Parse(args); err != nil { - return 2 - } - - opts := buildOptions(*configPath, *agent, *pane) - if err := a.runTmuxEdit(opts); err != nil { - fmt.Fprintln(stderr, err) - return 1 - } - return 0 -} - -// buildOptions constructs tmuxedit.Options from the parsed flag values, -// trimming whitespace from each field. -func buildOptions(configPath, agent, pane string) tmuxedit.Options { - return tmuxedit.Options{ - ConfigPath: strings.TrimSpace(configPath), - Agent: strings.TrimSpace(agent), - Pane: strings.TrimSpace(pane), - } -} diff --git a/cmd/hexai-tmux-edit/main_test.go b/cmd/hexai-tmux-edit/main_test.go deleted file mode 100644 index 3171b86..0000000 --- a/cmd/hexai-tmux-edit/main_test.go +++ /dev/null @@ -1,111 +0,0 @@ -package main - -import ( - "bytes" - "errors" - "strings" - "testing" - - "codeberg.org/snonux/hexai/internal/tmuxedit" -) - -func TestBuildOptions_AllEmpty(t *testing.T) { - opts := buildOptions("", "", "") - if opts.ConfigPath != "" || opts.Agent != "" || opts.Pane != "" { - t.Fatalf("expected all empty, got %+v", opts) - } -} - -func TestBuildOptions_TrimsWhitespace(t *testing.T) { - opts := buildOptions(" /tmp/cfg.toml ", " claude ", " %5 ") - if opts.ConfigPath != "/tmp/cfg.toml" { - t.Fatalf("expected trimmed config path, got %q", opts.ConfigPath) - } - if opts.Agent != "claude" { - t.Fatalf("expected trimmed agent, got %q", opts.Agent) - } - if opts.Pane != "%5" { - t.Fatalf("expected trimmed pane, got %q", opts.Pane) - } -} - -func TestRunTmuxEdit_Success(t *testing.T) { - var gotOpts tmuxedit.Options - a := &app{runTmuxEdit: func(opts tmuxedit.Options) error { - gotOpts = opts - return nil - }} - - opts := buildOptions("/tmp/cfg.toml", "cursor", "%3") - if err := a.runTmuxEdit(opts); err != nil { - t.Fatalf("runTmuxEdit: %v", err) - } - if gotOpts.ConfigPath != "/tmp/cfg.toml" || gotOpts.Agent != "cursor" || gotOpts.Pane != "%3" { - t.Fatalf("unexpected opts: %+v", gotOpts) - } -} - -func TestRunTmuxEdit_Error(t *testing.T) { - wantErr := errors.New("tmux not found") - a := &app{runTmuxEdit: func(_ tmuxedit.Options) error { return wantErr }} - - if err := a.runTmuxEdit(tmuxedit.Options{}); !errors.Is(err, wantErr) { - t.Fatalf("expected error, got: %v", err) - } -} - -// runMain happy path: flags parse, runTmuxEdit returns nil, exit code 0. -// We capture the resolved Options to confirm flags map onto fields correctly. -func TestRunMain_FlagsForwardedToTmuxedit(t *testing.T) { - var got tmuxedit.Options - a := &app{runTmuxEdit: func(opts tmuxedit.Options) error { - got = opts - return nil - }} - - var stderr bytes.Buffer - code := a.runMain([]string{"-config", " /tmp/cfg.toml ", "-agent", "claude", "-pane", "%9"}, &stderr) - if code != 0 { - t.Fatalf("runMain code = %d, want 0", code) - } - if got.ConfigPath != "/tmp/cfg.toml" || got.Agent != "claude" || got.Pane != "%9" { - t.Fatalf("unexpected opts: %+v", got) - } - if stderr.Len() != 0 { - t.Fatalf("stderr should be empty on success, got %q", stderr.String()) - } -} - -// runMain reports tmuxedit.Run failures by writing to stderr and returning 1 -// — the production exit code that the shipped binary uses. -func TestRunMain_RunErrorReturnsOne(t *testing.T) { - a := &app{runTmuxEdit: func(tmuxedit.Options) error { return errors.New("boom") }} - - var stderr bytes.Buffer - code := a.runMain(nil, &stderr) - if code != 1 { - t.Fatalf("runMain code = %d, want 1", code) - } - if !strings.Contains(stderr.String(), "boom") { - t.Fatalf("stderr missing error: %q", stderr.String()) - } -} - -// Unknown flags must yield exit 2 (the convention used by stdlib `flag` when -// ExitOnError aborts) without ever invoking runTmuxEdit. -func TestRunMain_BadFlagReturnsTwo(t *testing.T) { - called := false - a := &app{runTmuxEdit: func(tmuxedit.Options) error { - called = true - return nil - }} - - var stderr bytes.Buffer - code := a.runMain([]string{"--no-such-flag"}, &stderr) - if code != 2 { - t.Fatalf("runMain code = %d, want 2", code) - } - if called { - t.Fatal("runTmuxEdit must not be called on flag-parse failure") - } -} diff --git a/config.toml.example b/config.toml.example index 5526e7f..9018a3d 100644 --- a/config.toml.example +++ b/config.toml.example @@ -200,29 +200,3 @@ research_effort = "standard" # 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 (checked in order): cursor, amp, aider. -# OpenAI Codex CLI and Claude Code CLI both support external editor mode via -# Ctrl+G, so no built-in tmux_edit agent profiles are needed for them. -# - cursor: Box UI │...│, clears with End+BSpace*200 -# - amp: Box UI │...│ (TUI mode), clears with C-u (Emacs/readline) -# - aider: Shell-style > prompt, clears with C-u (Emacs/readline) -# Tmux keybinding (add to ~/.tmux.conf): -# bind e run-shell -b "cd '#{pane_current_path}' && hexai-tmux-edit --pane '#{pane_id}'" - -# [[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/docs/buildandinstall.md b/docs/buildandinstall.md index 8e7a4f8..94de7b4 100644 --- a/docs/buildandinstall.md +++ b/docs/buildandinstall.md @@ -3,7 +3,7 @@ Hexai uses Mage for developer tasks. Install Mage, then run targets like build, dev, test, and install. - Install Mage: `go install github.com/magefile/mage@latest` -- Build binaries: `mage build` (produces `ask`, `hexai`, `hexai-lsp-server`, `hexai-tmux-action`, and `hexai-tmux-edit`) +- Build binaries: `mage build` (produces `ask`, `hexai`, `hexai-lsp-server`, and `hexai-tmux-action`) - Dev build (+ tests, vet, lint): `mage dev` - Run tests: `mage test` - Run tests with coverage: `go test ./... -cover` @@ -23,4 +23,3 @@ Either use the Mage method as mentioned above, or install directly with: - CLI: `go install codeberg.org/snonux/hexai/cmd/hexai@latest` - LSP: `go install codeberg.org/snonux/hexai/cmd/hexai-lsp-server@latest` - Action runner: `go install codeberg.org/snonux/hexai/cmd/hexai-tmux-action@latest` -- Tmux popup editor: `go install codeberg.org/snonux/hexai/cmd/hexai-tmux-edit@latest` diff --git a/docs/configuration.md b/docs/configuration.md index fd5a703..96cfa0b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -157,33 +157,3 @@ All prompts used by `hexai-tmux-action` (and the LSP code actions) can be overri | `fix_typos_*` | Fix typos and improve grammar and clarity | User templates support `{{selection}}` (always available) and `{{diagnostics}}` (diagnostics scope). See [config.toml.example](../config.toml.example) for the full defaults. - -Hexai Tmux Edit (popup editor) - -- `hexai-tmux-edit` opens `$EDITOR` in a tmux popup for composing longer AI agent prompts. -- Configure popup dimensions and agent detection patterns in the `[tmux_edit]` section: - - ```toml - [tmux_edit] - popup_width = "80%" - popup_height = "80%" - # default_agent = "claude" # force agent; skip auto-detect - ``` - -- Override or add agent definitions with `[[tmux_edit.agents]]` (merged with built-in defaults by name): - - ```toml - [[tmux_edit.agents]] - name = "claude" - display_name = "Claude Code" - detect_pattern = "(?i)(claude|anthropic)" - prompt_pattern = '(?m)>\s*(.+)$' - clear_first = true - clear_keys = "C-u" - newline_keys = "S-Enter" - submit_keys = "Enter" - ``` - -- Built-in agents: `cursor`, `amp`, `aider`. See [config.toml.example](../config.toml.example) for all fields. -- OpenAI Codex CLI and Claude Code CLI use their native external editor shortcut (`Ctrl+G`) instead of a built-in `tmux_edit` agent profile. -- Tmux keybinding: `bind e run-shell -b "cd '#{pane_current_path}' && hexai-tmux-edit --pane '#{pane_id}'"` diff --git a/docs/tmux-edit-popup.png b/docs/tmux-edit-popup.png deleted file mode 100644 index 5985d55..0000000 Binary files a/docs/tmux-edit-popup.png and /dev/null differ diff --git a/docs/tmux-edit-result.png b/docs/tmux-edit-result.png deleted file mode 100644 index 1d7f26d..0000000 Binary files a/docs/tmux-edit-result.png and /dev/null differ diff --git a/docs/tmux.md b/docs/tmux.md index ba024e3..a5e397c 100644 --- a/docs/tmux.md +++ b/docs/tmux.md @@ -68,36 +68,3 @@ window_minutes = 60 # default 60; min 1, max 1440 ``` - The tmux status shows the window as `Σ@1h` or `Σ@45m`. - -## Popup editor for AI agent prompts - -`hexai-tmux-edit` opens your `$EDITOR` in a tmux popup to compose longer prompts when working with AI CLI agents (Cursor, Amp, Aider, etc.). - -OpenAI Codex CLI and Claude Code CLI both support editing in an external editor natively via `Ctrl+G`, so neither needs a built-in `hexai-tmux-edit` agent profile. - -![Popup editor in action](tmux-edit-popup.png) - -The editor opens as a tmux popup overlay, pre-filled with any existing prompt text from the agent's input. After saving and closing, the text is sent back: - -![Text sent back to the agent](tmux-edit-result.png) - -*(Screenshots from the [original blog post](https://foo.zone/gemfeed/2026-02-02-tmux-popup-editor-for-cursor-agent-prompts.html) showing the concept with Cursor Agent.)* - -Add this keybinding to `~/.tmux.conf`: - -``` -bind e run-shell -b "cd '#{pane_current_path}' && hexai-tmux-edit --pane '#{pane_id}'" -``` - -Then press `prefix + e` in any pane running an AI agent. Hexai auto-detects the agent, extracts any existing prompt text, and pre-fills the editor. After saving and closing, the edited text is sent back to the agent's pane. - -See the [configuration guide](configuration.md) for customizing popup dimensions and agent patterns, or the [usage guide](usage.md) for the full workflow description. - -**Input mode notes**: Each agent uses different clearing methods based on their input handling: -- **Cursor**: Uses simple backspace clearing (`End BSpace*200`) -- **Amp**: Uses Emacs/readline keybindings (`C-u`) -- **Aider**: Uses Emacs/readline keybindings (`C-u`) - -The popup editor uses `$EDITOR` (or `$HEXAI_EDITOR`), so your normal vim/neovim setup is used for composing prompts. - -**Note**: Agent detection and prompt extraction rely on regex patterns matched against each agent's terminal UI (box-drawing characters, prompt symbols, status text). When agents update their TUI layout, these patterns may need adjustment. You can override patterns per-agent in `[[tmux_edit.agents]]` config without code changes -- see the [configuration guide](configuration.md). diff --git a/docs/usage.md b/docs/usage.md index c6c55db..14a85ba 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -275,62 +275,6 @@ Tips: - Ensure Helix runs inside tmux to see the status updates. - You can also set a language-specific binding in `languages.toml` if preferred. -## Hexai Tmux Edit (Popup Editor) - -`hexai-tmux-edit` opens your `$EDITOR` in a tmux popup for composing longer 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`. - -This is useful when working with AI CLI agents (Cursor, Amp, Aider, etc.) and you need to compose a longer, multi-line prompt with the comfort of your regular editor (spellcheck, search/replace, etc.). - -OpenAI Codex CLI and Claude Code CLI are not built-in `hexai-tmux-edit` agents. Both support editing in an external editor natively via `Ctrl+G`. - -### Supported agents - -Built-in agent detection (auto-detected from pane content, checked in order): - -1. **Cursor** -- detects box-drawing UI `│ →` or footer `/ commands · @ files` - - Clears with: `End BSpace*200` (backspace method) - - Prompt pattern: Extracts from last `│...│` box -2. **Amp** -- detects "amp" or "sourcegraph" in pane (TUI mode) - - Clears with: `C-u` (Emacs/readline style) - - Prompt pattern: Extracts from `│...│` box UI (similar to Cursor) -3. **Aider** -- detects "aider" in pane - - Clears with: `C-u` (Emacs/readline style) - - Prompt pattern: Shell-style `> prompt` - -**Detection order matters**: Cursor is checked first to avoid false positives. For example, Cursor may display "Claude 4.5 Sonnet" as its model name, but Cursor's distinctive `│ →` box UI is matched first. - -Additional agents can be added via `[tmux_edit.agents]` in config.toml without code changes. - -### Tmux keybinding - -Add to `~/.tmux.conf`: - -``` -bind e run-shell -b "cd '#{pane_current_path}' && hexai-tmux-edit --pane '#{pane_id}'" -``` - -The `#{pane_id}` is expanded by tmux to the active pane at keypress time, so the popup editor always knows which pane to send text back to. - -### Flags - -- `--config` path to config file (default: `$XDG_CONFIG_HOME/hexai/config.toml`) -- `--agent` explicit agent name (auto-detected if omitted) -- `--pane` tmux target pane ID (e.g. `%5`) - -### Workflow - -1. Press your tmux keybinding (e.g. `prefix + e`) -2. A tmux popup opens with your `$EDITOR`, pre-filled with any existing prompt text -3. Edit or compose your prompt -4. Save and close the editor -5. The edited text is sent to the agent's pane via `tmux send-keys` - -If you keep the original text unchanged and append new text, only the appended text is sent. If you rewrite the prompt entirely, the full new text is sent. If you save an empty file or don't change anything, nothing is sent. - -### Configuration - -See `[tmux_edit]` in [config.toml.example](../config.toml.example) for all options, including custom popup dimensions and agent overrides. - ### Slash commands Type a slash command at the end of a chat line (for example `/? reload>`). Available commands: 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") - } -