diff options
| author | Paul Buetow <paul@buetow.org> | 2026-02-08 15:19:36 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-02-08 15:19:36 +0200 |
| commit | 887d7bc186db90c3903851b0f1db2d24df5d7a7b (patch) | |
| tree | 1cfb8055ddbb907bae9461b924dda1d2b3f15b46 /internal | |
| parent | 6da37034708dc7d4dcb7c71e890478a68e6ae4a1 (diff) | |
fix hexai-tmux-edit agent detection, multi-line extraction, and clearing
- Reorder agents: cursor first to avoid false positive from "Claude 4.5
Sonnet" model name appearing in cursor panes
- Change cursor detect pattern to box-drawing UI elements instead of
name-based matching
- Change claude detect pattern to ❯ prompt instead of generic "claude"
- Support multi-line prompt extraction using last-contiguous-block
algorithm to ignore command-review and dialog boxes
- Fix deduplicateText to always return full edited text (clear + resend)
instead of stripping original prefix which caused double-removal
- Replace vim-style clear (Escape gg dG i) with universal End+BSpace*200
since cursor's prompt is not actually vim
- Add Key*N repeat syntax in ClearKeys (parsed by parseKeyRepeat, sent
via tmux send-keys -N)
- Add 300ms sleep after clear to let TUI drain queued backspaces before
new text arrives
- Add debug logging to /tmp/hexai-tmux-edit.log
- Add strip pattern for "ctrl+c to stop" noise in cursor prompt
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/tmuxedit/agent.go | 86 | ||||
| -rw-r--r-- | internal/tmuxedit/agent_test.go | 71 | ||||
| -rw-r--r-- | internal/tmuxedit/run.go | 66 | ||||
| -rw-r--r-- | internal/tmuxedit/run_test.go | 70 | ||||
| -rw-r--r-- | internal/tmuxedit/send.go | 92 | ||||
| -rw-r--r-- | internal/tmuxedit/send_test.go | 81 |
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 |
