summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--internal/tmuxedit/agent.go86
-rw-r--r--internal/tmuxedit/agent_test.go71
-rw-r--r--internal/tmuxedit/run.go66
-rw-r--r--internal/tmuxedit/run_test.go70
-rw-r--r--internal/tmuxedit/send.go92
-rw-r--r--internal/tmuxedit/send_test.go81
6 files changed, 368 insertions, 98 deletions
diff --git a/internal/tmuxedit/agent.go b/internal/tmuxedit/agent.go
index 2e07824..7be38ed 100644
--- a/internal/tmuxedit/agent.go
+++ b/internal/tmuxedit/agent.go
@@ -24,26 +24,36 @@ type AgentConfig struct {
SubmitKeys string // tmux key to submit the prompt (e.g. "Enter")
}
-// builtinAgents returns the default set of agent configurations. These are
-// overridden/extended by user config in [tmux_edit.agents].
+// builtinAgents returns the default set of agent configurations. 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. Overridden/extended by
+// user config in [tmux_edit.agents].
func builtinAgents() []AgentConfig {
return []AgentConfig{
{
- Name: "claude",
- DisplayName: "Claude Code",
- DetectPattern: `(?i)(claude|anthropic)`,
- PromptPattern: `(?m)>\s*(.+)$`,
+ // Cursor Agent uses a distinctive box-drawing │ → prompt │ UI.
+ // Detect by the box structure or "/ commands" footer. Checked
+ // first because cursor panes show model names like "Claude 4.5".
+ // Clear uses End + bulk backspace to delete all existing text.
+ // The *200 suffix sends 200 backspaces via tmux send-keys -N.
+ Name: "cursor",
+ DisplayName: "Cursor",
+ DetectPattern: `(│\s*→|/ commands · @ files)`,
+ PromptPattern: `(?m)│\s*→?\s*(.+?)\s*│\s*$`,
+ StripPatterns: []string{"INSERT", "Add a follow-up", "ctrl+c to stop"},
ClearFirst: true,
- ClearKeys: "C-u",
+ ClearKeys: "End BSpace*200",
NewlineKeys: "S-Enter",
SubmitKeys: "Enter",
},
{
- Name: "cursor",
- DisplayName: "Cursor",
- DetectPattern: `(?i)cursor`,
- PromptPattern: `(?m)│\s*(.+)$`,
- StripPatterns: []string{"INSERT", "Add a follow-up"},
+ // Claude Code uses ❯ prompt between ──── horizontal rules.
+ // Detect by the ❯ prompt or explicit "claude code" banner.
+ Name: "claude",
+ DisplayName: "Claude Code",
+ DetectPattern: `(❯|claude code|anthropic)`,
+ PromptPattern: `(?m)❯\s*(.+)$`,
ClearFirst: true,
ClearKeys: "C-u",
NewlineKeys: "S-Enter",
@@ -185,7 +195,10 @@ func findAgentByName(name string, agents []AgentConfig) AgentConfig {
}
// extractPrompt uses the agent's PromptPattern to extract the current prompt
-// text from pane content. Returns empty string if no pattern or no match.
+// text from pane content. For multi-line prompts (e.g. cursor's box-drawing
+// │...│ UI) it takes only the last contiguous group of matched lines, which
+// avoids picking up command-review or dialog boxes that use the same border
+// characters. Returns empty string if no pattern or no match.
func extractPrompt(paneContent string, agent AgentConfig) string {
if agent.PromptPattern == "" {
return ""
@@ -194,12 +207,51 @@ func extractPrompt(paneContent string, agent AgentConfig) string {
if err != nil {
return ""
}
- m := re.FindStringSubmatch(paneContent)
- if len(m) < 2 {
+ allMatches := matchPromptLines(re, paneContent)
+ if len(allMatches) == 0 {
return ""
}
- text := m[1]
- return stripNoise(text, agent.StripPatterns)
+ return joinLastContiguousBlock(allMatches, agent.StripPatterns)
+}
+
+// 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
+}
+
+// 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")
}
// stripNoise removes each of the agent's StripPatterns from text and trims
diff --git a/internal/tmuxedit/agent_test.go b/internal/tmuxedit/agent_test.go
index a6bc20d..7ad1274 100644
--- a/internal/tmuxedit/agent_test.go
+++ b/internal/tmuxedit/agent_test.go
@@ -15,9 +15,11 @@ func TestDetectAgent(t *testing.T) {
content string
want string
}{
- {"claude from banner", "Welcome to Claude Code v1.2\n> ", "claude"},
- {"claude from anthropic", "Powered by Anthropic\n> ", "claude"},
- {"cursor from prompt", "cursor agent ready\n│ type here", "cursor"},
+ {"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"},
@@ -64,23 +66,70 @@ func TestExtractPrompt(t *testing.T) {
}{
{
name: "claude prompt",
- content: "Claude Code v1\n> hello world",
- agent: builtinAgents()[0], // claude
+ content: "────\n❯ hello world\n────",
+ agent: builtinAgents()[1], // claude
want: "hello world",
},
{
- name: "cursor prompt with strip",
- content: "Cursor Agent\n│ fix the bug INSERT",
- agent: builtinAgents()[1], // cursor
+ name: "cursor prompt with box and arrow",
+ content: "Cursor Agent\n │ → fix the bug INSERT │",
+ agent: builtinAgents()[0], // cursor
+ want: "fix the bug",
+ },
+ {
+ name: "cursor prompt without arrow",
+ content: "Cursor Agent\n │ fix the bug │",
+ agent: builtinAgents()[0], // cursor
want: "fix the bug",
},
{
name: "cursor prompt strips follow-up",
- content: "Cursor\n│ Add a follow-up",
- agent: builtinAgents()[1], // cursor
+ content: "Cursor\n │ → Add a follow-up │",
+ agent: builtinAgents()[0], // cursor
want: "",
},
{
+ name: "cursor multi-line prompt",
+ content: " │ → first line of prompt │\n │ second line here │\n │ third line end │",
+ agent: builtinAgents()[0], // cursor
+ want: "first line of prompt\nsecond line here\nthird line end",
+ },
+ {
+ name: "cursor multi-line with noise",
+ content: " │ → fix the bug INSERT │\n │ also refactor tests │",
+ agent: builtinAgents()[0], // cursor
+ want: "fix the bug\nalso refactor tests",
+ },
+ {
+ name: "cursor 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",
+ agent: builtinAgents()[0], // cursor
+ want: "hello world",
+ },
+ {
+ name: "cursor multi-box multi-line prompt",
+ content: " ┌──────────────┐\n" +
+ " │ $ git push │\n" +
+ " └──────────────┘\n" +
+ " ┌──────────────┐\n" +
+ " │ → first line │\n" +
+ " │ second line │\n" +
+ " │ third line │\n" +
+ " └──────────────┘\n",
+ agent: builtinAgents()[0], // cursor
+ want: "first line\nsecond line\nthird line",
+ },
+ {
name: "no pattern",
content: "some text",
agent: genericAgent(),
@@ -89,7 +138,7 @@ func TestExtractPrompt(t *testing.T) {
{
name: "no match",
content: "no prompt here",
- agent: builtinAgents()[0], // claude
+ agent: builtinAgents()[1], // claude
want: "",
},
{
diff --git a/internal/tmuxedit/run.go b/internal/tmuxedit/run.go
index 173e936..dde91fa 100644
--- a/internal/tmuxedit/run.go
+++ b/internal/tmuxedit/run.go
@@ -4,6 +4,7 @@ import (
"fmt"
"log"
"os"
+ "os/exec"
"strings"
"codeberg.org/snonux/hexai/internal/appconfig"
@@ -56,9 +57,10 @@ var openEditorPopup = func(initial, popupW, popupH string) (string, error) {
return strings.TrimSpace(string(b)), nil
}
-// launchPopup runs `tmux display-popup` with the editor command.
-// The -E flag makes the popup close when the editor exits.
-func launchPopup(ed, path, width, height string) error {
+// 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)
@@ -67,8 +69,7 @@ func launchPopup(ed, path, width, height string) error {
args = append(args, "-h", height)
}
args = append(args, ed+" "+shellQuote(path))
- _, err := runCommand("tmux", args...)
- return err
+ return exec.Command("tmux", args...).Run()
}
// shellQuote wraps a path in single quotes for safe shell use.
@@ -99,23 +100,58 @@ func loadConfig(configPath string) appconfig.App {
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.
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))
+ // Log a few lines around the prompt
+ for i, line := range strings.Split(content, "\n") {
+ if strings.Contains(line, "│") || strings.Contains(line, "→") {
+ dbg(" pane line %d: %q", i, line)
+ }
+ }
+
agents := resolveAgents(cfg.TmuxEditAgents)
agent := pickAgent(opts.Agent, content, agents)
+ dbg("agent: name=%q detect=%q prompt=%q strip=%v clear=%v clearKeys=%q",
+ agent.Name, agent.DetectPattern, agent.PromptPattern, agent.StripPatterns, agent.ClearFirst, agent.ClearKeys)
- // Extract current prompt text from pane content
original := extractPrompt(content, agent)
+ dbg("extractPrompt result: %q", original)
- // Determine popup dimensions from config (with defaults)
popupW := cfg.TmuxEditPopupWidth
if popupW == "" {
popupW = "80%"
@@ -124,19 +160,29 @@ func runWithConfig(opts Options, cfg appconfig.App) error {
if popupH == "" {
popupH = "80%"
}
+ 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)
- // Deduplicate: remove the pre-filled text if the user didn't change it
text := deduplicateText(original, edited)
+ dbg("deduplicateText result: %q", text)
if text == "" {
- return nil // nothing to send
+ dbg("nothing to send, exiting")
+ return nil
}
- return sendTextToPane(paneID, text, agent)
+ dbg("sending to pane %q: %q", paneID, text)
+ err = sendTextToPane(paneID, text, agent)
+ if err != nil {
+ dbg("sendTextToPane error: %v", err)
+ }
+ dbg("=== done ===")
+ return err
}
// pickAgent selects an agent by explicit name or auto-detection.
diff --git a/internal/tmuxedit/run_test.go b/internal/tmuxedit/run_test.go
index 88c94a2..2766f6b 100644
--- a/internal/tmuxedit/run_test.go
+++ b/internal/tmuxedit/run_test.go
@@ -29,9 +29,9 @@ func TestRunWithConfig_HappyPath(t *testing.T) {
return nil, nil
}
- // Mock: capture pane content with Claude agent detected
+ // Mock: capture pane content with Claude Code agent detected
capturePane = func(paneID string) (string, error) {
- return "Claude Code v1.0\n> fix the bug", nil
+ return "claude code v1.0\n────\n❯ fix the bug\n────", nil
}
// Mock: editor popup returns modified text
@@ -58,14 +58,16 @@ func TestRunWithConfig_HappyPath(t *testing.T) {
t.Fatalf("unexpected error: %v", err)
}
- // Should have sent: clear (C-u), "also refactor the module"
- // (since "fix the bug" was pre-filled and kept, only the appended text is sent)
- if len(sent) < 1 {
- t.Fatalf("no send calls recorded")
+ // Should have sent: clear (C-u), then the full edited text (both lines)
+ // since deduplicateText returns the complete text whenever anything changed.
+ if len(sent) < 2 {
+ t.Fatalf("expected at least 2 send calls (clear + text), got %d: %v", len(sent), sent)
}
- // Check that the deduplication worked - only new text should be 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)
}
@@ -121,7 +123,7 @@ func TestRunWithConfig_EditorEmpty(t *testing.T) {
return []byte("%1"), nil
}
capturePane = func(string) (string, error) {
- return "Claude\n> ", nil
+ return "claude code\n❯ ", nil
}
openEditorPopup = func(string, string, string) (string, error) {
return "", nil // user saved empty file
@@ -206,51 +208,51 @@ func TestShellQuote(t *testing.T) {
}
func TestLaunchPopup_CommandArgs(t *testing.T) {
- oldRunCmd := runCommand
- defer func() { runCommand = oldRunCmd }()
+ oldLaunch := launchPopup
+ defer func() { launchPopup = oldLaunch }()
- var captured []string
- runCommand = func(name string, args ...string) ([]byte, error) {
- captured = append(captured, name)
- captured = append(captured, args...)
- return nil, nil
+ 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)
}
- // Verify command structure: tmux display-popup -E -w 90% -h 85% "vim '/tmp/test.md'"
- if captured[0] != "tmux" {
- t.Errorf("command = %q, want tmux", captured[0])
- }
- if captured[1] != "display-popup" {
- t.Errorf("args[0] = %q, want display-popup", captured[1])
+ if capturedArgs.ed != "vim" {
+ t.Errorf("ed = %q, want vim", capturedArgs.ed)
}
- if captured[2] != "-E" {
- t.Errorf("args[1] = %q, want -E", captured[2])
+ 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) {
- oldRunCmd := runCommand
- defer func() { runCommand = oldRunCmd }()
+ oldLaunch := launchPopup
+ defer func() { launchPopup = oldLaunch }()
- var captured []string
- runCommand = func(name string, args ...string) ([]byte, error) {
- captured = args
- return nil, nil
+ 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)
}
- // Should not include -w or -h flags
- for _, a := range captured {
- if a == "-w" || a == "-h" {
- t.Errorf("unexpected dimension flag in args: %v", captured)
- }
+ if capturedArgs.w != "" || capturedArgs.h != "" {
+ t.Errorf("expected empty dimensions, got %qx%q", capturedArgs.w, capturedArgs.h)
}
}
diff --git a/internal/tmuxedit/send.go b/internal/tmuxedit/send.go
index b85f7d3..ea63057 100644
--- a/internal/tmuxedit/send.go
+++ b/internal/tmuxedit/send.go
@@ -2,7 +2,9 @@ package tmuxedit
import (
"fmt"
+ "strconv"
"strings"
+ "time"
)
// sendKeys is the seam for `tmux send-keys`. Override in tests.
@@ -15,45 +17,41 @@ var sendKeys = func(paneID string, keys ...string) error {
return nil
}
-// deduplicateText removes the original (pre-filled) text from the edited
-// result. If the user kept the original and appended, only the new text is
-// returned. If the user rewrote everything, the full new text is returned.
+// 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 == "" {
+ if edited == "" || edited == original {
return ""
}
- if original == "" {
- return edited
- }
- // If the edited text starts with the original, return only the appended part
- if strings.HasPrefix(edited, original) {
- appended := strings.TrimSpace(edited[len(original):])
- if appended != "" {
- return appended
- }
- // User didn't change anything; return empty to signal no-op
- return ""
- }
- // User rewrote the prompt; return the full new text
return edited
}
// sendTextToPane sends the given text to the target pane. It optionally
-// clears existing input first (using the agent's ClearKeys), then sends
-// text line-by-line using the agent's NewlineKeys between lines.
+// clears existing input first (using the agent's ClearKeys sequence), then
+// sends text line-by-line using the agent's NewlineKeys between lines.
+// ClearKeys is space-separated; tokens like "BSpace*200" repeat a key N times
+// via tmux send-keys -N. Example: "End BSpace*200" moves to end then
+// sends 200 backspaces to clear the entire prompt buffer.
func sendTextToPane(paneID, text string, agent AgentConfig) error {
if strings.TrimSpace(text) == "" {
return nil
}
- // Clear existing input if configured
+ // Clear existing input using the key sequence (space-separated).
+ // Each token is sent as a separate tmux send-keys call.
+ // A short pause after clearing lets the TUI process all queued
+ // keystrokes (e.g. 200 backspaces) before new text arrives.
if agent.ClearFirst && agent.ClearKeys != "" {
- if err := sendKeys(paneID, agent.ClearKeys); err != nil {
- return fmt.Errorf("clear failed: %w", err)
+ if err := sendClearSequence(paneID, agent.ClearKeys); err != nil {
+ return err
}
+ sleepAfterClear()
}
- // Send text line by line, inserting newline keys between lines
+ // Send text line-by-line, inserting newline keys between lines
lines := strings.Split(text, "\n")
for i, line := range lines {
if err := sendKeys(paneID, line); err != nil {
@@ -72,3 +70,51 @@ func sendTextToPane(paneID, text string, agent AgentConfig) error {
}
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) }
+
+// 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)
+ }
+ }
+ }
+ 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
+}
+
+// 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
+}
diff --git a/internal/tmuxedit/send_test.go b/internal/tmuxedit/send_test.go
index eeced35..e458282 100644
--- a/internal/tmuxedit/send_test.go
+++ b/internal/tmuxedit/send_test.go
@@ -17,10 +17,10 @@ func TestDeduplicateText(t *testing.T) {
{"empty original", "", "new text", "new text"},
{"empty edited", "original", "", ""},
{"unchanged", "hello world", "hello world", ""},
- {"appended", "hello", "hello world", "world"},
+ {"appended", "hello", "hello world", "hello world"},
{"rewritten", "hello world", "goodbye world", "goodbye world"},
- {"whitespace handling", " hello ", " hello world ", "world"},
- {"prefix match with newlines", "line1\nline2", "line1\nline2\nline3", "line3"},
+ {"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) {
@@ -33,7 +33,16 @@ func TestDeduplicateText(t *testing.T) {
}
}
+// 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 TestSendTextToPane_SingleLine(t *testing.T) {
+ noSleep(t)
var calls []string
oldSend := sendKeys
defer func() { sendKeys = oldSend }()
@@ -116,6 +125,7 @@ func TestSendTextToPane_Empty(t *testing.T) {
}
func TestSendTextToPane_ClearError(t *testing.T) {
+ noSleep(t)
oldSend := sendKeys
defer func() { sendKeys = oldSend }()
sendKeys = func(paneID string, keys ...string) error {
@@ -129,6 +139,7 @@ func TestSendTextToPane_ClearError(t *testing.T) {
}
func TestSendTextToPane_SendError(t *testing.T) {
+ noSleep(t)
callCount := 0
oldSend := sendKeys
defer func() { sendKeys = oldSend }()
@@ -146,6 +157,70 @@ func TestSendTextToPane_SendError(t *testing.T) {
}
}
+func TestSendTextToPane_BulkClear(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
+ }
+ // "End BSpace*200" should send End normally, then BSpace 200 times via -N
+ agent := AgentConfig{ClearFirst: true, ClearKeys: "End BSpace*200", NewlineKeys: "S-Enter"}
+ err := sendTextToPane("%5", "new text", agent)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ want := []string{
+ "send:%5:End",
+ "repeat:%5:BSpace*200",
+ "send:%5:new text",
+ }
+ 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 TestParseKeyRepeat(t *testing.T) {
+ tests := []struct {
+ token string
+ wantKey string
+ wantCount int
+ }{
+ {"BSpace*200", "BSpace", 200},
+ {"End", "End", 1},
+ {"C-u", "C-u", 1},
+ {"BSpace*1", "BSpace", 1},
+ {"BSpace*0", "BSpace*0", 1}, // invalid count
+ {"BSpace*abc", "BSpace*abc", 1}, // non-numeric
+ {"*200", "*200", 1}, // no key name
+ {"x*3", "x", 3},
+ }
+ for _, tt := range tests {
+ t.Run(tt.token, func(t *testing.T) {
+ key, count := parseKeyRepeat(tt.token)
+ if key != tt.wantKey || count != tt.wantCount {
+ t.Errorf("parseKeyRepeat(%q) = (%q, %d), want (%q, %d)",
+ tt.token, key, count, tt.wantKey, tt.wantCount)
+ }
+ })
+ }
+}
+
func TestSendTextToPane_FallbackNewline(t *testing.T) {
var calls []string
oldSend := sendKeys