summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore9
-rw-r--r--AGENTS.md3
-rw-r--r--CLAUDE.md1
-rw-r--r--Magefile.go52
-rw-r--r--README.md6
-rw-r--r--cmd/hexai-tmux-edit/main.go48
-rw-r--r--config.toml.example47
-rw-r--r--docs/buildandinstall.md3
-rw-r--r--docs/configuration.md42
-rw-r--r--docs/tmux-edit-popup.pngbin0 -> 66443 bytes
-rw-r--r--docs/tmux-edit-result.pngbin0 -> 79061 bytes
-rw-r--r--docs/tmux.md32
-rw-r--r--docs/usage.md57
-rw-r--r--go.mod1
-rw-r--r--go.sum8
-rw-r--r--internal/appconfig/config.go220
-rw-r--r--internal/appconfig/config_test.go391
-rw-r--r--internal/hexailsp/run.go16
-rw-r--r--internal/ignore/checker.go90
-rw-r--r--internal/ignore/checker_test.go282
-rw-r--r--internal/lsp/handlers_codeaction.go8
-rw-r--r--internal/lsp/handlers_completion.go12
-rw-r--r--internal/lsp/handlers_document.go4
-rw-r--r--internal/lsp/handlers_ignore.go41
-rw-r--r--internal/lsp/ignore_test.go175
-rw-r--r--internal/lsp/server.go10
-rw-r--r--internal/tmuxedit/agent.go134
-rw-r--r--internal/tmuxedit/agent_test.go119
-rw-r--r--internal/tmuxedit/agentutil.go165
-rw-r--r--internal/tmuxedit/agentutil_test.go206
-rw-r--r--internal/tmuxedit/capture.go17
-rw-r--r--internal/tmuxedit/capture_test.go51
-rw-r--r--internal/tmuxedit/claude_agent.go85
-rw-r--r--internal/tmuxedit/claude_agent_test.go125
-rw-r--r--internal/tmuxedit/config_agent.go134
-rw-r--r--internal/tmuxedit/config_agent_test.go179
-rw-r--r--internal/tmuxedit/cursor_agent.go58
-rw-r--r--internal/tmuxedit/cursor_agent_test.go140
-rw-r--r--internal/tmuxedit/pane.go42
-rw-r--r--internal/tmuxedit/pane_test.go83
-rw-r--r--internal/tmuxedit/run.go211
-rw-r--r--internal/tmuxedit/run_test.go323
-rw-r--r--internal/tmuxedit/send.go47
-rw-r--r--internal/tmuxedit/send_test.go122
-rw-r--r--internal/version.go2
-rw-r--r--prompts/tmux-edit-integration-tests.md445
46 files changed, 4214 insertions, 32 deletions
diff --git a/.gitignore b/.gitignore
index 69e7e7f..a285ccb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,7 +1,16 @@
/hexai
/hexai-lsp
/hexai-tmux-action
+/hexai-tmux-edit
/bin/
+
+# Coverage artifacts (mage coverage, mage covercheck)
+docs/coverage.out
+docs/coverage.html
+docs/coverage/
+
+# Temp/scratch files
+.tmux-edit-send.*.md
/.gocache/
/.gocache*
/.gomodcache/
diff --git a/AGENTS.md b/AGENTS.md
index 598b0f8..4009b7f 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -10,7 +10,7 @@
- Avoid duplication of code when the functions are larger than 5 lines.
- If possible, construct individual methods so that they can be unit tested. But only if it doesn't add too much boilerplate to the code base.
-- Aim for at least 85% unit test coverage of all source code. The command to check the coverage is "mage coverage"
+- Aim for at least 80% unit test coverage of all source code. The command to check the coverage is "mage coverage"
- Ensure that all unit tests pass before commiting any changes.
- Always run the gofumpt code reformatter on all go files modified.
- There should be no source code file larger than 1000 lines. If so, split it up into multiple.
@@ -25,3 +25,4 @@
- Whenever incrementing the version, update the version number in the project, commit to git, tag the version and push to git.
- When a major feature was introduced, increment ?.X.?
- When only minor changes were done or only bugs were fixed, increment the version as ?.?.X
+
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..43c994c
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1 @@
+@AGENTS.md
diff --git a/Magefile.go b/Magefile.go
index fdf5389..fb43238 100644
--- a/Magefile.go
+++ b/Magefile.go
@@ -18,13 +18,13 @@ import (
var (
Default = Build // Default target: build all binaries.
- coverageThreshold float64 = 85
+ coverageThreshold float64 = 80
coveragePrinted = make(chan struct{}, 1)
)
// 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/README.md b/README.md
index 88ee031..a8f7901 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,11 @@ It has got improved capabilities for Go code understanding (for example, create
* Stand-alone command line tool for LLM interaction
* Parallel completions and CLI responses from multiple providers/models for side-by-side comparison
* TUI AI code-action runner (`hexai-tmux-action`) with Bubble Tea
- - Includes a “Custom prompt” action (hotkey `p`) that opens your editor (`$HEXAI_EDITOR` or `$EDITOR`) on a temporary Markdown file.
+ - Includes a "Custom prompt" action (hotkey `p`) that 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 Claude Code, Cursor, Amp, Aider, and other agents
+ - Config-driven: add new agents via `[tmux_edit]` in config.toml
* Support for OpenAI, OpenRouter, Anthropic, and Ollama
## Documentation
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 bb8165d..f2bd66f 100644
--- a/config.toml.example
+++ b/config.toml.example
@@ -1,4 +1,10 @@
# Hexai sectioned config example
+#
+# Global location: $XDG_CONFIG_HOME/hexai/config.toml (usually ~/.config/hexai/config.toml)
+#
+# Per-project overrides: place a .hexaiconfig.toml at the root of a git
+# repository. It uses the same format and selectively overrides the global
+# config. Environment variables (HEXAI_*) always take precedence over both.
[general]
max_tokens = 4000
@@ -142,3 +148,44 @@ temperature = 0.2
[stats]
# window_minutes = 60 # sliding window for global stats (Σ@window); min 1, max 1440
+
+[ignore]
+# 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, claude, amp, aider.
+# - cursor: Box UI │...│, clears with End+BSpace*200
+# - claude: Prompt symbol ❯, clears with C-a C-k (Emacs/readline)
+# - 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 "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/docs/buildandinstall.md b/docs/buildandinstall.md
index 7400eda..89a7c57 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 `hexai`, `hexai-lsp`, and `hexai-tmux-action`)
+- Build binaries: `mage build` (produces `hexai`, `hexai-lsp`, `hexai-tmux-action`, and `hexai-tmux-edit`)
- Dev build (+ tests, vet, lint): `mage dev`
- Run tests: `mage test`
- Run tests with coverage: `go test ./... -cover`
@@ -21,3 +21,4 @@ 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@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 f4469a9..52e0689 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -2,15 +2,22 @@
This page explains where the config lives and how to choose a style; the authoritative list of options and comments lives in the example file.
-Config file
+Global config file
- Location: `$XDG_CONFIG_HOME/hexai/config.toml` (usually `~/.config/hexai/config.toml`).
- Style: sectioned tables only — see [config.toml.example](../config.toml.example) for a complete, commented reference.
+Per-project config file
+
+- Place a `.hexaiconfig.toml` at the root of a git repository to selectively override the global config for that project.
+- Uses the same TOML format as the global config file — only specify the settings you want to override.
+- Hexai auto-detects the git repository root by walking up from the current working directory.
+- Precedence (lowest to highest): built-in defaults → global config → per-project config → environment variables.
+
Environment overrides
- All options can be overridden by environment variables prefixed with `HEXAI_`.
-- Env values take precedence over the config file.
+- Env values always take precedence over both the global and per-project config files.
- Examples:
- `HEXAI_PROVIDER`, `HEXAI_MAX_TOKENS`, `HEXAI_CONTEXT_MODE`, `HEXAI_CONTEXT_WINDOW_LINES`, `HEXAI_MAX_CONTEXT_TOKENS`, `HEXAI_LOG_PREVIEW_LIMIT`, `HEXAI_REQUEST_TIMEOUT`
- `HEXAI_CODING_TEMPERATURE`
@@ -103,6 +110,35 @@ See the [tmux integration guide](docs/tmux.md) for details on configuring the st
Code action prompts
-- All prompts can be customized under `[prompts.code_action]` in `config.toml`. In addition to `rewrite_*`, `diagnostics_*`, `document_*`, and `go_test_*`, the following templates control the “Simplify and improve” action:
+- All prompts can be customized under `[prompts.code_action]` in `config.toml`. In addition to `rewrite_*`, `diagnostics_*`, `document_*`, and `go_test_*`, the following templates control the \u201cSimplify and improve\u201d action:
- `simplify_system`
- `simplify_user` (uses `{{selection}}`)
+
+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: `claude`, `cursor`, `amp`, `aider`. See [config.toml.example](../config.toml.example) for all fields.
+- Tmux keybinding: `bind e run-shell -b "hexai-tmux-edit --pane '#{pane_id}'"`
diff --git a/docs/tmux-edit-popup.png b/docs/tmux-edit-popup.png
new file mode 100644
index 0000000..5985d55
--- /dev/null
+++ b/docs/tmux-edit-popup.png
Binary files differ
diff --git a/docs/tmux-edit-result.png b/docs/tmux-edit-result.png
new file mode 100644
index 0000000..1d7f26d
--- /dev/null
+++ b/docs/tmux-edit-result.png
Binary files differ
diff --git a/docs/tmux.md b/docs/tmux.md
index a5e397c..e7c99dd 100644
--- a/docs/tmux.md
+++ b/docs/tmux.md
@@ -68,3 +68,35 @@ 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 (Claude Code, Cursor, Amp, Aider, etc.).
+
+![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 "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:
+- **Claude Code**: Uses Emacs/readline keybindings (`C-a C-k`)
+- **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 49ed4e6..387ad34 100644
--- a/docs/usage.md
+++ b/docs/usage.md
@@ -151,6 +151,63 @@ 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 (Claude Code, 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.).
+
+### 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. **Claude Code** -- detects `❯` prompt symbol, "claude code", or "anthropic"
+ - Clears with: `C-a C-k` (Emacs/readline style)
+ - Prompt pattern: Extracts from last section between `─────` rules
+3. **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)
+4. **Aider** -- detects "aider" in pane
+ - Clears with: `C-u` (Emacs/readline style)
+ - Prompt pattern: Shell-style `> prompt`
+
+**Detection order matters**: Cursor and Claude are 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 "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/go.mod b/go.mod
index 5e6dfd2..6ae1d08 100644
--- a/go.mod
+++ b/go.mod
@@ -26,6 +26,7 @@ require (
github.com/muesli/reflow v0.3.0 // indirect
github.com/muesli/termenv v0.15.2 // indirect
github.com/rivo/uniseg v0.4.6 // indirect
+ github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 // indirect
github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f // indirect
golang.org/x/sync v0.1.0 // indirect
golang.org/x/sys v0.12.0 // indirect
diff --git a/go.sum b/go.sum
index 2ce28eb..33a6a77 100644
--- a/go.sum
+++ b/go.sum
@@ -10,6 +10,7 @@ github.com/charmbracelet/lipgloss v0.9.1 h1:PNyd3jvaJbg4jRHKWXnCj1akQm4rh8dbEzN1
github.com/charmbracelet/lipgloss v0.9.1/go.mod h1:1mPmG4cxScwUQALAAnacHaigiiHB9Pmr+v1VEawJl6I=
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY=
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
@@ -33,12 +34,17 @@ github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo
github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.6 h1:Sovz9sDSwbOz9tgUy8JpT+KgCkPYJEN/oYzlJiYTNLg=
github.com/rivo/uniseg v0.4.6/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
+github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 h1:OkMGxebDjyw0ULyrTYWeN0UNCCkmCWfjPnIA2W6oviI=
+github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06/go.mod h1:+ePHsJ1keEjQtpvf9HHw0f4ZeJ0TLRsxhunSI2hYJSs=
github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f h1:MvTmaQdww/z0Q4wrYjDSCcZ78NoftLQyHBSLW/Cx79Y=
github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -49,3 +55,5 @@ golang.org/x/term v0.6.0 h1:clScbb1cHjoCkyRbWwBEUZ5H/tIFu5TAXIqaZD0Gcjw=
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/internal/appconfig/config.go b/internal/appconfig/config.go
index 3077d42..63b5ea5 100644
--- a/internal/appconfig/config.go
+++ b/internal/appconfig/config.go
@@ -113,6 +113,17 @@ type App struct {
TmuxCustomMenuHotkey string `json:"-" toml:"-"`
// Stats
StatsWindowMinutes int `json:"-" toml:"-"`
+
+ // Ignore: gitignore-aware file filtering for LSP
+ 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.
@@ -127,6 +138,21 @@ 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
+ SectionPattern 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
@@ -180,9 +206,15 @@ func newDefaultConfig() App {
// Stats
StatsWindowMinutes: 60,
+
+ // Ignore: respect .gitignore by default, notify in LSP by default
+ IgnoreGitignore: boolPtr(true),
+ IgnoreLSPNotify: boolPtr(true),
}
}
+func boolPtr(b bool) *bool { return &b }
+
// Load reads configuration from a file and merges with defaults.
// It respects the XDG Base Directory Specification.
func Load(logger *log.Logger) App { return LoadWithOptions(logger, LoadOptions{}) }
@@ -190,8 +222,12 @@ func Load(logger *log.Logger) App { return LoadWithOptions(logger, LoadOptions{}
// LoadOptions tune how configuration is loaded at runtime.
type LoadOptions struct {
// IgnoreEnv skips applying environment overrides when true.
- IgnoreEnv bool
+ IgnoreEnv bool
+ // ConfigPath overrides the global config file path (e.g. via --config flag).
ConfigPath string
+ // ProjectRoot overrides the project root directory for locating .hexaiconfig.toml.
+ // When empty, findGitRoot() is used to auto-detect from the current working directory.
+ ProjectRoot string
}
// LoadWithOptions reads configuration and applies the requested loading options.
@@ -201,6 +237,7 @@ func LoadWithOptions(logger *log.Logger, opts LoadOptions) App {
return cfg // Return defaults if no logger is provided (e.g. in tests)
}
+ // Step 1: Load global config file
configPath := strings.TrimSpace(opts.ConfigPath)
if configPath != "" {
if fileCfg, err := loadFromFile(configPath, logger); err == nil && fileCfg != nil {
@@ -217,8 +254,12 @@ func LoadWithOptions(logger *log.Logger, opts LoadOptions) App {
}
}
+ // Step 2: Load per-project config (.hexaiconfig.toml at git repo root).
+ // Project config overrides global config but is itself overridden by env vars.
+ loadProjectConfig(logger, opts, &cfg)
+
+ // Step 3: Environment overrides (always take precedence over all config files)
if !opts.IgnoreEnv {
- // Environment overrides (take precedence over file)
if envCfg := loadFromEnv(logger); envCfg != nil {
cfg.mergeWith(envCfg)
}
@@ -226,6 +267,22 @@ func LoadWithOptions(logger *log.Logger, opts LoadOptions) App {
return cfg
}
+// loadProjectConfig attempts to load .hexaiconfig.toml from the project root and
+// merges it into cfg. Uses opts.ProjectRoot if set, otherwise auto-detects via FindGitRoot().
+func loadProjectConfig(logger *log.Logger, opts LoadOptions, cfg *App) {
+ projectRoot := strings.TrimSpace(opts.ProjectRoot)
+ if projectRoot == "" {
+ projectRoot = FindGitRoot()
+ }
+ if projectRoot == "" {
+ return
+ }
+ projectCfgPath := filepath.Join(projectRoot, ProjectConfigFilename)
+ if projCfg, err := loadFromFile(projectCfgPath, logger); err == nil && projCfg != nil {
+ cfg.mergeWith(projCfg)
+ }
+}
+
// Private helpers
// Sectioned (table-based) file format only.
type fileConfig struct {
@@ -244,6 +301,8 @@ type fileConfig struct {
Prompts sectionPrompts `toml:"prompts"`
Tmux sectionTmux `toml:"tmux"`
Stats sectionStats `toml:"stats"`
+ Ignore sectionIgnore `toml:"ignore"`
+ TmuxEdit sectionTmuxEdit `toml:"tmux_edit"`
}
type sectionGeneral struct {
@@ -288,6 +347,36 @@ type sectionStats struct {
WindowMinutes int `toml:"window_minutes"`
}
+// sectionIgnore controls gitignore-aware file filtering. Files matching
+// these patterns are skipped for completions and code actions.
+type sectionIgnore struct {
+ Gitignore *bool `toml:"gitignore"`
+ ExtraPatterns []string `toml:"extra_patterns"`
+ 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"`
+}
+
type sectionOpenAI struct {
Model string `toml:"model"`
BaseURL string `toml:"base_url"`
@@ -604,9 +693,53 @@ func (fc *fileConfig) toApp() App {
out.StatsWindowMinutes = fc.Stats.WindowMinutes
}
+ // ignore
+ if fc.Ignore.Gitignore != nil || len(fc.Ignore.ExtraPatterns) > 0 || fc.Ignore.LSPNotifyIgnored != nil {
+ tmp := App{
+ IgnoreGitignore: fc.Ignore.Gitignore,
+ IgnoreExtraPatterns: fc.Ignore.ExtraPatterns,
+ IgnoreLSPNotify: fc.Ignore.LSPNotifyIgnored,
+ }
+ 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),
+ 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 loadFromFile(path string, logger *log.Logger) (*App, error) {
b, err := os.ReadFile(path)
if err != nil {
@@ -845,6 +978,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.
@@ -900,6 +1034,16 @@ func (a *App) mergeBasics(other *App) {
if s := strings.TrimSpace(other.Provider); s != "" {
a.Provider = s
}
+ // Ignore settings
+ if other.IgnoreGitignore != nil {
+ a.IgnoreGitignore = other.IgnoreGitignore
+ }
+ if len(other.IgnoreExtraPatterns) > 0 {
+ a.IgnoreExtraPatterns = slices.Clone(other.IgnoreExtraPatterns)
+ }
+ if other.IgnoreLSPNotify != nil {
+ a.IgnoreLSPNotify = other.IgnoreLSPNotify
+ }
}
// mergeSurfaceModels copies per-surface model and temperature overrides.
@@ -995,6 +1139,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{})
@@ -1110,6 +1270,40 @@ func ConfigPath() (string, error) {
return configPath, nil
}
+// ProjectConfigFilename is the name of the per-project config file placed at a git repo root.
+const ProjectConfigFilename = ".hexaiconfig.toml"
+
+// ProjectConfigPath returns the path to the per-project config file if a git repository
+// root is detected from the current working directory. Returns empty string otherwise.
+func ProjectConfigPath() string {
+ root := FindGitRoot()
+ if root == "" {
+ return ""
+ }
+ return filepath.Join(root, ProjectConfigFilename)
+}
+
+// FindGitRoot walks up from the current working directory to find the nearest
+// .git directory or file (worktrees use a .git file), returning its parent
+// path or "" if none is found.
+func FindGitRoot() string {
+ dir, err := os.Getwd()
+ if err != nil {
+ return ""
+ }
+ for {
+ if info, err := os.Stat(filepath.Join(dir, ".git")); err == nil &&
+ (info.IsDir() || info.Mode().IsRegular()) {
+ return dir
+ }
+ parent := filepath.Dir(dir)
+ if parent == dir {
+ return "" // reached filesystem root
+ }
+ dir = parent
+ }
+}
+
// --- Environment overrides ---
// loadFromEnv constructs an App containing only fields set via HEXAI_* env vars.
@@ -1343,6 +1537,28 @@ func loadFromEnv(logger *log.Logger) *App {
any = true
}
+ // Ignore settings (bool: "true"/"1" or "false"/"0")
+ if s := getenv("HEXAI_IGNORE_GITIGNORE"); s != "" {
+ b := s == "true" || s == "1"
+ out.IgnoreGitignore = &b
+ any = true
+ }
+ if s := getenv("HEXAI_IGNORE_EXTRA_PATTERNS"); s != "" {
+ parts := strings.Split(s, ",")
+ out.IgnoreExtraPatterns = nil
+ for _, p := range parts {
+ if t := strings.TrimSpace(p); t != "" {
+ out.IgnoreExtraPatterns = append(out.IgnoreExtraPatterns, t)
+ }
+ }
+ any = true
+ }
+ if s := getenv("HEXAI_IGNORE_LSP_NOTIFY"); s != "" {
+ b := s == "true" || s == "1"
+ out.IgnoreLSPNotify = &b
+ any = true
+ }
+
if !any {
return nil
}
diff --git a/internal/appconfig/config_test.go b/internal/appconfig/config_test.go
index ff9616b..6b8ee5b 100644
--- a/internal/appconfig/config_test.go
+++ b/internal/appconfig/config_test.go
@@ -600,3 +600,394 @@ custom_menu_hotkey = "r"
t.Fatalf("expected clash error, got %v", err)
}
}
+
+func TestFindGitRoot(t *testing.T) {
+ // Create a temp dir with a .git subdirectory to simulate a git repo
+ dir := t.TempDir()
+ gitDir := filepath.Join(dir, ".git")
+ if err := os.Mkdir(gitDir, 0o755); err != nil {
+ t.Fatalf("mkdir .git: %v", err)
+ }
+ // Create a nested subdir to test walking up
+ nested := filepath.Join(dir, "a", "b", "c")
+ if err := os.MkdirAll(nested, 0o755); err != nil {
+ t.Fatalf("mkdir nested: %v", err)
+ }
+
+ // Save and restore cwd
+ origDir, err := os.Getwd()
+ if err != nil {
+ t.Fatalf("getwd: %v", err)
+ }
+ t.Cleanup(func() { _ = os.Chdir(origDir) })
+
+ // Test from nested subdir — should find the git root
+ if err := os.Chdir(nested); err != nil {
+ t.Fatalf("chdir: %v", err)
+ }
+ root := FindGitRoot()
+ if root != dir {
+ t.Fatalf("FindGitRoot() = %q, want %q", root, dir)
+ }
+
+ // Test from a dir with no .git ancestor
+ noGit := t.TempDir()
+ if err := os.Chdir(noGit); err != nil {
+ t.Fatalf("chdir: %v", err)
+ }
+ root = FindGitRoot()
+ if root != "" {
+ t.Fatalf("FindGitRoot() = %q, want empty", root)
+ }
+}
+
+func TestLoadWithOptions_ProjectConfig(t *testing.T) {
+ clearHexaiEnv(t)
+
+ // Set up global config
+ globalDir := t.TempDir()
+ t.Setenv("XDG_CONFIG_HOME", globalDir)
+ globalCfgPath := filepath.Join(globalDir, "hexai", "config.toml")
+ writeFile(t, globalCfgPath, `
+[general]
+max_tokens = 2000
+context_mode = "always-full"
+
+[provider]
+name = "openai"
+`)
+
+ // Set up project root with .git and .hexaiconfig.toml
+ projectDir := t.TempDir()
+ if err := os.Mkdir(filepath.Join(projectDir, ".git"), 0o755); err != nil {
+ t.Fatalf("mkdir .git: %v", err)
+ }
+ writeFile(t, filepath.Join(projectDir, ProjectConfigFilename), `
+[general]
+max_tokens = 8000
+
+[provider]
+name = "anthropic"
+`)
+
+ // Load using explicit ProjectRoot (avoids needing to chdir)
+ logger := newLogger()
+ cfg := LoadWithOptions(logger, LoadOptions{ProjectRoot: projectDir})
+
+ // Project config should override global values
+ if cfg.MaxTokens != 8000 {
+ t.Fatalf("expected project max_tokens=8000, got %d", cfg.MaxTokens)
+ }
+ if cfg.Provider != "anthropic" {
+ t.Fatalf("expected project provider=anthropic, got %q", cfg.Provider)
+ }
+ // Values not overridden by project config should come from global config
+ if cfg.ContextMode != "always-full" {
+ t.Fatalf("expected global context_mode=always-full, got %q", cfg.ContextMode)
+ }
+}
+
+func TestLoadWithOptions_ProjectConfig_EnvOverridesProject(t *testing.T) {
+ clearHexaiEnv(t)
+
+ // Set up global config
+ globalDir := t.TempDir()
+ t.Setenv("XDG_CONFIG_HOME", globalDir)
+ globalCfgPath := filepath.Join(globalDir, "hexai", "config.toml")
+ writeFile(t, globalCfgPath, `
+[general]
+max_tokens = 2000
+`)
+
+ // Set up project config
+ projectDir := t.TempDir()
+ if err := os.Mkdir(filepath.Join(projectDir, ".git"), 0o755); err != nil {
+ t.Fatalf("mkdir .git: %v", err)
+ }
+ writeFile(t, filepath.Join(projectDir, ProjectConfigFilename), `
+[general]
+max_tokens = 8000
+`)
+
+ // Env var should override project config
+ withEnv(t, "HEXAI_MAX_TOKENS", "9999")
+
+ logger := newLogger()
+ cfg := LoadWithOptions(logger, LoadOptions{ProjectRoot: projectDir})
+
+ if cfg.MaxTokens != 9999 {
+ t.Fatalf("expected env max_tokens=9999 to override project, got %d", cfg.MaxTokens)
+ }
+}
+
+func TestLoadWithOptions_ProjectConfig_NoGitRoot(t *testing.T) {
+ clearHexaiEnv(t)
+
+ // Set up global config
+ globalDir := t.TempDir()
+ t.Setenv("XDG_CONFIG_HOME", globalDir)
+ globalCfgPath := filepath.Join(globalDir, "hexai", "config.toml")
+ writeFile(t, globalCfgPath, `
+[general]
+max_tokens = 2000
+`)
+
+ // No ProjectRoot, no .git — should work as before
+ noGit := t.TempDir()
+ origDir, err := os.Getwd()
+ if err != nil {
+ t.Fatalf("getwd: %v", err)
+ }
+ t.Cleanup(func() { _ = os.Chdir(origDir) })
+ if err := os.Chdir(noGit); err != nil {
+ t.Fatalf("chdir: %v", err)
+ }
+
+ logger := newLogger()
+ cfg := LoadWithOptions(logger, LoadOptions{})
+
+ // Should get global config values, not defaults
+ if cfg.MaxTokens != 2000 {
+ t.Fatalf("expected global max_tokens=2000 without project config, got %d", cfg.MaxTokens)
+ }
+}
+
+func TestProjectConfigPath(t *testing.T) {
+ // Set up a fake git repo
+ dir := t.TempDir()
+ if err := os.Mkdir(filepath.Join(dir, ".git"), 0o755); err != nil {
+ t.Fatalf("mkdir .git: %v", err)
+ }
+
+ origDir, err := os.Getwd()
+ if err != nil {
+ t.Fatalf("getwd: %v", err)
+ }
+ t.Cleanup(func() { _ = os.Chdir(origDir) })
+
+ if err := os.Chdir(dir); err != nil {
+ t.Fatalf("chdir: %v", err)
+ }
+ path := ProjectConfigPath()
+ want := filepath.Join(dir, ProjectConfigFilename)
+ if path != want {
+ t.Fatalf("ProjectConfigPath() = %q, want %q", path, want)
+ }
+
+ // No git root
+ noGit := t.TempDir()
+ if err := os.Chdir(noGit); err != nil {
+ t.Fatalf("chdir: %v", err)
+ }
+ path = ProjectConfigPath()
+ if path != "" {
+ t.Fatalf("ProjectConfigPath() = %q, want empty", path)
+ }
+}
+
+func TestIgnoreConfig_Defaults(t *testing.T) {
+ clearHexaiEnv(t)
+ cfg := Load(nil)
+ if cfg.IgnoreGitignore == nil || !*cfg.IgnoreGitignore {
+ t.Error("expected IgnoreGitignore default true")
+ }
+ if cfg.IgnoreLSPNotify == nil || !*cfg.IgnoreLSPNotify {
+ t.Error("expected IgnoreLSPNotify default true")
+ }
+ if len(cfg.IgnoreExtraPatterns) != 0 {
+ t.Errorf("expected empty IgnoreExtraPatterns, got %v", cfg.IgnoreExtraPatterns)
+ }
+}
+
+func TestIgnoreConfig_FromFile(t *testing.T) {
+ clearHexaiEnv(t)
+ dir := t.TempDir()
+ cfgPath := filepath.Join(dir, "config.toml")
+ writeFile(t, cfgPath, `
+[ignore]
+gitignore = false
+extra_patterns = ["*.min.js", "dist/**"]
+lsp_notify_ignored = false
+`)
+ cfg := LoadWithOptions(newLogger(), LoadOptions{ConfigPath: cfgPath})
+ if cfg.IgnoreGitignore == nil || *cfg.IgnoreGitignore {
+ t.Error("expected IgnoreGitignore false from file")
+ }
+ if cfg.IgnoreLSPNotify == nil || *cfg.IgnoreLSPNotify {
+ t.Error("expected IgnoreLSPNotify false from file")
+ }
+ want := []string{"*.min.js", "dist/**"}
+ if !reflect.DeepEqual(cfg.IgnoreExtraPatterns, want) {
+ t.Errorf("IgnoreExtraPatterns = %v, want %v", cfg.IgnoreExtraPatterns, want)
+ }
+}
+
+func TestIgnoreConfig_EnvOverrides(t *testing.T) {
+ clearHexaiEnv(t)
+ dir := t.TempDir()
+ cfgPath := filepath.Join(dir, "config.toml")
+ writeFile(t, cfgPath, `
+[ignore]
+gitignore = true
+lsp_notify_ignored = true
+`)
+ withEnv(t, "HEXAI_IGNORE_GITIGNORE", "false")
+ withEnv(t, "HEXAI_IGNORE_LSP_NOTIFY", "0")
+ withEnv(t, "HEXAI_IGNORE_EXTRA_PATTERNS", "*.bak,*.tmp")
+ cfg := LoadWithOptions(newLogger(), LoadOptions{ConfigPath: cfgPath})
+ if cfg.IgnoreGitignore == nil || *cfg.IgnoreGitignore {
+ t.Error("expected IgnoreGitignore false from env override")
+ }
+ if cfg.IgnoreLSPNotify == nil || *cfg.IgnoreLSPNotify {
+ t.Error("expected IgnoreLSPNotify false from env override")
+ }
+ want := []string{"*.bak", "*.tmp"}
+ if !reflect.DeepEqual(cfg.IgnoreExtraPatterns, want) {
+ t.Errorf("IgnoreExtraPatterns = %v, want %v", cfg.IgnoreExtraPatterns, want)
+ }
+}
+
+func TestIgnoreConfig_ProjectOverride(t *testing.T) {
+ clearHexaiEnv(t)
+ dir := t.TempDir()
+ cfgPath := filepath.Join(dir, "config.toml")
+ writeFile(t, cfgPath, `
+[ignore]
+gitignore = true
+`)
+ // Set up a fake git repo with project override
+ projectDir := t.TempDir()
+ if err := os.Mkdir(filepath.Join(projectDir, ".git"), 0o755); err != nil {
+ t.Fatalf("mkdir .git: %v", err)
+ }
+ projectCfg := filepath.Join(projectDir, ProjectConfigFilename)
+ writeFile(t, projectCfg, `
+[ignore]
+gitignore = false
+extra_patterns = ["build/**"]
+`)
+ cfg := LoadWithOptions(newLogger(), LoadOptions{ConfigPath: cfgPath, ProjectRoot: projectDir})
+ if cfg.IgnoreGitignore == nil || *cfg.IgnoreGitignore {
+ t.Error("expected project override to set IgnoreGitignore false")
+ }
+ want := []string{"build/**"}
+ if !reflect.DeepEqual(cfg.IgnoreExtraPatterns, want) {
+ t.Errorf("IgnoreExtraPatterns = %v, want %v", cfg.IgnoreExtraPatterns, want)
+ }
+}
+
+func TestIgnoreConfig_DisableGitignore(t *testing.T) {
+ clearHexaiEnv(t)
+ dir := t.TempDir()
+ cfgPath := filepath.Join(dir, "config.toml")
+ writeFile(t, cfgPath, `
+[ignore]
+gitignore = false
+`)
+ cfg := LoadWithOptions(newLogger(), LoadOptions{ConfigPath: cfgPath})
+ if cfg.IgnoreGitignore == nil || *cfg.IgnoreGitignore {
+ t.Error("expected IgnoreGitignore false")
+ }
+ // LSP notify should still be true (default, not overridden)
+ if cfg.IgnoreLSPNotify == nil || !*cfg.IgnoreLSPNotify {
+ 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/hexailsp/run.go b/internal/hexailsp/run.go
index e2aaf9d..18f5aa5 100644
--- a/internal/hexailsp/run.go
+++ b/internal/hexailsp/run.go
@@ -10,6 +10,7 @@ import (
"time"
"codeberg.org/snonux/hexai/internal/appconfig"
+ "codeberg.org/snonux/hexai/internal/ignore"
"codeberg.org/snonux/hexai/internal/llm"
"codeberg.org/snonux/hexai/internal/logging"
"codeberg.org/snonux/hexai/internal/lsp"
@@ -66,10 +67,15 @@ func RunWithFactory(logPath string, configPath string, stdin io.Reader, stdout i
client = buildClientIfNil(cfg, client)
factory = ensureFactory(factory)
+ // Create gitignore-aware file checker for LSP filtering
+ gitRoot := appconfig.FindGitRoot()
+ useGI := cfg.IgnoreGitignore == nil || *cfg.IgnoreGitignore
+ ignoreChecker := ignore.New(gitRoot, useGI, cfg.IgnoreExtraPatterns)
+
store := runtimeconfig.New(cfg)
logContext := strings.TrimSpace(logPath) != ""
loadOpts := appconfig.LoadOptions{ConfigPath: strings.TrimSpace(configPath)}
- opts := makeServerOptions(cfg, logContext, client, loadOpts)
+ opts := makeServerOptions(cfg, logContext, client, loadOpts, ignoreChecker)
opts.ConfigLoadOptions = loadOpts
opts.ConfigStore = store
server := factory(stdin, stdout, logger, opts)
@@ -83,7 +89,10 @@ func RunWithFactory(logPath string, configPath string, stdin io.Reader, stdout i
if newClient := buildClientIfNil(updated, nil); newClient != nil {
client = newClient
}
- opts := makeServerOptions(updated, logContext, client, loadOpts)
+ // Update ignore checker patterns on config hot-reload
+ useGI := updated.IgnoreGitignore == nil || *updated.IgnoreGitignore
+ ignoreChecker.Update(useGI, updated.IgnoreExtraPatterns)
+ opts := makeServerOptions(updated, logContext, client, loadOpts, ignoreChecker)
opts.ConfigStore = store
configurable.ApplyOptions(opts)
})
@@ -156,7 +165,7 @@ func ensureFactory(factory ServerFactory) ServerFactory {
}
}
-func makeServerOptions(cfg appconfig.App, logContext bool, client llm.Client, loadOpts appconfig.LoadOptions) lsp.ServerOptions {
+func makeServerOptions(cfg appconfig.App, logContext bool, client llm.Client, loadOpts appconfig.LoadOptions, ignoreChecker *ignore.Checker) lsp.ServerOptions {
// Map custom actions from appconfig to lsp type
var customs []lsp.CustomAction
if len(cfg.CustomActions) > 0 {
@@ -214,5 +223,6 @@ func makeServerOptions(cfg appconfig.App, logContext bool, client llm.Client, lo
PromptSimplifySystem: cfg.PromptCodeActionSimplifySystem,
PromptSimplifyUser: cfg.PromptCodeActionSimplifyUser,
CustomActions: customs,
+ IgnoreChecker: ignoreChecker,
}
}
diff --git a/internal/ignore/checker.go b/internal/ignore/checker.go
new file mode 100644
index 0000000..92129b8
--- /dev/null
+++ b/internal/ignore/checker.go
@@ -0,0 +1,90 @@
+// Summary: Thread-safe gitignore-aware file checker that combines .gitignore
+// patterns with user-configured extra patterns. Used by the LSP server to
+// skip completions and code actions for ignored files.
+package ignore
+
+import (
+ "path/filepath"
+ "strings"
+ "sync"
+
+ gitignore "github.com/sabhiram/go-gitignore"
+)
+
+// Checker evaluates whether an absolute file path should be ignored based on
+// .gitignore patterns and/or user-configured extra patterns. It is safe for
+// concurrent use.
+type Checker struct {
+ mu sync.RWMutex
+ gitRoot string
+ giMatcher *gitignore.GitIgnore // compiled .gitignore (nil when disabled or missing)
+ exMatcher *gitignore.GitIgnore // compiled extra patterns (nil when empty)
+}
+
+// New creates a Checker. If useGitignore is true and gitRoot is non-empty, it
+// loads .gitignore from gitRoot. extraPatterns are always compiled (gitignore
+// syntax).
+func New(gitRoot string, useGitignore bool, extraPatterns []string) *Checker {
+ c := &Checker{gitRoot: gitRoot}
+ c.compile(useGitignore, extraPatterns)
+ return c
+}
+
+// IsIgnored returns whether absPath should be ignored and a human-readable
+// reason string. When the checker is nil, nothing is ignored.
+func (c *Checker) IsIgnored(absPath string) (ignored bool, reason string) {
+ if c == nil {
+ return false, ""
+ }
+ c.mu.RLock()
+ defer c.mu.RUnlock()
+
+ rel, inside := c.relPath(absPath)
+
+ // Only check gitignore when the path is inside the git root
+ if inside && c.giMatcher != nil && c.giMatcher.MatchesPath(rel) {
+ return true, "matched .gitignore pattern"
+ }
+ if c.exMatcher != nil && c.exMatcher.MatchesPath(rel) {
+ return true, "matched extra ignore pattern"
+ }
+ return false, ""
+}
+
+// Update recompiles matchers for hot-reload. Thread-safe.
+func (c *Checker) Update(useGitignore bool, extraPatterns []string) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ c.compile(useGitignore, extraPatterns)
+}
+
+// compile builds the gitignore and extra-pattern matchers. Must be called
+// under c.mu write lock (or during construction).
+func (c *Checker) compile(useGitignore bool, extraPatterns []string) {
+ c.giMatcher = nil
+ c.exMatcher = nil
+
+ if useGitignore && c.gitRoot != "" {
+ giPath := filepath.Join(c.gitRoot, ".gitignore")
+ if gi, err := gitignore.CompileIgnoreFile(giPath); err == nil {
+ c.giMatcher = gi
+ }
+ }
+ if len(extraPatterns) > 0 {
+ c.exMatcher = gitignore.CompileIgnoreLines(extraPatterns...)
+ }
+}
+
+// relPath converts absPath to a path relative to gitRoot. Returns the
+// relative path and true if the path is inside the git root; otherwise
+// returns the original path and false.
+func (c *Checker) relPath(absPath string) (string, bool) {
+ if c.gitRoot == "" {
+ return absPath, false
+ }
+ rel, err := filepath.Rel(c.gitRoot, absPath)
+ if err != nil || strings.HasPrefix(rel, "..") {
+ return absPath, false
+ }
+ return rel, true
+}
diff --git a/internal/ignore/checker_test.go b/internal/ignore/checker_test.go
new file mode 100644
index 0000000..3e3384c
--- /dev/null
+++ b/internal/ignore/checker_test.go
@@ -0,0 +1,282 @@
+package ignore
+
+import (
+ "os"
+ "path/filepath"
+ "sync"
+ "testing"
+)
+
+// writeGitignore creates a .gitignore in dir with the given lines.
+func writeGitignore(t *testing.T, dir string, lines ...string) {
+ t.Helper()
+ content := ""
+ for _, l := range lines {
+ content += l + "\n"
+ }
+ if err := os.WriteFile(filepath.Join(dir, ".gitignore"), []byte(content), 0o644); err != nil {
+ t.Fatalf("write .gitignore: %v", err)
+ }
+}
+
+func TestSimpleWildcard(t *testing.T) {
+ dir := t.TempDir()
+ writeGitignore(t, dir, "*.log")
+ c := New(dir, true, nil)
+
+ if ign, _ := c.IsIgnored(filepath.Join(dir, "app.log")); !ign {
+ t.Error("expected app.log to be ignored")
+ }
+ if ign, _ := c.IsIgnored(filepath.Join(dir, "debug.log")); !ign {
+ t.Error("expected debug.log to be ignored")
+ }
+ if ign, _ := c.IsIgnored(filepath.Join(dir, "app.go")); ign {
+ t.Error("app.go should not be ignored")
+ }
+ if ign, _ := c.IsIgnored(filepath.Join(dir, "log.txt")); ign {
+ t.Error("log.txt should not be ignored")
+ }
+}
+
+func TestDirectoryPattern(t *testing.T) {
+ dir := t.TempDir()
+ writeGitignore(t, dir, "build/")
+ c := New(dir, true, nil)
+
+ if ign, _ := c.IsIgnored(filepath.Join(dir, "build", "output.js")); !ign {
+ t.Error("expected build/output.js to be ignored")
+ }
+ if ign, _ := c.IsIgnored(filepath.Join(dir, "rebuild", "x")); ign {
+ t.Error("rebuild/x should not be ignored")
+ }
+}
+
+func TestDoubleStarPattern(t *testing.T) {
+ dir := t.TempDir()
+ writeGitignore(t, dir, "**/temp")
+ c := New(dir, true, nil)
+
+ if ign, _ := c.IsIgnored(filepath.Join(dir, "a", "b", "temp")); !ign {
+ t.Error("expected a/b/temp to be ignored")
+ }
+ if ign, _ := c.IsIgnored(filepath.Join(dir, "temp")); !ign {
+ t.Error("expected temp to be ignored")
+ }
+}
+
+func TestNegation(t *testing.T) {
+ dir := t.TempDir()
+ writeGitignore(t, dir, "*.log", "!important.log")
+ c := New(dir, true, nil)
+
+ if ign, _ := c.IsIgnored(filepath.Join(dir, "debug.log")); !ign {
+ t.Error("expected debug.log to be ignored")
+ }
+ if ign, _ := c.IsIgnored(filepath.Join(dir, "important.log")); ign {
+ t.Error("important.log should not be ignored (negated)")
+ }
+}
+
+func TestComments(t *testing.T) {
+ dir := t.TempDir()
+ writeGitignore(t, dir, "# comment", "*.tmp")
+ c := New(dir, true, nil)
+
+ if ign, _ := c.IsIgnored(filepath.Join(dir, "x.tmp")); !ign {
+ t.Error("expected x.tmp to be ignored")
+ }
+ // A file literally named "# comment" should not be ignored
+ if ign, _ := c.IsIgnored(filepath.Join(dir, "# comment")); ign {
+ t.Error("file named '# comment' should not be ignored")
+ }
+}
+
+func TestExtensionGroups(t *testing.T) {
+ dir := t.TempDir()
+ writeGitignore(t, dir, "*.out", "*.html")
+ c := New(dir, true, nil)
+
+ if ign, _ := c.IsIgnored(filepath.Join(dir, "coverage.out")); !ign {
+ t.Error("expected coverage.out to be ignored")
+ }
+ if ign, _ := c.IsIgnored(filepath.Join(dir, "main.go")); ign {
+ t.Error("main.go should not be ignored")
+ }
+}
+
+func TestNestedDirs(t *testing.T) {
+ dir := t.TempDir()
+ writeGitignore(t, dir, "vendor/**")
+ c := New(dir, true, nil)
+
+ if ign, _ := c.IsIgnored(filepath.Join(dir, "vendor", "lib", "x.go")); !ign {
+ t.Error("expected vendor/lib/x.go to be ignored")
+ }
+ if ign, _ := c.IsIgnored(filepath.Join(dir, "myvendor", "x")); ign {
+ t.Error("myvendor/x should not be ignored")
+ }
+}
+
+func TestExtraPatternsOnly(t *testing.T) {
+ // No gitignore, only extra patterns
+ c := New("", false, []string{"*.min.js", "dist/**"})
+
+ if ign, reason := c.IsIgnored("/project/app.min.js"); !ign {
+ t.Error("expected app.min.js to be ignored")
+ } else if reason != "matched extra ignore pattern" {
+ t.Errorf("unexpected reason: %s", reason)
+ }
+ if ign, _ := c.IsIgnored("/project/dist/bundle.js"); !ign {
+ t.Error("expected dist/bundle.js to be ignored")
+ }
+ if ign, _ := c.IsIgnored("/project/app.js"); ign {
+ t.Error("app.js should not be ignored")
+ }
+}
+
+func TestCombinedGitignoreAndExtra(t *testing.T) {
+ dir := t.TempDir()
+ writeGitignore(t, dir, "*.log")
+ c := New(dir, true, []string{"*.min.js"})
+
+ // gitignore match
+ if ign, reason := c.IsIgnored(filepath.Join(dir, "app.log")); !ign {
+ t.Error("expected app.log to be ignored")
+ } else if reason != "matched .gitignore pattern" {
+ t.Errorf("unexpected reason: %s", reason)
+ }
+ // extra pattern match
+ if ign, reason := c.IsIgnored(filepath.Join(dir, "app.min.js")); !ign {
+ t.Error("expected app.min.js to be ignored")
+ } else if reason != "matched extra ignore pattern" {
+ t.Errorf("unexpected reason: %s", reason)
+ }
+ // neither match
+ if ign, _ := c.IsIgnored(filepath.Join(dir, "main.go")); ign {
+ t.Error("main.go should not be ignored")
+ }
+}
+
+func TestNilChecker(t *testing.T) {
+ var c *Checker
+ if ign, _ := c.IsIgnored("/some/file.go"); ign {
+ t.Error("nil checker should never ignore")
+ }
+}
+
+func TestEmptyChecker(t *testing.T) {
+ c := New("", false, nil)
+ if ign, _ := c.IsIgnored("/some/file.go"); ign {
+ t.Error("empty checker should never ignore")
+ }
+}
+
+func TestUpdatePatterns(t *testing.T) {
+ dir := t.TempDir()
+ writeGitignore(t, dir, "*.log")
+ c := New(dir, true, nil)
+
+ if ign, _ := c.IsIgnored(filepath.Join(dir, "app.log")); !ign {
+ t.Error("expected app.log ignored initially")
+ }
+
+ // Update: disable gitignore, add extra pattern
+ c.Update(false, []string{"*.tmp"})
+
+ if ign, _ := c.IsIgnored(filepath.Join(dir, "app.log")); ign {
+ t.Error("app.log should not be ignored after disabling gitignore")
+ }
+ if ign, _ := c.IsIgnored(filepath.Join(dir, "x.tmp")); !ign {
+ t.Error("expected x.tmp ignored after update")
+ }
+}
+
+func TestThreadSafety(t *testing.T) {
+ dir := t.TempDir()
+ writeGitignore(t, dir, "*.log")
+ c := New(dir, true, nil)
+
+ var wg sync.WaitGroup
+ // Concurrent reads
+ for i := 0; i < 50; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ c.IsIgnored(filepath.Join(dir, "app.log"))
+ c.IsIgnored(filepath.Join(dir, "main.go"))
+ }()
+ }
+ // Concurrent updates
+ for i := 0; i < 10; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ c.Update(true, []string{"*.tmp"})
+ }()
+ }
+ wg.Wait()
+}
+
+func TestNoGitRoot(t *testing.T) {
+ // gitRoot empty but gitignore enabled — should not crash, gitignore has no effect
+ c := New("", true, []string{"*.bak"})
+
+ if ign, _ := c.IsIgnored("/any/file.go"); ign {
+ t.Error("should not ignore .go files")
+ }
+ if ign, _ := c.IsIgnored("/any/file.bak"); !ign {
+ t.Error("extra patterns should still work without git root")
+ }
+}
+
+func TestPathOutsideGitRoot(t *testing.T) {
+ dir := t.TempDir()
+ writeGitignore(t, dir, "*.log")
+ c := New(dir, true, nil)
+
+ // Path outside the git root — relPath returns absolute, gitignore won't match
+ if ign, _ := c.IsIgnored("/completely/elsewhere/app.log"); ign {
+ t.Error("files outside git root should not be matched by gitignore")
+ }
+}
+
+func TestMixedRealGitignore(t *testing.T) {
+ dir := t.TempDir()
+ writeGitignore(t, dir,
+ "# Build outputs",
+ "bin/",
+ "*.exe",
+ "*.dll",
+ "",
+ "# Dependencies",
+ "vendor/**",
+ "",
+ "# IDE",
+ ".idea/",
+ ".vscode/",
+ )
+ c := New(dir, true, nil)
+
+ ignored := []string{
+ filepath.Join(dir, "bin", "app"),
+ filepath.Join(dir, "main.exe"),
+ filepath.Join(dir, "vendor", "lib", "x.go"),
+ filepath.Join(dir, ".idea", "workspace.xml"),
+ }
+ for _, p := range ignored {
+ if ign, _ := c.IsIgnored(p); !ign {
+ t.Errorf("expected %s to be ignored", p)
+ }
+ }
+
+ allowed := []string{
+ filepath.Join(dir, "main.go"),
+ filepath.Join(dir, "internal", "app.go"),
+ filepath.Join(dir, "README.md"),
+ }
+ for _, p := range allowed {
+ if ign, _ := c.IsIgnored(p); ign {
+ t.Errorf("%s should not be ignored", p)
+ }
+ }
+}
diff --git a/internal/lsp/handlers_codeaction.go b/internal/lsp/handlers_codeaction.go
index 24429a1..4562954 100644
--- a/internal/lsp/handlers_codeaction.go
+++ b/internal/lsp/handlers_codeaction.go
@@ -22,6 +22,14 @@ func (s *Server) handleCodeAction(req Request) {
}
return
}
+ // Skip code actions for gitignored / extra-pattern-ignored files
+ if ignored, reason := s.isFileIgnored(p.TextDocument.URI); ignored {
+ logging.Logf("lsp ", "code action skipped: file ignored (%s) uri=%s", reason, p.TextDocument.URI)
+ if len(req.ID) != 0 {
+ s.reply(req.ID, []CodeAction{}, nil)
+ }
+ return
+ }
d := s.getDocument(p.TextDocument.URI)
if d == nil || len(d.lines) == 0 || s.currentLLMClient() == nil {
if len(req.ID) != 0 {
diff --git a/internal/lsp/handlers_completion.go b/internal/lsp/handlers_completion.go
index 28da503..6350c59 100644
--- a/internal/lsp/handlers_completion.go
+++ b/internal/lsp/handlers_completion.go
@@ -37,6 +37,18 @@ func (s *Server) handleCompletion(req Request) {
var p CompletionParams
var docStr string
if err := json.Unmarshal(req.Params, &p); err == nil {
+ // Skip completion for gitignored / extra-pattern-ignored files
+ if ignored, reason := s.isFileIgnored(p.TextDocument.URI); ignored {
+ logging.Logf("lsp ", "completion skipped: file ignored (%s) uri=%s", reason, p.TextDocument.URI)
+ if s.ignoreLSPNotifyEnabled() {
+ s.reply(req.ID, CompletionList{IsIncomplete: false, Items: []CompletionItem{
+ {Label: "[hexai] file ignored", Detail: reason},
+ }}, nil)
+ } else {
+ s.reply(req.ID, CompletionList{IsIncomplete: false, Items: nil}, nil)
+ }
+ return
+ }
// Log trigger information for every completion request from client
tk, tch := extractTriggerInfo(p)
logging.Logf("lsp ", "completion trigger kind=%d char=%q uri=%s line=%d char=%d",
diff --git a/internal/lsp/handlers_document.go b/internal/lsp/handlers_document.go
index a047324..b907014 100644
--- a/internal/lsp/handlers_document.go
+++ b/internal/lsp/handlers_document.go
@@ -16,6 +16,10 @@ func (s *Server) handleDidOpen(req Request) {
if err := json.Unmarshal(req.Params, &p); err == nil {
s.setDocument(p.TextDocument.URI, p.TextDocument.Text)
s.markActivity()
+ // Log when an ignored file is opened (document still stored for editor sync)
+ if ignored, reason := s.isFileIgnored(p.TextDocument.URI); ignored {
+ logging.Logf("lsp ", "file opened (ignored): %s (%s)", p.TextDocument.URI, reason)
+ }
}
}
diff --git a/internal/lsp/handlers_ignore.go b/internal/lsp/handlers_ignore.go
new file mode 100644
index 0000000..bbd2dfa
--- /dev/null
+++ b/internal/lsp/handlers_ignore.go
@@ -0,0 +1,41 @@
+// Summary: Helpers for gitignore-aware file filtering in LSP handlers.
+package lsp
+
+import (
+ "net/url"
+ "strings"
+)
+
+// isFileIgnored checks whether the file at the given LSP URI should be ignored.
+// Returns false when no ignore checker is configured.
+func (s *Server) isFileIgnored(uri string) (bool, string) {
+ if s.ignoreChecker == nil {
+ return false, ""
+ }
+ absPath := uriToPath(uri)
+ if absPath == "" {
+ return false, ""
+ }
+ return s.ignoreChecker.IsIgnored(absPath)
+}
+
+// ignoreLSPNotifyEnabled returns whether to show "file ignored" completion items
+// when a file is ignored. Reads from the IgnoreLSPNotify config field.
+func (s *Server) ignoreLSPNotifyEnabled() bool {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ return s.cfg.IgnoreLSPNotify == nil || *s.cfg.IgnoreLSPNotify
+}
+
+// uriToPath converts a file:// URI to an absolute file path.
+// Returns empty string for non-file URIs.
+func uriToPath(uri string) string {
+ if !strings.HasPrefix(uri, "file://") {
+ return ""
+ }
+ parsed, err := url.Parse(uri)
+ if err != nil {
+ return ""
+ }
+ return parsed.Path
+}
diff --git a/internal/lsp/ignore_test.go b/internal/lsp/ignore_test.go
new file mode 100644
index 0000000..5414137
--- /dev/null
+++ b/internal/lsp/ignore_test.go
@@ -0,0 +1,175 @@
+package lsp
+
+import (
+ "io"
+ "log"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "codeberg.org/snonux/hexai/internal/appconfig"
+ "codeberg.org/snonux/hexai/internal/ignore"
+ "codeberg.org/snonux/hexai/internal/llm"
+)
+
+// newIgnoreTestServer creates a Server with an ignore checker configured
+// from the given gitRoot and extra patterns.
+func newIgnoreTestServer(gitRoot string, useGI bool, extra []string, notifyIgnored *bool) *Server {
+ cfg := appconfig.App{
+ IgnoreLSPNotify: notifyIgnored,
+ InlineOpen: ">!",
+ InlineClose: ">",
+ ChatSuffix: ">",
+ ChatPrefixes: []string{"?", "!", ":", ";"},
+ }
+ s := &Server{
+ logger: log.New(io.Discard, "", 0),
+ docs: make(map[string]*document),
+ cfg: cfg,
+ altClients: make(map[string]llm.Client),
+ ignoreChecker: ignore.New(gitRoot, useGI, extra),
+ }
+ return s
+}
+
+func boolPtr(b bool) *bool { return &b }
+
+func TestHandleCompletion_IgnoredFile_WithNotify(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(dir, ".gitignore"), []byte("*.log\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ s := newIgnoreTestServer(dir, true, nil, boolPtr(true))
+
+ uri := "file://" + filepath.Join(dir, "debug.log")
+ ignored, _ := s.isFileIgnored(uri)
+ if !ignored {
+ t.Fatal("expected file to be ignored")
+ }
+
+ // Verify notify is enabled
+ if !s.ignoreLSPNotifyEnabled() {
+ t.Fatal("expected LSP notify enabled")
+ }
+}
+
+func TestHandleCompletion_IgnoredFile_WithoutNotify(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(dir, ".gitignore"), []byte("*.log\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ s := newIgnoreTestServer(dir, true, nil, boolPtr(false))
+
+ uri := "file://" + filepath.Join(dir, "debug.log")
+
+ ignored, _ := s.isFileIgnored(uri)
+ if !ignored {
+ t.Fatal("expected file to be ignored")
+ }
+ if s.ignoreLSPNotifyEnabled() {
+ t.Fatal("expected LSP notify disabled")
+ }
+}
+
+func TestHandleCompletion_NonIgnoredFile(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(dir, ".gitignore"), []byte("*.log\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ s := newIgnoreTestServer(dir, true, nil, boolPtr(true))
+
+ uri := "file://" + filepath.Join(dir, "main.go")
+
+ ignored, _ := s.isFileIgnored(uri)
+ if ignored {
+ t.Fatal("main.go should not be ignored")
+ }
+}
+
+func TestHandleCodeAction_IgnoredFile(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(dir, ".gitignore"), []byte("*.log\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ s := newIgnoreTestServer(dir, true, nil, nil)
+
+ uri := "file://" + filepath.Join(dir, "app.log")
+
+ ignored, reason := s.isFileIgnored(uri)
+ if !ignored {
+ t.Fatal("expected app.log to be ignored")
+ }
+ if reason != "matched .gitignore pattern" {
+ t.Errorf("unexpected reason: %s", reason)
+ }
+}
+
+func TestHandleDidOpen_IgnoredFile(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(dir, ".gitignore"), []byte("*.log\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ s := newIgnoreTestServer(dir, true, nil, nil)
+ uri := "file://" + filepath.Join(dir, "app.log")
+
+ // Simulate didOpen — document should be stored even if ignored
+ s.setDocument(uri, "log content")
+ d := s.getDocument(uri)
+ if d == nil {
+ t.Fatal("document should be stored even for ignored files")
+ }
+
+ ignored, _ := s.isFileIgnored(uri)
+ if !ignored {
+ t.Fatal("expected app.log to be ignored")
+ }
+}
+
+func TestIsFileIgnored_NoChecker(t *testing.T) {
+ s := &Server{
+ logger: log.New(io.Discard, "", 0),
+ docs: make(map[string]*document),
+ altClients: make(map[string]llm.Client),
+ // ignoreChecker is nil
+ }
+
+ ignored, reason := s.isFileIgnored("file:///some/file.log")
+ if ignored {
+ t.Fatal("nil checker should not ignore anything")
+ }
+ if reason != "" {
+ t.Errorf("expected empty reason, got %q", reason)
+ }
+}
+
+func TestUriToPath(t *testing.T) {
+ tests := []struct {
+ uri string
+ want string
+ }{
+ {"file:///home/user/file.go", "/home/user/file.go"},
+ {"file:///tmp/test.log", "/tmp/test.log"},
+ {"", ""},
+ {"https://example.com", ""},
+ {"file:///path/with%20space/file.go", "/path/with space/file.go"},
+ }
+ for _, tc := range tests {
+ got := uriToPath(tc.uri)
+ if got != tc.want {
+ t.Errorf("uriToPath(%q) = %q, want %q", tc.uri, got, tc.want)
+ }
+ }
+}
+
+func TestIgnoreLSPNotifyEnabled_NilConfig(t *testing.T) {
+ // When IgnoreLSPNotify is nil, defaults to true
+ s := &Server{
+ logger: log.New(io.Discard, "", 0),
+ docs: make(map[string]*document),
+ altClients: make(map[string]llm.Client),
+ cfg: appconfig.App{},
+ }
+ if !s.ignoreLSPNotifyEnabled() {
+ t.Error("expected notify enabled when config is nil (default)")
+ }
+}
diff --git a/internal/lsp/server.go b/internal/lsp/server.go
index c226ab4..a5a8a2a 100644
--- a/internal/lsp/server.go
+++ b/internal/lsp/server.go
@@ -12,6 +12,7 @@ import (
"time"
"codeberg.org/snonux/hexai/internal/appconfig"
+ "codeberg.org/snonux/hexai/internal/ignore"
"codeberg.org/snonux/hexai/internal/llm"
"codeberg.org/snonux/hexai/internal/logging"
"codeberg.org/snonux/hexai/internal/runtimeconfig"
@@ -50,6 +51,9 @@ type Server struct {
completionsDisabled bool
+ // Gitignore-aware file checker (nil when disabled)
+ ignoreChecker *ignore.Checker
+
// Dispatch table for JSON-RPC methods → handler functions
handlers map[string]func(Request)
}
@@ -101,6 +105,9 @@ type ServerOptions struct {
// Custom actions
CustomActions []CustomAction
+
+ // Gitignore-aware file checker (optional)
+ IgnoreChecker *ignore.Checker
}
// CustomAction mirrors user-defined code actions passed from config.
@@ -204,6 +211,9 @@ func (s *Server) applyOptions(opts ServerOptions) {
s.llmProvider = canonicalProvider(s.cfg.Provider)
}
s.altClients = make(map[string]llm.Client)
+ if opts.IgnoreChecker != nil {
+ s.ignoreChecker = opts.IgnoreChecker
+ }
}
// ApplyOptions updates the server's configuration at runtime.
diff --git a/internal/tmuxedit/agent.go b/internal/tmuxedit/agent.go
new file mode 100644
index 0000000..313907a
--- /dev/null
+++ b/internal/tmuxedit/agent.go
@@ -0,0 +1,134 @@
+// 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 (cursor, claude) 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
+}
+
+// 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. "claude", "cursor").
+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 := sendClearSequence(paneID, b.clearKeys); err != nil {
+ return err
+ }
+ sleepAfterClear()
+ 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 sendLines(paneID, text, b.newlineKeys)
+}
+
+// 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
new file mode 100644
index 0000000..3673d70
--- /dev/null
+++ b/internal/tmuxedit/agent_test.go
@@ -0,0 +1,119 @@
+package tmuxedit
+
+import (
+ "testing"
+)
+
+func TestDetectAgent(t *testing.T) {
+ agents := builtinAgents()
+ tests := []struct {
+ name string
+ content string
+ want string
+ }{
+ {"claude code prompt", "────\n❯ hello world\n────", "claude"},
+ {"claude code banner", "claude code v1.2\n❯ ", "claude"},
+ {"claude from anthropic", "Powered by Anthropic\n❯ ", "claude"},
+ {"cursor box ui", "│ → type here │\n/ commands · @ files", "cursor"},
+ {"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
+ }{
+ {"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 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_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
new file mode 100644
index 0000000..18ece9b
--- /dev/null
+++ b/internal/tmuxedit/agentutil.go
@@ -0,0 +1,165 @@
+// 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"
+)
+
+// 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 (e.g. Claude's
+// ─── rules) 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 {
+ for _, token := range strings.Fields(clearKeys) {
+ key, count := parseKeyRepeat(token)
+ if count > 1 {
+ if err := sendRepeatedKey(paneID, key, count); err != nil {
+ return fmt.Errorf("clear key %q*%d failed: %w", key, count, err)
+ }
+ } else {
+ if err := sendKeys(paneID, key); err != nil {
+ return fmt.Errorf("clear key %q failed: %w", key, err)
+ }
+ }
+ // Add delay after Escape to let Vim/Claude exit INSERT mode
+ if key == "Escape" {
+ time.Sleep(150 * time.Millisecond)
+ }
+ }
+ return nil
+}
+
+// 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 {
+ lines := strings.Split(text, "\n")
+ for i, line := range lines {
+ if err := sendKeys(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 := sendKeys(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
new file mode 100644
index 0000000..8bf2e64
--- /dev/null
+++ b/internal/tmuxedit/agentutil_test.go
@@ -0,0 +1,206 @@
+package tmuxedit
+
+import (
+ "regexp"
+ "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)
+ }
+ })
+ }
+}
+
+// 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
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/claude_agent.go b/internal/tmuxedit/claude_agent.go
new file mode 100644
index 0000000..b84c77e
--- /dev/null
+++ b/internal/tmuxedit/claude_agent.go
@@ -0,0 +1,85 @@
+package tmuxedit
+
+import (
+ "regexp"
+ "strings"
+)
+
+// claudeAgent handles Claude Code's ❯ prompt between ──── horizontal rules.
+// Claude Code runs in actual vim mode, so clearing uses vim commands.
+// Wrapped text appears as indented continuation lines without ❯.
+type claudeAgent struct{ baseAgent }
+
+// newClaudeAgent returns a claudeAgent with the default configuration.
+// SectionPattern scopes extraction to the last ─── delimited area, avoiding
+// false positives from ❯ in previous messages.
+func newClaudeAgent() *claudeAgent {
+ return &claudeAgent{baseAgent{
+ name: "claude",
+ displayName: "Claude Code",
+ detectPattern: `(❯|(?i)claude code|(?i)anthropic)`,
+ sectionPat: `^─{5,}`,
+ promptPat: `(?m)❯\s*(.+)$`,
+ clearFirst: true,
+ clearKeys: "C-a C-k",
+ newlineKeys: "S-Enter",
+ submitKeys: "Enter",
+ }}
+}
+
+// ExtractPrompt extracts the prompt text from the last section between ─────
+// rules. Within the scoped section, all non-empty lines are collected:
+// ❯-prefixed lines have the prefix stripped, and indented continuation lines
+// (wrapped text without ❯) are included as-is after trimming.
+func (c *claudeAgent) ExtractPrompt(paneContent string) string {
+ if c.promptPat == "" {
+ return ""
+ }
+ re, err := regexp.Compile(c.promptPat)
+ if err != nil {
+ return ""
+ }
+ // Scope to the last section between ───── delimiters
+ content := scopeToLastSection(paneContent, c.sectionPat)
+ // Collect ❯-prefixed lines and their continuation lines (indented
+ // wrapped text without ❯). Only include non-❯ lines that directly
+ // follow a ❯-matched line to avoid picking up unrelated content.
+ paneLines := strings.Split(content, "\n")
+ var lines []string
+ inPrompt := false
+ for _, line := range paneLines {
+ m := re.FindStringSubmatch(line)
+ if len(m) >= 2 {
+ // ❯-prefixed line: use the captured text
+ cleaned := stripNoise(m[1], c.stripPatterns)
+ if cleaned != "" {
+ lines = append(lines, cleaned)
+ }
+ inPrompt = true
+ } else if inPrompt {
+ // Non-❯ line after a prompt: include indented continuation text
+ trimmed := strings.TrimSpace(line)
+ if trimmed != "" {
+ lines = append(lines, trimmed)
+ } else {
+ // Empty line breaks the continuation
+ inPrompt = false
+ }
+ }
+ }
+ return strings.Join(lines, "\n")
+}
+
+// ClearInput sends vim commands to clear Claude Code's input:
+// Escape to ensure normal mode, gg to go to top, C-v G d to visual-block
+// select all and delete, then i to re-enter insert mode.
+func (c *claudeAgent) ClearInput(paneID string) error {
+ if !c.clearFirst || c.clearKeys == "" {
+ return nil
+ }
+ if err := sendClearSequence(paneID, c.clearKeys); err != nil {
+ return err
+ }
+ sleepAfterClear()
+ return nil
+}
diff --git a/internal/tmuxedit/claude_agent_test.go b/internal/tmuxedit/claude_agent_test.go
new file mode 100644
index 0000000..1a80433
--- /dev/null
+++ b/internal/tmuxedit/claude_agent_test.go
@@ -0,0 +1,125 @@
+package tmuxedit
+
+import (
+ "fmt"
+ "strings"
+ "testing"
+)
+
+func TestClaudeAgent_ExtractPrompt(t *testing.T) {
+ agent := newClaudeAgent()
+ tests := []struct {
+ name string
+ content string
+ want string
+ }{
+ {
+ name: "single line",
+ content: "──────\n❯ hello world\n──────",
+ want: "hello world",
+ },
+ {
+ name: "multi-line between rules",
+ content: "previous output\n" +
+ "──────────────\n" +
+ "❯ first line\n" +
+ "\n" +
+ "❯ second line\n" +
+ "\n" +
+ "❯ third line\n" +
+ "──────────────\n" +
+ " -- INSERT --",
+ want: "first line\nsecond line\nthird line",
+ },
+ {
+ name: "wrapped long line",
+ content: "──────────────\n" +
+ "❯ This is a really long prompt that wraps\n" +
+ " to a second line in the terminal\n" +
+ "──────────────\n" +
+ " -- INSERT --",
+ want: "This is a really long prompt that wraps\nto a second line in the terminal",
+ },
+ {
+ name: "ignores previous messages",
+ content: "──────────────\n" +
+ "❯ old user message\n" +
+ "──────────────\n" +
+ "assistant response here\n" +
+ "──────────────\n" +
+ "❯ current prompt\n" +
+ "──────────────\n" +
+ " -- INSERT --",
+ want: "current prompt",
+ },
+ {
+ name: "no match",
+ content: "no prompt here",
+ want: "",
+ },
+ {
+ name: "no section delimiters",
+ content: "❯ hello world",
+ want: "hello world",
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := agent.ExtractPrompt(tt.content)
+ if got != tt.want {
+ t.Errorf("ExtractPrompt() = %q, want %q", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestClaudeAgent_ClearInput(t *testing.T) {
+ noSleep(t)
+ var calls []string
+ oldSend := sendKeys
+ defer func() { sendKeys = oldSend }()
+ sendKeys = func(paneID string, keys ...string) error {
+ calls = append(calls, fmt.Sprintf("send:%s:%s", paneID, strings.Join(keys, ",")))
+ return nil
+ }
+
+ agent := newClaudeAgent()
+ err := agent.ClearInput("%3")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ // "C-a C-k" (Emacs/readline style) should send each as separate send-keys call
+ want := []string{
+ "send:%3:C-a",
+ "send:%3: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)
+ }
+ }
+}
+
+func TestClaudeAgent_Detect(t *testing.T) {
+ agent := newClaudeAgent()
+ tests := []struct {
+ name string
+ content string
+ want bool
+ }{
+ {"prompt symbol", "❯ hello", true},
+ {"claude code banner", "claude code v1.0", true},
+ {"anthropic mention", "Powered by Anthropic", true},
+ {"no match", "some text", false},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := agent.Detect(tt.content); got != tt.want {
+ t.Errorf("Detect() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
diff --git a/internal/tmuxedit/config_agent.go b/internal/tmuxedit/config_agent.go
new file mode 100644
index 0000000..e5268fa
--- /dev/null
+++ b/internal/tmuxedit/config_agent.go
@@ -0,0 +1,134 @@
+package tmuxedit
+
+import (
+ "strings"
+
+ "codeberg.org/snonux/hexai/internal/appconfig"
+)
+
+// configAgent uses baseAgent defaults for all operations. It serves
+// user-defined agents from TOML config and simple built-ins (amp, aider)
+// that don't need specialized extraction or clearing logic.
+type configAgent struct{ baseAgent }
+
+// builtinAgents returns the default set of agent implementations. Order
+// matters: agents with distinctive UI elements (box-drawing, etc.) are
+// checked first to avoid false positives from model names like "Claude
+// 4.5 Sonnet" appearing in other agents' panes.
+func builtinAgents() []Agent {
+ return []Agent{
+ newCursorAgent(),
+ newClaudeAgent(),
+ &configAgent{baseAgent{
+ name: "amp",
+ displayName: "Amp",
+ detectPattern: `(?i)(amp|sourcegraph)`,
+ promptPat: `(?m)│\s*(.+?)\s*│\s*$`,
+ clearFirst: true,
+ clearKeys: "C-u",
+ newlineKeys: "S-Enter",
+ submitKeys: "Enter",
+ }},
+ &configAgent{baseAgent{
+ name: "aider",
+ displayName: "Aider",
+ detectPattern: `(?i)aider`,
+ promptPat: `(?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() Agent {
+ return &configAgent{baseAgent{
+ 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. The Configurable interface provides
+// access to baseAgent fields for merging.
+func resolveAgents(cfgAgents []appconfig.TmuxEditAgentCfg) []Agent {
+ agents := builtinAgents()
+ for _, ca := range cfgAgents {
+ merged := false
+ for i, a := range agents {
+ if !strings.EqualFold(a.Name(), ca.Name) {
+ continue
+ }
+ if c, ok := a.(Configurable); ok {
+ mergeAgentConfig(c.Base(), ca)
+ }
+ merged = true
+ _ = i // index not needed; we modify through the pointer
+ break
+ }
+ if !merged {
+ agents = append(agents, agentFromConfig(ca))
+ }
+ }
+ return agents
+}
+
+// mergeAgentConfig overrides fields in base with non-zero values from cfg.
+// It modifies the baseAgent in place via pointer.
+func mergeAgentConfig(base *baseAgent, cfg appconfig.TmuxEditAgentCfg) {
+ 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.SectionPattern); s != "" {
+ base.sectionPat = s
+ }
+ if s := strings.TrimSpace(cfg.PromptPattern); s != "" {
+ base.promptPat = 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
+ }
+}
+
+// agentFromConfig creates a new configAgent from a user config entry.
+func agentFromConfig(cfg appconfig.TmuxEditAgentCfg) Agent {
+ b := baseAgent{
+ name: strings.TrimSpace(cfg.Name),
+ displayName: strings.TrimSpace(cfg.DisplayName),
+ detectPattern: strings.TrimSpace(cfg.DetectPattern),
+ sectionPat: strings.TrimSpace(cfg.SectionPattern),
+ promptPat: 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 {
+ b.clearFirst = *cfg.ClearFirst
+ }
+ if b.displayName == "" {
+ b.displayName = b.name
+ }
+ return &configAgent{b}
+}
diff --git a/internal/tmuxedit/config_agent_test.go b/internal/tmuxedit/config_agent_test.go
new file mode 100644
index 0000000..d7ad649
--- /dev/null
+++ b/internal/tmuxedit/config_agent_test.go
@@ -0,0 +1,179 @@
+package tmuxedit
+
+import (
+ "testing"
+
+ "codeberg.org/snonux/hexai/internal/appconfig"
+)
+
+func boolP(b bool) *bool { return &b }
+
+func TestResolveAgents_MergeOverride(t *testing.T) {
+ cfgAgents := []appconfig.TmuxEditAgentCfg{
+ {
+ Name: "claude",
+ DisplayName: "My Claude",
+ ClearFirst: boolP(false),
+ },
+ }
+ agents := resolveAgents(cfgAgents)
+ var claude Agent
+ for _, a := range agents {
+ if a.Name() == "claude" {
+ claude = a
+ break
+ }
+ }
+ if claude == nil {
+ t.Fatal("claude agent not found")
+ }
+ if claude.DisplayName() != "My Claude" {
+ t.Errorf("DisplayName = %q, want My Claude", claude.DisplayName())
+ }
+ // ClearInput should be no-op after override to false
+ c := claude.(Configurable)
+ if c.Base().clearFirst {
+ t.Error("clearFirst should be false after override")
+ }
+ // DetectPattern should be preserved from builtin
+ if c.Base().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 Agent
+ for _, ag := range agents {
+ if ag.Name() == "claude" {
+ a = ag
+ break
+ }
+ }
+ if a == nil {
+ t.Fatal("claude agent not found")
+ }
+ c := a.(Configurable)
+ base := c.Base()
+ if base.detectPattern != "(?i)custom-claude" {
+ t.Errorf("detectPattern = %q", base.detectPattern)
+ }
+ if base.promptPat != `>\s+(.*)$` {
+ t.Errorf("promptPat = %q", base.promptPat)
+ }
+ if len(base.stripPatterns) != 1 || base.stripPatterns[0] != "NOISE" {
+ t.Errorf("stripPatterns = %v", base.stripPatterns)
+ }
+ if base.clearKeys != "C-k" {
+ t.Errorf("clearKeys = %q", base.clearKeys)
+ }
+ if base.newlineKeys != "C-Enter" {
+ t.Errorf("newlineKeys = %q", base.newlineKeys)
+ }
+ if base.submitKeys != "C-m" {
+ t.Errorf("submitKeys = %q", base.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())
+ }
+ c := a.(Configurable)
+ if !c.Base().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 TestConfigAgent_ExtractPrompt(t *testing.T) {
+ // Config agent uses baseAgent's default extraction (section-aware)
+ agent := &configAgent{baseAgent{
+ promptPat: `(?m)>\s*(.+)$`,
+ }}
+ content := "> hello world"
+ got := agent.ExtractPrompt(content)
+ if got != "hello world" {
+ t.Errorf("ExtractPrompt() = %q, want %q", got, "hello world")
+ }
+}
+
+func TestConfigAgent_Amp(t *testing.T) {
+ agents := builtinAgents()
+ var amp Agent
+ for _, a := range agents {
+ if a.Name() == "amp" {
+ amp = a
+ break
+ }
+ }
+ if amp == nil {
+ t.Fatal("amp agent not found")
+ }
+ if !amp.Detect("Amp by Sourcegraph") {
+ t.Error("amp should detect 'Amp by Sourcegraph'")
+ }
+ // Amp uses box-drawing TUI format (like cursor), not shell-style > prompt
+ got := amp.ExtractPrompt("│ fix the bug │")
+ if got != "fix the bug" {
+ t.Errorf("ExtractPrompt() = %q, want %q", got, "fix the bug")
+ }
+}
+
+func TestConfigAgent_Aider(t *testing.T) {
+ agents := builtinAgents()
+ var aider Agent
+ for _, a := range agents {
+ if a.Name() == "aider" {
+ aider = a
+ break
+ }
+ }
+ if aider == nil {
+ t.Fatal("aider agent not found")
+ }
+ if !aider.Detect("aider v0.50") {
+ t.Error("aider should detect 'aider v0.50'")
+ }
+}
diff --git a/internal/tmuxedit/cursor_agent.go b/internal/tmuxedit/cursor_agent.go
new file mode 100644
index 0000000..1346d05
--- /dev/null
+++ b/internal/tmuxedit/cursor_agent.go
@@ -0,0 +1,58 @@
+package tmuxedit
+
+import (
+ "regexp"
+)
+
+// cursorAgent handles Cursor's distinctive box-drawing │ → prompt │ UI.
+// Cursor uses a text field (not vim), so clearing is done with End + bulk
+// backspace. Multi-line prompts are entered with Shift-Enter within the box.
+type cursorAgent struct{ baseAgent }
+
+// newCursorAgent returns a cursorAgent with the default configuration.
+// Detect by the box structure or "/ commands" footer. Checked first because
+// cursor panes often show model names like "Claude 4.5 Sonnet".
+func newCursorAgent() *cursorAgent {
+ return &cursorAgent{baseAgent{
+ name: "cursor",
+ displayName: "Cursor",
+ detectPattern: `(│\s*→|/ commands · @ files)`,
+ promptPat: `(?m)│\s*→?\s*(.+?)\s*│\s*$`,
+ stripPatterns: []string{"INSERT", "Add a follow-up", "ctrl+c to stop"},
+ clearFirst: true,
+ clearKeys: "End BSpace*200",
+ newlineKeys: "S-Enter",
+ submitKeys: "Enter",
+ }}
+}
+
+// ExtractPrompt extracts the prompt text from the last contiguous │...│ block
+// in the pane. This avoids picking up earlier command-review or dialog boxes
+// that also use box-drawing characters.
+func (c *cursorAgent) ExtractPrompt(paneContent string) string {
+ if c.promptPat == "" {
+ return ""
+ }
+ re, err := regexp.Compile(c.promptPat)
+ if err != nil {
+ return ""
+ }
+ allMatches := matchPromptLines(re, paneContent)
+ if len(allMatches) == 0 {
+ return ""
+ }
+ return joinLastContiguousBlock(allMatches, c.stripPatterns)
+}
+
+// ClearInput sends End + 200 backspaces to clear Cursor's text field.
+// Cursor's input is a standard text field, not vim.
+func (c *cursorAgent) ClearInput(paneID string) error {
+ if !c.clearFirst || c.clearKeys == "" {
+ return nil
+ }
+ if err := sendClearSequence(paneID, c.clearKeys); err != nil {
+ return err
+ }
+ sleepAfterClear()
+ return nil
+}
diff --git a/internal/tmuxedit/cursor_agent_test.go b/internal/tmuxedit/cursor_agent_test.go
new file mode 100644
index 0000000..28d7fe1
--- /dev/null
+++ b/internal/tmuxedit/cursor_agent_test.go
@@ -0,0 +1,140 @@
+package tmuxedit
+
+import (
+ "fmt"
+ "strings"
+ "testing"
+)
+
+func TestCursorAgent_ExtractPrompt(t *testing.T) {
+ agent := newCursorAgent()
+ tests := []struct {
+ name string
+ content string
+ want string
+ }{
+ {
+ name: "box with arrow",
+ content: "Cursor Agent\n │ → fix the bug INSERT │",
+ want: "fix the bug",
+ },
+ {
+ name: "box without arrow",
+ content: "Cursor Agent\n │ fix the bug │",
+ want: "fix the bug",
+ },
+ {
+ name: "strips follow-up placeholder",
+ content: "Cursor\n │ → Add a follow-up │",
+ want: "",
+ },
+ {
+ name: "multi-line prompt",
+ content: " │ → first line of prompt │\n │ second line here │\n │ third line end │",
+ want: "first line of prompt\nsecond line here\nthird line end",
+ },
+ {
+ name: "multi-line with noise",
+ content: " │ → fix the bug INSERT │\n │ also refactor tests │",
+ want: "fix the bug\nalso refactor tests",
+ },
+ {
+ name: "multi-box takes last box only",
+ content: " ┌──────────────┐\n" +
+ " │ $ git push │\n" +
+ " └──────────────┘\n" +
+ " ┌──────────────┐\n" +
+ " │ Run command? │\n" +
+ " │ → Yes (enter) │\n" +
+ " │ No (esc) │\n" +
+ " └──────────────┘\n" +
+ " ┌──────────────┐\n" +
+ " │ → hello world │\n" +
+ " └──────────────┘\n",
+ want: "hello world",
+ },
+ {
+ name: "multi-box multi-line prompt",
+ content: " ┌──────────────┐\n" +
+ " │ $ git push │\n" +
+ " └──────────────┘\n" +
+ " ┌──────────────┐\n" +
+ " │ → first line │\n" +
+ " │ second line │\n" +
+ " │ third line │\n" +
+ " └──────────────┘\n",
+ want: "first line\nsecond line\nthird line",
+ },
+ {
+ name: "no match",
+ content: "no prompt here",
+ want: "",
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := agent.ExtractPrompt(tt.content)
+ if got != tt.want {
+ t.Errorf("ExtractPrompt() = %q, want %q", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestCursorAgent_ClearInput(t *testing.T) {
+ noSleep(t)
+ var calls []string
+ oldSend := sendKeys
+ oldRepeat := sendRepeatedKey
+ defer func() {
+ sendKeys = oldSend
+ sendRepeatedKey = oldRepeat
+ }()
+ sendKeys = func(paneID string, keys ...string) error {
+ calls = append(calls, fmt.Sprintf("send:%s:%s", paneID, strings.Join(keys, ",")))
+ return nil
+ }
+ sendRepeatedKey = func(paneID, key string, count int) error {
+ calls = append(calls, fmt.Sprintf("repeat:%s:%s*%d", paneID, key, count))
+ return nil
+ }
+
+ agent := newCursorAgent()
+ err := agent.ClearInput("%5")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ // "End BSpace*200" should send End normally, then BSpace 200 times via -N
+ want := []string{
+ "send:%5:End",
+ "repeat:%5:BSpace*200",
+ }
+ 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)
+ }
+ }
+}
+
+func TestCursorAgent_Detect(t *testing.T) {
+ agent := newCursorAgent()
+ tests := []struct {
+ name string
+ content string
+ want bool
+ }{
+ {"box with arrow", "│ → type here │", true},
+ {"commands footer", "/ commands · @ files", true},
+ {"no match", "some text", false},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := agent.Detect(tt.content); got != tt.want {
+ t.Errorf("Detect() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
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
+ }
+ // 2. Environment variable
+ if p := strings.TrimSpace(os.Getenv("HEXAI_TMUX_PANE")); p != "" {
+ return p, nil
+ }
+ // 3. Query tmux for the active pane in the current window
+ return queryActivePane()
+}
+
+// queryActivePane asks tmux for the active pane ID using display-message.
+func queryActivePane() (string, error) {
+ out, err := runCommand("tmux", "display-message", "-p", "#{pane_id}")
+ if err != nil {
+ return "", fmt.Errorf("cannot determine tmux pane: %w", err)
+ }
+ pane := strings.TrimSpace(string(out))
+ if pane == "" {
+ return "", fmt.Errorf("tmux returned empty pane ID")
+ }
+ return pane, nil
+}
diff --git a/internal/tmuxedit/pane_test.go b/internal/tmuxedit/pane_test.go
new file mode 100644
index 0000000..5b6f1b6
--- /dev/null
+++ b/internal/tmuxedit/pane_test.go
@@ -0,0 +1,83 @@
+package tmuxedit
+
+import (
+ "fmt"
+ "testing"
+)
+
+func TestResolveTargetPane_FlagWins(t *testing.T) {
+ old := runCommand
+ defer func() { runCommand = old }()
+ runCommand = func(string, ...string) ([]byte, error) {
+ return []byte("%99"), nil
+ }
+ t.Setenv("HEXAI_TMUX_PANE", "%10")
+ got, err := resolveTargetPane("%5")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if got != "%5" {
+ t.Errorf("got %q, want %%5 (flag should win)", got)
+ }
+}
+
+func TestResolveTargetPane_EnvFallback(t *testing.T) {
+ old := runCommand
+ defer func() { runCommand = old }()
+ runCommand = func(string, ...string) ([]byte, error) {
+ return []byte("%99"), nil
+ }
+ t.Setenv("HEXAI_TMUX_PANE", "%10")
+ got, err := resolveTargetPane("")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if got != "%10" {
+ t.Errorf("got %q, want %%10 (env fallback)", got)
+ }
+}
+
+func TestResolveTargetPane_TmuxQuery(t *testing.T) {
+ old := runCommand
+ defer func() { runCommand = old }()
+ runCommand = func(name string, args ...string) ([]byte, error) {
+ if name == "tmux" && len(args) > 0 && args[0] == "display-message" {
+ return []byte("%42\n"), nil
+ }
+ return nil, fmt.Errorf("unexpected command: %s", name)
+ }
+ t.Setenv("HEXAI_TMUX_PANE", "")
+ got, err := resolveTargetPane("")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if got != "%42" {
+ t.Errorf("got %q, want %%42 (tmux query)", got)
+ }
+}
+
+func TestResolveTargetPane_TmuxError(t *testing.T) {
+ old := runCommand
+ defer func() { runCommand = old }()
+ runCommand = func(string, ...string) ([]byte, error) {
+ return nil, fmt.Errorf("tmux not available")
+ }
+ t.Setenv("HEXAI_TMUX_PANE", "")
+ _, err := resolveTargetPane("")
+ if err == nil {
+ t.Fatal("expected error when tmux fails")
+ }
+}
+
+func TestResolveTargetPane_TmuxEmptyOutput(t *testing.T) {
+ old := runCommand
+ defer func() { runCommand = old }()
+ runCommand = func(string, ...string) ([]byte, error) {
+ return []byte(" \n"), nil
+ }
+ t.Setenv("HEXAI_TMUX_PANE", "")
+ _, err := resolveTargetPane("")
+ if err == nil {
+ t.Fatal("expected error for empty tmux output")
+ }
+}
diff --git a/internal/tmuxedit/run.go b/internal/tmuxedit/run.go
new file mode 100644
index 0000000..f81eb64
--- /dev/null
+++ b/internal/tmuxedit/run.go
@@ -0,0 +1,211 @@
+package tmuxedit
+
+import (
+ "fmt"
+ "log"
+ "os"
+ "os/exec"
+ "strings"
+
+ "codeberg.org/snonux/hexai/internal/appconfig"
+ "codeberg.org/snonux/hexai/internal/editor"
+ "codeberg.org/snonux/hexai/internal/tmux"
+)
+
+// Options holds the parsed command-line flags for hexai-tmux-edit.
+type Options struct {
+ ConfigPath string // --config flag
+ Agent string // --agent flag (explicit agent name, or auto-detect)
+ Pane string // --pane flag (target pane ID)
+}
+
+// openEditorPopup is the seam for opening an editor in a tmux popup.
+// It creates a temp file, opens it in a tmux popup with the user's editor,
+// waits for completion, and returns the edited content. Override in tests.
+var openEditorPopup = func(initial, popupW, popupH string) (string, error) {
+ ed, err := editor.Resolve()
+ if err != nil {
+ return "", err
+ }
+ // Create a temp file with the initial content
+ f, err := os.CreateTemp("", "hexai-tmux-edit-*.md")
+ if err != nil {
+ return "", fmt.Errorf("create temp file: %w", err)
+ }
+ path := f.Name()
+ defer func() { _ = os.Remove(path) }()
+
+ if initial != "" {
+ if _, err := f.WriteString(initial); err != nil {
+ _ = f.Close()
+ return "", fmt.Errorf("write initial content: %w", err)
+ }
+ }
+ if err := f.Close(); err != nil {
+ return "", fmt.Errorf("close temp file: %w", err)
+ }
+
+ // Build the tmux display-popup command to launch the editor
+ if err := launchPopup(ed, path, popupW, popupH); err != nil {
+ return "", fmt.Errorf("popup editor: %w", err)
+ }
+
+ b, err := os.ReadFile(path)
+ if err != nil {
+ return "", fmt.Errorf("read edited file: %w", err)
+ }
+ return strings.TrimSpace(string(b)), nil
+}
+
+// launchPopup is the seam for running `tmux display-popup` with the editor.
+// The -E flag makes the popup close when the editor exits. Uses .Run()
+// (not .Output()) so the popup blocks until the user closes the editor.
+var launchPopup = func(ed, path, width, height string) error {
+ args := []string{"display-popup", "-E"}
+ if width != "" {
+ args = append(args, "-w", width)
+ }
+ if height != "" {
+ args = append(args, "-h", height)
+ }
+ args = append(args, ed+" "+shellQuote(path))
+ return exec.Command("tmux", args...).Run()
+}
+
+// shellQuote wraps a path in single quotes for safe shell use.
+func shellQuote(s string) string {
+ return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
+}
+
+// Run is the main orchestrator for hexai-tmux-edit. It:
+// 1. Checks tmux availability
+// 2. Resolves the target pane
+// 3. Captures pane content
+// 4. Detects or selects the agent
+// 5. Extracts the current prompt
+// 6. Opens the editor in a popup
+// 7. Deduplicates and sends edited text back
+func Run(opts Options) error {
+ if !tmux.Available() {
+ return fmt.Errorf("tmux is not available (not in a tmux session)")
+ }
+ cfg := loadConfig(opts.ConfigPath)
+ return runWithConfig(opts, cfg)
+}
+
+// loadConfig loads the application config, extracting tmux_edit settings.
+func loadConfig(configPath string) appconfig.App {
+ logger := log.New(os.Stderr, "[hexai-tmux-edit] ", log.LstdFlags)
+ lopts := appconfig.LoadOptions{ConfigPath: configPath}
+ return appconfig.LoadWithOptions(logger, lopts)
+}
+
+// debugLog is the debug logger. Set to a real logger via initDebugLog().
+var debugLog *log.Logger
+
+// initDebugLog creates a debug log file at /tmp/hexai-tmux-edit.log.
+func initDebugLog() {
+ f, err := os.OpenFile("/tmp/hexai-tmux-edit.log", os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
+ if err != nil {
+ return
+ }
+ debugLog = log.New(f, "", log.LstdFlags|log.Lmicroseconds)
+}
+
+func dbg(format string, args ...any) {
+ if debugLog != nil {
+ debugLog.Printf(format, args...)
+ }
+}
+
+// runWithConfig executes the edit workflow using the provided config.
+// It resolves the agent (by name or auto-detect), extracts the current
+// prompt, opens the editor popup, then clears and sends the result.
+func runWithConfig(opts Options, cfg appconfig.App) error {
+ initDebugLog()
+ dbg("=== hexai-tmux-edit start ===")
+ dbg("opts: pane=%q agent=%q config=%q", opts.Pane, opts.Agent, opts.ConfigPath)
+
+ paneID, err := resolveTargetPane(opts.Pane)
+ if err != nil {
+ dbg("resolveTargetPane error: %v", err)
+ return err
+ }
+ dbg("resolved pane: %q", paneID)
+
+ content, err := capturePane(paneID)
+ if err != nil {
+ dbg("capturePane error: %v", err)
+ return err
+ }
+ dbg("captured %d bytes from pane", len(content))
+ logPaneLines(content)
+
+ agents := resolveAgents(cfg.TmuxEditAgents)
+ agent := pickAgent(opts.Agent, content, agents)
+ dbg("agent: name=%q", agent.Name())
+
+ original := agent.ExtractPrompt(content)
+ dbg("extractPrompt result: %q", original)
+
+ popupW, popupH := popupDimensions(cfg)
+ dbg("opening editor popup: w=%s h=%s initial=%q", popupW, popupH, original)
+
+ edited, err := openEditorPopup(original, popupW, popupH)
+ if err != nil {
+ dbg("openEditorPopup error: %v", err)
+ return err
+ }
+ dbg("editor returned: %q", edited)
+
+ text := deduplicateText(original, edited)
+ dbg("deduplicateText result: %q", text)
+ if text == "" {
+ dbg("nothing to send, exiting")
+ return nil
+ }
+
+ dbg("clearing and sending to pane %q: %q", paneID, text)
+ if err := agent.ClearInput(paneID); err != nil {
+ dbg("ClearInput error: %v", err)
+ return err
+ }
+ if err := agent.SendText(paneID, text); err != nil {
+ dbg("SendText error: %v", err)
+ return err
+ }
+ dbg("=== done ===")
+ return nil
+}
+
+// logPaneLines logs lines containing box-drawing or arrow characters for
+// debugging prompt detection.
+func logPaneLines(content string) {
+ for i, line := range strings.Split(content, "\n") {
+ if strings.Contains(line, "│") || strings.Contains(line, "→") {
+ dbg(" pane line %d: %q", i, line)
+ }
+ }
+}
+
+// popupDimensions returns the popup width and height from config, defaulting
+// to "80%" for both if not set.
+func popupDimensions(cfg appconfig.App) (string, string) {
+ w := cfg.TmuxEditPopupWidth
+ if w == "" {
+ w = "80%"
+ }
+ h := cfg.TmuxEditPopupHeight
+ if h == "" {
+ h = "80%"
+ }
+ return w, h
+}
+
+// pickAgent selects an agent by explicit name or auto-detection.
+func pickAgent(name, content string, agents []Agent) Agent {
+ if name != "" {
+ return findAgentByName(name, agents)
+ }
+ return detectAgent(content, agents)
+}
diff --git a/internal/tmuxedit/run_test.go b/internal/tmuxedit/run_test.go
new file mode 100644
index 0000000..1b603e4
--- /dev/null
+++ b/internal/tmuxedit/run_test.go
@@ -0,0 +1,323 @@
+package tmuxedit
+
+import (
+ "fmt"
+ "strings"
+ "testing"
+
+ "codeberg.org/snonux/hexai/internal/appconfig"
+)
+
+func TestRunWithConfig_HappyPath(t *testing.T) {
+ noSleep(t)
+ // Save and restore all seams
+ oldCapture := capturePane
+ oldSendKeys := sendKeys
+ oldEditorPopup := openEditorPopup
+ oldRunCmd := runCommand
+ defer func() {
+ capturePane = oldCapture
+ sendKeys = oldSendKeys
+ openEditorPopup = oldEditorPopup
+ runCommand = oldRunCmd
+ }()
+
+ // Mock: pane resolution via tmux query
+ runCommand = func(name string, args ...string) ([]byte, error) {
+ if name == "tmux" && args[0] == "display-message" {
+ return []byte("%5"), nil
+ }
+ return nil, nil
+ }
+
+ // Mock: capture pane content with Claude Code agent detected
+ capturePane = func(paneID string) (string, error) {
+ return "claude code v1.0\n──────\n❯ fix the bug\n──────", nil
+ }
+
+ // Mock: editor popup returns modified text
+ openEditorPopup = func(initial, w, h string) (string, error) {
+ if initial != "fix the bug" {
+ t.Errorf("initial = %q, want 'fix the bug'", initial)
+ }
+ if w != "80%" || h != "80%" {
+ t.Errorf("dimensions = %sx%s, want 80%%x80%%", w, h)
+ }
+ return "fix the bug\nalso refactor the module", nil
+ }
+
+ // Track send-keys calls
+ var sent []string
+ sendKeys = func(paneID string, keys ...string) error {
+ sent = append(sent, strings.Join(keys, ","))
+ return nil
+ }
+
+ cfg := appconfig.App{}
+ err := runWithConfig(Options{}, cfg)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ // Should have sent: clear keys, then the full edited text (both lines)
+ if len(sent) < 2 {
+ t.Fatalf("expected at least 2 send calls (clear + text), got %d: %v", len(sent), sent)
+ }
+
+ allSent := strings.Join(sent, "|")
+ if !strings.Contains(allSent, "fix the bug") {
+ t.Errorf("expected 'fix the bug' in sent calls: %v", sent)
+ }
+ if !strings.Contains(allSent, "also refactor the module") {
+ t.Errorf("expected 'also refactor the module' in sent calls: %v", sent)
+ }
+}
+
+func TestRunWithConfig_ExplicitAgent(t *testing.T) {
+ noSleep(t)
+ oldCapture := capturePane
+ oldSendKeys := sendKeys
+ oldEditorPopup := openEditorPopup
+ oldRunCmd := runCommand
+ defer func() {
+ capturePane = oldCapture
+ sendKeys = oldSendKeys
+ openEditorPopup = oldEditorPopup
+ runCommand = oldRunCmd
+ }()
+
+ runCommand = func(name string, args ...string) ([]byte, error) {
+ return []byte("%1"), nil
+ }
+ capturePane = func(string) (string, error) {
+ return "some generic content\n> hello", nil
+ }
+ openEditorPopup = func(initial, w, h string) (string, error) {
+ // With cursor agent, prompt extraction uses │ pattern, so initial should be empty
+ if initial != "" {
+ t.Errorf("initial = %q, want empty (cursor agent doesn't match > pattern)", initial)
+ }
+ return "new prompt", nil
+ }
+ sendKeys = func(string, ...string) error { return nil }
+
+ cfg := appconfig.App{}
+ err := runWithConfig(Options{Agent: "cursor"}, cfg)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestRunWithConfig_EditorEmpty(t *testing.T) {
+ oldCapture := capturePane
+ oldSendKeys := sendKeys
+ oldEditorPopup := openEditorPopup
+ oldRunCmd := runCommand
+ defer func() {
+ capturePane = oldCapture
+ sendKeys = oldSendKeys
+ openEditorPopup = oldEditorPopup
+ runCommand = oldRunCmd
+ }()
+
+ runCommand = func(name string, args ...string) ([]byte, error) {
+ return []byte("%1"), nil
+ }
+ capturePane = func(string) (string, error) {
+ return "claude code\n❯ ", nil
+ }
+ openEditorPopup = func(string, string, string) (string, error) {
+ return "", nil // user saved empty file
+ }
+ sendKeys = func(string, ...string) error {
+ t.Fatal("sendKeys should not be called when editor returns empty")
+ return nil
+ }
+
+ cfg := appconfig.App{}
+ err := runWithConfig(Options{}, cfg)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestRunWithConfig_CustomDimensions(t *testing.T) {
+ oldCapture := capturePane
+ oldSendKeys := sendKeys
+ oldEditorPopup := openEditorPopup
+ oldRunCmd := runCommand
+ defer func() {
+ capturePane = oldCapture
+ sendKeys = oldSendKeys
+ openEditorPopup = oldEditorPopup
+ runCommand = oldRunCmd
+ }()
+
+ runCommand = func(name string, args ...string) ([]byte, error) {
+ return []byte("%1"), nil
+ }
+ capturePane = func(string) (string, error) { return "", nil }
+ openEditorPopup = func(initial, w, h string) (string, error) {
+ if w != "90%" || h != "85%" {
+ t.Errorf("dimensions = %sx%s, want 90%%x85%%", w, h)
+ }
+ return "test", nil
+ }
+ sendKeys = func(string, ...string) error { return nil }
+
+ cfg := appconfig.App{
+ TmuxEditPopupWidth: "90%",
+ TmuxEditPopupHeight: "85%",
+ }
+ err := runWithConfig(Options{}, cfg)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestPickAgent_ExplicitName(t *testing.T) {
+ agents := builtinAgents()
+ got := pickAgent("cursor", "Claude Code detected", agents)
+ if got.Name() != "cursor" {
+ t.Errorf("pickAgent(cursor) = %q, want cursor (explicit name should win)", got.Name())
+ }
+}
+
+func TestPickAgent_AutoDetect(t *testing.T) {
+ agents := builtinAgents()
+ got := pickAgent("", "Amp by Sourcegraph", agents)
+ if got.Name() != "amp" {
+ t.Errorf("pickAgent('', amp content) = %q, want amp", got.Name())
+ }
+}
+
+func TestShellQuote(t *testing.T) {
+ tests := []struct {
+ input string
+ want string
+ }{
+ {"simple", "'simple'"},
+ {"with space", "'with space'"},
+ {"it's", "'it'\\''s'"},
+ }
+ for _, tt := range tests {
+ got := shellQuote(tt.input)
+ if got != tt.want {
+ t.Errorf("shellQuote(%q) = %q, want %q", tt.input, got, tt.want)
+ }
+ }
+}
+
+func TestLaunchPopup_CommandArgs(t *testing.T) {
+ oldLaunch := launchPopup
+ defer func() { launchPopup = oldLaunch }()
+
+ var capturedArgs struct {
+ ed, path, w, h string
+ }
+ launchPopup = func(ed, path, w, h string) error {
+ capturedArgs.ed = ed
+ capturedArgs.path = path
+ capturedArgs.w = w
+ capturedArgs.h = h
+ return nil
+ }
+
+ err := launchPopup("vim", "/tmp/test.md", "90%", "85%")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if capturedArgs.ed != "vim" {
+ t.Errorf("ed = %q, want vim", capturedArgs.ed)
+ }
+ if capturedArgs.w != "90%" || capturedArgs.h != "85%" {
+ t.Errorf("dimensions = %sx%s, want 90%%x85%%", capturedArgs.w, capturedArgs.h)
+ }
+}
+
+func TestLaunchPopup_NoDimensions(t *testing.T) {
+ oldLaunch := launchPopup
+ defer func() { launchPopup = oldLaunch }()
+
+ var capturedArgs struct {
+ w, h string
+ }
+ launchPopup = func(ed, path, w, h string) error {
+ capturedArgs.w = w
+ capturedArgs.h = h
+ return nil
+ }
+
+ err := launchPopup("nano", "/tmp/f.md", "", "")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if capturedArgs.w != "" || capturedArgs.h != "" {
+ t.Errorf("expected empty dimensions, got %qx%q", capturedArgs.w, capturedArgs.h)
+ }
+}
+
+func TestRunWithConfig_CaptureError(t *testing.T) {
+ oldCapture := capturePane
+ oldRunCmd := runCommand
+ defer func() {
+ capturePane = oldCapture
+ runCommand = oldRunCmd
+ }()
+
+ runCommand = func(name string, args ...string) ([]byte, error) {
+ return []byte("%1"), nil
+ }
+ capturePane = func(string) (string, error) {
+ return "", fmt.Errorf("capture failed")
+ }
+
+ cfg := appconfig.App{}
+ err := runWithConfig(Options{Pane: "%1"}, cfg)
+ if err == nil || !strings.Contains(err.Error(), "capture failed") {
+ t.Errorf("expected capture error, got: %v", err)
+ }
+}
+
+func TestRunWithConfig_EditorError(t *testing.T) {
+ oldCapture := capturePane
+ oldEditorPopup := openEditorPopup
+ oldRunCmd := runCommand
+ defer func() {
+ capturePane = oldCapture
+ openEditorPopup = oldEditorPopup
+ runCommand = oldRunCmd
+ }()
+
+ runCommand = func(name string, args ...string) ([]byte, error) {
+ return []byte("%1"), nil
+ }
+ capturePane = func(string) (string, error) {
+ return "some content", nil
+ }
+ openEditorPopup = func(string, string, string) (string, error) {
+ return "", fmt.Errorf("editor crashed")
+ }
+
+ cfg := appconfig.App{}
+ err := runWithConfig(Options{Pane: "%1"}, cfg)
+ if err == nil || !strings.Contains(err.Error(), "editor crashed") {
+ t.Errorf("expected editor error, got: %v", err)
+ }
+}
+
+func TestRunWithConfig_PaneResolveError(t *testing.T) {
+ oldRunCmd := runCommand
+ defer func() { runCommand = oldRunCmd }()
+
+ runCommand = func(string, ...string) ([]byte, error) {
+ return nil, fmt.Errorf("tmux unavailable")
+ }
+ t.Setenv("HEXAI_TMUX_PANE", "")
+
+ cfg := appconfig.App{}
+ err := runWithConfig(Options{}, cfg)
+ if err == nil {
+ t.Fatal("expected error for pane resolution failure")
+ }
+}
diff --git a/internal/tmuxedit/send.go b/internal/tmuxedit/send.go
new file mode 100644
index 0000000..7a6bce2
--- /dev/null
+++ b/internal/tmuxedit/send.go
@@ -0,0 +1,47 @@
+package tmuxedit
+
+import (
+ "fmt"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// sendKeys is the seam for `tmux send-keys`. Override in tests.
+var sendKeys = func(paneID string, keys ...string) error {
+ args := append([]string{"send-keys", "-t", paneID}, keys...)
+ _, err := runCommand("tmux", args...)
+ if err != nil {
+ return fmt.Errorf("send-keys failed: %w", err)
+ }
+ return nil
+}
+
+// sendRepeatedKey is the seam for `tmux send-keys -N <count>`. Override in
+// tests. Uses -N for efficient bulk key repeats (e.g. 200 backspaces).
+var sendRepeatedKey = func(paneID, key string, count int) error {
+ args := []string{"send-keys", "-t", paneID, "-N", strconv.Itoa(count), key}
+ _, err := runCommand("tmux", args...)
+ if err != nil {
+ return fmt.Errorf("send-keys -N failed: %w", err)
+ }
+ return nil
+}
+
+// sleepAfterClear pauses to let the TUI drain queued keystrokes (like bulk
+// backspaces) before new text is sent. Override in tests to avoid delays.
+var sleepAfterClear = func() { time.Sleep(300 * time.Millisecond) }
+
+// deduplicateText compares the original (pre-filled) text with what the user
+// returned from the editor. Returns empty string if unchanged (no-op), or
+// the full edited text if anything changed. The caller is responsible for
+// clearing existing pane input before sending the result, so we always return
+// the complete text rather than stripping the original prefix.
+func deduplicateText(original, edited string) string {
+ original = strings.TrimSpace(original)
+ edited = strings.TrimSpace(edited)
+ if edited == "" || edited == original {
+ return ""
+ }
+ return edited
+}
diff --git a/internal/tmuxedit/send_test.go b/internal/tmuxedit/send_test.go
new file mode 100644
index 0000000..3722d1a
--- /dev/null
+++ b/internal/tmuxedit/send_test.go
@@ -0,0 +1,122 @@
+package tmuxedit
+
+import (
+ "fmt"
+ "strings"
+ "testing"
+)
+
+// noSleep disables the post-clear sleep in tests and restores it on cleanup.
+func noSleep(t *testing.T) {
+ t.Helper()
+ old := sleepAfterClear
+ sleepAfterClear = func() {}
+ t.Cleanup(func() { sleepAfterClear = old })
+}
+
+func TestDeduplicateText(t *testing.T) {
+ tests := []struct {
+ name string
+ original string
+ edited string
+ want string
+ }{
+ {"empty both", "", "", ""},
+ {"empty original", "", "new text", "new text"},
+ {"empty edited", "original", "", ""},
+ {"unchanged", "hello world", "hello world", ""},
+ {"appended", "hello", "hello world", "hello world"},
+ {"rewritten", "hello world", "goodbye world", "goodbye world"},
+ {"whitespace handling", " hello ", " hello world ", "hello world"},
+ {"appended with newlines", "line1\nline2", "line1\nline2\nline3", "line1\nline2\nline3"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := deduplicateText(tt.original, tt.edited)
+ if got != tt.want {
+ t.Errorf("deduplicateText(%q, %q) = %q, want %q",
+ tt.original, tt.edited, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestSendLines_SingleLine(t *testing.T) {
+ var calls []string
+ oldSend := sendKeys
+ defer func() { sendKeys = oldSend }()
+ sendKeys = func(paneID string, keys ...string) error {
+ calls = append(calls, fmt.Sprintf("send:%s:%s", paneID, strings.Join(keys, ",")))
+ return nil
+ }
+
+ err := sendLines("%5", "hello", "S-Enter")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if len(calls) != 1 {
+ t.Fatalf("got %d calls, want 1: %v", len(calls), calls)
+ }
+ if calls[0] != "send:%5:hello" {
+ t.Errorf("call[0] = %q, want text", calls[0])
+ }
+}
+
+func TestSendLines_MultiLine(t *testing.T) {
+ var calls []string
+ oldSend := sendKeys
+ defer func() { sendKeys = oldSend }()
+ sendKeys = func(paneID string, keys ...string) error {
+ calls = append(calls, strings.Join(keys, ","))
+ return nil
+ }
+
+ err := sendLines("%1", "line1\nline2\nline3", "S-Enter")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ want := []string{"line1", "S-Enter", "line2", "S-Enter", "line3"}
+ 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)
+ }
+ }
+}
+
+func TestSendLines_FallbackNewline(t *testing.T) {
+ var calls []string
+ oldSend := sendKeys
+ defer func() { sendKeys = oldSend }()
+ sendKeys = func(paneID string, keys ...string) error {
+ calls = append(calls, strings.Join(keys, ","))
+ return nil
+ }
+
+ // Empty newlineKeys should fallback to "Enter"
+ err := sendLines("%1", "a\nb", "")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if len(calls) != 3 {
+ t.Fatalf("got %d calls, want 3: %v", len(calls), calls)
+ }
+ if calls[1] != "Enter" {
+ t.Errorf("newline key = %q, want Enter (fallback)", calls[1])
+ }
+}
+
+func TestSendLines_Error(t *testing.T) {
+ oldSend := sendKeys
+ defer func() { sendKeys = oldSend }()
+ sendKeys = func(string, ...string) error {
+ return fmt.Errorf("send failed")
+ }
+
+ err := sendLines("%1", "hello", "Enter")
+ if err == nil {
+ t.Fatal("expected error on send failure")
+ }
+}
diff --git a/internal/version.go b/internal/version.go
index 737fa97..d4ebeb5 100644
--- a/internal/version.go
+++ b/internal/version.go
@@ -1,4 +1,4 @@
// Summary: Hexai semantic version identifier used by CLI and LSP binaries.
package internal
-const Version = "0.17.0"
+const Version = "0.18.1"
diff --git a/prompts/tmux-edit-integration-tests.md b/prompts/tmux-edit-integration-tests.md
new file mode 100644
index 0000000..a0525b0
--- /dev/null
+++ b/prompts/tmux-edit-integration-tests.md
@@ -0,0 +1,445 @@
+# hexai-tmux-edit Integration Test Runbook
+
+Real-life integration tests against actual tmux panes running AI agent CLIs.
+These tests verify prompt capture, agent detection, multi-line extraction,
+text clearing, and the full edit-and-replace flow.
+
+**Test Status**:
+- ✅ **cursor-agent**: All tests passed (Feb 8, 2026)
+- ✅ **amp**: All tests passed (Feb 8, 2026) - Note: amp uses TUI mode with Emacs/readline keybindings
+- ⏳ **claude**: Needs testing
+- ⏳ **aider**: Needs testing
+
+**Important**: Agent detection and prompt extraction rely on regex patterns
+matched against each agent's TUI output (box-drawing characters, prompt
+symbols, status text). When agents update their TUI, these patterns may
+break. If tests fail after an agent update, check the built-in patterns in
+`internal/tmuxedit/agent.go` `builtinAgents()` and adjust the
+`DetectPattern`, `PromptPattern`, and `StripPatterns` fields accordingly.
+Users can also override patterns via `[[tmux_edit.agents]]` in config
+without code changes.
+
+## Prerequisites
+
+- Must be running inside a tmux session
+- `hexai-tmux-edit` binary must be installed (`go build -o ~/go/bin/hexai-tmux-edit ./cmd/hexai-tmux-edit/`)
+- At least one tmux pane running `cursor-agent` with an empty prompt
+- All unit tests must pass first: `go test ./internal/tmuxedit/`
+
+## Finding a test pane
+
+List all panes and pick one running cursor-agent with an idle prompt:
+
+```sh
+tmux list-panes -a -F '#{pane_id} #{window_name} #{pane_current_command}'
+```
+
+Look for a pane running `cursor-agent`. Use its `%NN` pane ID throughout.
+Verify it has an empty input prompt:
+
+```sh
+tmux capture-pane -p -t '%NN' | tail -10
+```
+
+You should see the box-drawing prompt:
+```
+ ┌─────────────────────┐
+ │ → Add a follow-up │
+ └─────────────────────┘
+```
+
+If the prompt has text in it, clear it first:
+
+```sh
+tmux send-keys -t '%NN' End && sleep 0.1 && tmux send-keys -t '%NN' -N 200 BSpace
+```
+
+## Test 1: Single-line prompt capture
+
+**Goal**: Verify that text typed into cursor-agent's prompt is correctly
+detected and extracted.
+
+```sh
+# 1. Send test text to the prompt
+tmux send-keys -t '%NN' 'hello world test'
+
+# 2. Wait for the TUI to render
+sleep 1
+
+# 3. Verify text appeared in prompt
+tmux capture-pane -p -t '%NN' | grep '│.*→'
+# Expected: │ → hello world test │
+
+# 4. Write a Go test script to verify extraction logic
+cat > /tmp/test_capture.go << 'GOEOF'
+//go:build ignore
+
+package main
+
+import (
+ "fmt"
+ "os"
+ "os/exec"
+ "regexp"
+ "strings"
+)
+
+func main() {
+ pane := os.Args[1]
+ out, err := exec.Command("tmux", "capture-pane", "-p", "-t", pane).Output()
+ if err != nil {
+ fmt.Printf("FAIL: capture error: %v\n", err)
+ os.Exit(1)
+ }
+ content := string(out)
+
+ // Verify cursor agent detected
+ detectRe := regexp.MustCompile(`(│\s*→|/ commands · @ files)`)
+ if !detectRe.MatchString(content) {
+ fmt.Println("FAIL: cursor agent not detected")
+ os.Exit(1)
+ }
+ fmt.Println("PASS: cursor agent detected")
+
+ // Verify prompt extracted
+ promptRe := regexp.MustCompile(`(?m)│\s*→?\s*(.+?)\s*│\s*$`)
+ m := promptRe.FindStringSubmatch(content)
+ if len(m) < 2 {
+ fmt.Println("FAIL: no prompt match")
+ os.Exit(1)
+ }
+ text := m[1]
+ for _, s := range []string{"INSERT", "Add a follow-up", "ctrl+c to stop"} {
+ text = strings.ReplaceAll(text, s, "")
+ }
+ text = strings.TrimSpace(text)
+ fmt.Printf("PASS: extracted %q\n", text)
+}
+GOEOF
+
+go run /tmp/test_capture.go '%NN'
+# Expected: PASS: cursor agent detected
+# PASS: extracted "hello world test"
+
+# 5. Clean up
+tmux send-keys -t '%NN' End && sleep 0.1 && tmux send-keys -t '%NN' -N 200 BSpace
+rm /tmp/test_capture.go
+```
+
+## Test 2: Multi-box disambiguation
+
+**Goal**: Verify that when cursor-agent shows multiple box-drawing sections
+(e.g. follow-ups box + input prompt), only the last box (the input prompt)
+is captured.
+
+This test requires a pane that has a follow-ups section above the input
+prompt. You can create this state by sending text with Shift+Enter (which
+cursor interprets as submit-and-queue-follow-up).
+
+```sh
+# 1. Verify the pane has multiple boxes visible
+tmux capture-pane -p -t '%NN' | grep '│' | head -20
+# Look for multiple │ lines from different boxes
+
+# 2. Type text into the (bottom) input prompt
+tmux send-keys -t '%NN' 'only this should be captured'
+sleep 1
+
+# 3. Run extraction and verify only the last box is picked
+cat > /tmp/test_multibox.go << 'GOEOF'
+//go:build ignore
+
+package main
+
+import (
+ "fmt"
+ "os"
+ "os/exec"
+ "regexp"
+ "strings"
+)
+
+type promptMatch struct {
+ lineNum int
+ text string
+}
+
+func main() {
+ pane := os.Args[1]
+ out, err := exec.Command("tmux", "capture-pane", "-p", "-t", pane).Output()
+ if err != nil {
+ fmt.Printf("FAIL: capture error: %v\n", err)
+ os.Exit(1)
+ }
+ content := string(out)
+ re := regexp.MustCompile(`(?m)│\s*→?\s*(.+?)\s*│\s*$`)
+ strips := []string{"INSERT", "Add a follow-up", "ctrl+c to stop"}
+
+ // Find all matches with line numbers
+ paneLines := strings.Split(content, "\n")
+ var matches []promptMatch
+ for i, line := range paneLines {
+ m := re.FindStringSubmatch(line)
+ if len(m) >= 2 {
+ matches = append(matches, promptMatch{i, m[1]})
+ }
+ }
+ fmt.Printf("Total matches across all boxes: %d\n", len(matches))
+
+ // Take last contiguous block
+ if len(matches) == 0 {
+ fmt.Println("FAIL: no matches found")
+ os.Exit(1)
+ }
+ last := len(matches) - 1
+ start := last
+ for start > 0 && matches[start].lineNum-matches[start-1].lineNum == 1 {
+ start--
+ }
+
+ fmt.Printf("Last contiguous block: lines %d-%d (%d matches)\n",
+ matches[start].lineNum, matches[last].lineNum, last-start+1)
+
+ var lines []string
+ for i := start; i <= last; i++ {
+ text := matches[i].text
+ for _, s := range strips {
+ text = strings.ReplaceAll(text, s, "")
+ }
+ text = strings.TrimSpace(text)
+ if text != "" {
+ lines = append(lines, text)
+ }
+ }
+ result := strings.Join(lines, "\n")
+ fmt.Printf("EXTRACTED: %q\n", result)
+
+ if strings.Contains(result, "only this should be captured") {
+ fmt.Println("PASS: last box correctly isolated")
+ } else {
+ fmt.Println("FAIL: wrong box content captured")
+ os.Exit(1)
+ }
+}
+GOEOF
+
+go run /tmp/test_multibox.go '%NN'
+# Expected: Total matches > 1 (from multiple boxes)
+# PASS: last box correctly isolated
+
+# 3. Clean up
+tmux send-keys -t '%NN' End && sleep 0.1 && tmux send-keys -t '%NN' -N 200 BSpace
+rm /tmp/test_multibox.go
+```
+
+## Test 3: Clear and retype flow
+
+**Goal**: Verify that `End + BSpace*200` clears the existing prompt text
+and new text can be inserted afterwards.
+
+```sh
+# 1. Type original text
+tmux send-keys -t '%NN' 'original text here'
+sleep 0.5
+tmux capture-pane -p -t '%NN' | grep '│.*→'
+# Expected: │ → original text here │
+
+# 2. Clear using the same method hexai-tmux-edit uses
+tmux send-keys -t '%NN' End
+sleep 0.1
+tmux send-keys -t '%NN' -N 200 BSpace
+sleep 0.5
+
+# 3. Verify prompt is empty
+tmux capture-pane -p -t '%NN' | grep '│.*→'
+# Expected: │ → Add a follow-up │ (placeholder = empty)
+
+# 4. Type replacement text
+tmux send-keys -t '%NN' 'replacement text here'
+sleep 0.5
+tmux capture-pane -p -t '%NN' | grep '│.*→'
+# Expected: │ → replacement text here │
+
+# 5. Clean up
+tmux send-keys -t '%NN' End && sleep 0.1 && tmux send-keys -t '%NN' -N 200 BSpace
+```
+
+## Test 4: Full end-to-end with mock editor
+
+**Goal**: Run the actual `hexai-tmux-edit` binary against a real pane,
+using a mock editor to automate the popup interaction. Verifies the
+complete flow: capture -> detect agent -> extract prompt -> edit -> clear
+-> send back.
+
+**Note**: Mock editors should include a small delay before exiting to ensure
+tmux popups close cleanly. Without the delay, the popup might not register
+the editor exit properly and could hang.
+
+```sh
+# 1. Type text into the prompt
+tmux send-keys -t '%NN' 'hello world'
+sleep 0.5
+tmux capture-pane -p -t '%NN' | grep '│.*→'
+# Expected: │ → hello world │
+
+# 2. Create a mock editor that replaces content (with delay for clean popup close)
+cat > /tmp/mock-editor.sh << 'SH'
+#!/bin/sh
+# Write new content
+echo "hello universe" > "$1"
+# Small delay ensures tmux popup closes cleanly
+sleep 0.1
+SH
+chmod +x /tmp/mock-editor.sh
+
+# 3. Run hexai-tmux-edit with the mock editor
+HEXAI_EDITOR=/tmp/mock-editor.sh hexai-tmux-edit --pane '%NN'
+# Wait for popup to close
+sleep 0.5
+
+# 4. Verify the prompt was updated
+tmux capture-pane -p -t '%NN' | grep '│.*→'
+# Expected: │ → hello universe │
+
+# 5. Check debug log for the full trace
+cat /tmp/hexai-tmux-edit.log
+# Expected log should show:
+# - agent detected as "cursor"
+# - extractPrompt result: "hello world"
+# - editor returned: "hello universe"
+# - deduplicateText result: "hello universe"
+# - sending to pane: "hello universe"
+# - === done ===
+
+# 6. Clean up
+rm /tmp/mock-editor.sh
+tmux send-keys -t '%NN' End && sleep 0.1 && tmux send-keys -t '%NN' -N 200 BSpace
+```
+
+## Test 5: Agent detection does not false-positive on model names
+
+**Goal**: Verify that a cursor-agent pane showing "Claude 4.5 Sonnet" as
+the model name is detected as cursor (not claude).
+
+```sh
+# 1. Capture pane content and check for model name
+tmux capture-pane -p -t '%NN' | grep -i 'claude\|sonnet'
+# This may show "Claude 4.5 Sonnet (Thinking)" in the status line
+
+# 2. Verify detection is still "cursor"
+cat > /tmp/test_detect.go << 'GOEOF'
+//go:build ignore
+
+package main
+
+import (
+ "fmt"
+ "os"
+ "os/exec"
+ "regexp"
+)
+
+func main() {
+ pane := os.Args[1]
+ out, _ := exec.Command("tmux", "capture-pane", "-p", "-t", pane).Output()
+ content := string(out)
+
+ agents := []struct {
+ name string
+ detect string
+ }{
+ {"cursor", `(│\s*→|/ commands · @ files)`},
+ {"claude", `(❯|claude code|anthropic)`},
+ }
+ for _, a := range agents {
+ re := regexp.MustCompile(a.detect)
+ if re.MatchString(content) {
+ fmt.Printf("Detected: %s\n", a.name)
+ if a.name == "cursor" {
+ fmt.Println("PASS: correctly detected as cursor")
+ } else {
+ fmt.Println("FAIL: should be cursor, not " + a.name)
+ os.Exit(1)
+ }
+ return
+ }
+ }
+ fmt.Println("FAIL: no agent detected")
+ os.Exit(1)
+}
+GOEOF
+
+go run /tmp/test_detect.go '%NN'
+# Expected: PASS: correctly detected as cursor
+
+rm /tmp/test_detect.go
+```
+
+## Test 6: Unchanged text results in no-op
+
+**Goal**: If the user opens the editor and saves without changes,
+nothing should be sent to the pane.
+
+```sh
+# 1. Type text into the prompt
+tmux send-keys -t '%NN' 'do not change me'
+sleep 0.5
+
+# 2. Create a mock editor that keeps the content unchanged (with delay)
+cat > /tmp/mock-noop-editor.sh << 'SH'
+#!/bin/sh
+# Do nothing -- file already has the pre-filled content
+# Small delay for clean popup close
+sleep 0.1
+SH
+chmod +x /tmp/mock-noop-editor.sh
+
+# 3. Run hexai-tmux-edit
+HEXAI_EDITOR=/tmp/mock-noop-editor.sh hexai-tmux-edit --pane '%NN'
+sleep 0.5
+
+# 4. Check debug log -- should show "nothing to send"
+grep 'nothing to send' /tmp/hexai-tmux-edit.log
+# Expected: "nothing to send, exiting"
+
+# 5. Verify prompt text is unchanged (still has original text)
+tmux capture-pane -p -t '%NN' | grep '│.*→'
+# Expected: │ → do not change me │
+
+# 6. Clean up
+rm /tmp/mock-noop-editor.sh
+tmux send-keys -t '%NN' End && sleep 0.1 && tmux send-keys -t '%NN' -N 200 BSpace
+```
+
+## Cleanup
+
+After all tests, remove any leftover temp files and check for hung popups:
+
+```sh
+# Remove test files
+rm -f /tmp/test_capture.go /tmp/test_multibox.go /tmp/test_detect.go
+rm -f /tmp/mock-editor.sh /tmp/mock-noop-editor.sh
+
+# Check for any hung tmux popups (should show nothing)
+tmux list-panes -a | grep popup || echo "✓ No hung popups"
+
+# If popups are hung, kill them
+# pkill -f 'tmux.*popup' || true
+```
+
+## Troubleshooting
+
+**Popup doesn't close**: Mock editors that exit instantly (< 50ms) might not
+give tmux enough time to register the exit. Add `sleep 0.1` before the editor
+script exits.
+
+**Popup hangs**: If a popup is stuck open:
+```sh
+# Find popup panes
+tmux list-panes -a | grep popup
+
+# Kill popup processes
+pkill -f 'tmux.*popup'
+
+# Or press Escape in the tmux session to close active popup
+```