From c5cd80b4bed1234152f19d23ddac51d86ba36f0f Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Sun, 8 Feb 2026 17:38:07 +0200 Subject: Fix Claude agent clearing to use readline instead of vim commands Claude Code's prompt input field does not support vim commands for clearing. The previous sequence 'Escape gg C-v G d i' resulted in literal text 'gGdijo' appearing in the prompt instead of clearing it. Changed to 'C-a C-k' (Emacs/readline style): - C-a: Move cursor to start of line - C-k: Kill (delete) from cursor to end of line Also added 150ms delay after Escape key in sendClearSequence to ensure mode transitions complete before subsequent keys are sent (though Claude uses readline, this helps other potential vim-based agents). Changes: - internal/tmuxedit/claude_agent.go: clearKeys "C-a C-k" instead of vim - internal/tmuxedit/claude_agent_test.go: Update test expectations - internal/tmuxedit/agentutil.go: Add time import and Escape delay - docs/usage.md: Update claude clear method documentation - docs/tmux.md: Clarify claude uses readline not vim - config.toml.example: Update claude description Integration tested: - Extracted: "final verification test" - Sent: "FINAL REPLACED TEXT NO VIM COMMANDS" - Result: Clean replacement with NO vim command artifacts - All unit tests pass (67/67) - Coverage: 80.9% Co-authored-by: Cursor --- docs/coverage.html | 237 +++++++++++++++++++++++++++-------------------------- 1 file changed, 121 insertions(+), 116 deletions(-) (limited to 'docs/coverage.html') diff --git a/docs/coverage.html b/docs/coverage.html index 70dbf00..1694242 100644 --- a/docs/coverage.html +++ b/docs/coverage.html @@ -149,7 +149,7 @@ - + @@ -9534,9 +9534,9 @@ import ( "golang.org/x/sys/unix" ) -func tryLockFile(fd uintptr) error { - if err := unix.Flock(int(fd), unix.LOCK_EX|unix.LOCK_NB); err != nil { - if errors.Is(err, unix.EWOULDBLOCK) { +func tryLockFile(fd uintptr) error { + if err := unix.Flock(int(fd), unix.LOCK_EX|unix.LOCK_NB); err != nil { + if errors.Is(err, unix.EWOULDBLOCK) { return errLockWouldBlock } return err @@ -9651,7 +9651,7 @@ func Update(ctx context.Context, provider, model string, sentBytes, recvBytes in if b, rerr := os.ReadFile(path); rerr == nil { _ = json.Unmarshal(b, &sf) } - if sf.Version != fileVersion { + if sf.Version != fileVersion { sf = File{Version: fileVersion} } now := time.Now() @@ -9664,12 +9664,12 @@ func Update(ctx context.Context, provider, model string, sentBytes, recvBytes in if len(sf.Events) > 0 { // Find first >= cutoff i := 0 - for ; i < len(sf.Events); i++ { + for ; i < len(sf.Events); i++ { if !sf.Events[i].TS.Before(cutoff) { break } } - if i > 0 { + if i > 0 { sf.Events = append([]Event(nil), sf.Events[i:]...) } } @@ -9704,30 +9704,30 @@ func Update(ctx context.Context, provider, model string, sentBytes, recvBytes in func acquireFileLock(ctx context.Context, f *os.File) (func() error, error) { fd := f.Fd() - for { + for { err := tryLockFile(fd) if err == nil { return func() error { return unlockFile(fd) }, nil } - if errors.Is(err, errLockWouldBlock) { + if errors.Is(err, errLockWouldBlock) { select { case <-ctx.Done(): return nil, ctx.Err() - case <-time.After(5 * time.Millisecond): + case <-time.After(5 * time.Millisecond): } - continue + continue } return nil, err } } // Snapshot reads and aggregates events within the configured window. -func TakeSnapshot() (Snapshot, error) { +func TakeSnapshot() (Snapshot, error) { dir, err := CacheDir() if err != nil { return Snapshot{}, err } - path := filepath.Join(dir, fileName) + path := filepath.Join(dir, fileName) b, err := os.ReadFile(path) if err != nil { if errors.Is(err, os.ErrNotExist) { @@ -9735,30 +9735,30 @@ func TakeSnapshot() (Snapshot, error) { } return Snapshot{}, err } - var sf File + var sf File if err := json.Unmarshal(b, &sf); err != nil { return Snapshot{}, err } - win := time.Duration(sf.WindowSeconds) * time.Second + win := time.Duration(sf.WindowSeconds) * time.Second if win <= 0 { win = Window() - } else { + } else { SetWindow(win) // align process with file window if changed elsewhere } - cutoff := time.Now().Add(-win) + cutoff := time.Now().Add(-win) snap := Snapshot{Providers: make(map[string]ProviderEntry), Window: win} - for _, ev := range sf.Events { + for _, ev := range sf.Events { if ev.TS.Before(cutoff) { continue } - snap.Global.Reqs++ + snap.Global.Reqs++ snap.Global.Sent += ev.Sent snap.Global.Recv += ev.Recv pe := snap.Providers[ev.Provider] if pe.Models == nil { pe.Models = make(map[string]Counters) } - pe.Totals.Reqs++ + pe.Totals.Reqs++ pe.Totals.Sent += ev.Sent pe.Totals.Recv += ev.Recv mc := pe.Models[ev.Model] @@ -9768,17 +9768,17 @@ func TakeSnapshot() (Snapshot, error) { pe.Models[ev.Model] = mc snap.Providers[ev.Provider] = pe } - mins := win.Minutes() + mins := win.Minutes() if mins <= 0 { mins = 0.001 } - snap.RPM = float64(snap.Global.Reqs) / mins + snap.RPM = float64(snap.Global.Reqs) / mins return snap, nil } // CacheDir resolves the cache directory for stats. -func CacheDir() (string, error) { - if x := os.Getenv("XDG_CACHE_HOME"); stringsTrim(x) != "" { +func CacheDir() (string, error) { + if x := os.Getenv("XDG_CACHE_HOME"); stringsTrim(x) != "" { return filepath.Join(x, "hexai"), nil } home, err := os.UserHomeDir() @@ -9789,23 +9789,23 @@ func CacheDir() (string, error) { } // stringsTrim is a tiny helper to avoid importing strings everywhere here. -func stringsTrim(s string) string { +func stringsTrim(s string) string { i := 0 j := len(s) for i < j && (s[i] == ' ' || s[i] == '\t' || s[i] == '\n' || s[i] == '\r') { i++ } - for j > i && (s[j-1] == ' ' || s[j-1] == '\t' || s[j-1] == '\n' || s[j-1] == '\r') { + for j > i && (s[j-1] == ' ' || s[j-1] == '\t' || s[j-1] == '\n' || s[j-1] == '\r') { j-- } - if i == 0 && j == len(s) { + if i == 0 && j == len(s) { return s } return s[i:j] } // DebugString returns a compact single-line view of a snapshot (useful for logs). -func (s Snapshot) DebugString() string { +func (s Snapshot) DebugString() string { return "Σ reqs=" + strconv.FormatInt(s.Global.Reqs, 10) + " rpm=" + fmt.Sprintf("%.2f", s.RPM) } @@ -10471,6 +10471,7 @@ import ( "regexp" "strconv" "strings" + "time" ) // promptMatch holds a regex match result with its line number in the pane. @@ -10481,117 +10482,121 @@ type promptMatch struct { // 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 { +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]}) + for i, line := range paneLines { + m := re.FindStringSubmatch(line) + if len(m) >= 2 { + matches = append(matches, promptMatch{lineNum: i, text: m[1]}) } - } - return matches + } + 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 { +// 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) + for _, m := range matches { + line := stripNoise(m.text, strips) + if line != "" { + lines = append(lines, line) } - } - return strings.Join(lines, "\n") + } + 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 { +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-- + 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) + 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") + } + 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 +func scopeToLastSection(paneContent, sectionPattern string) string { + if sectionPattern == "" { + return paneContent } - re, err := regexp.Compile(sectionPattern) - if err != nil { - return paneContent + re, err := regexp.Compile(sectionPattern) + if err != nil { + return paneContent } - lines := strings.Split(paneContent, "\n") + lines := strings.Split(paneContent, "\n") var delimLines []int - for i, line := range lines { - if re.MatchString(line) { - delimLines = append(delimLines, i) + for i, line := range lines { + if re.MatchString(line) { + delimLines = append(delimLines, i) } - } - if len(delimLines) < 2 { - return paneContent + } + if len(delimLines) < 2 { + return paneContent } - start := delimLines[len(delimLines)-2] + 1 - end := delimLines[len(delimLines)-1] - if start >= end { - 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") + 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, "") +func stripNoise(text string, patterns []string) string { + for _, p := range patterns { + text = strings.ReplaceAll(text, p, "") } - return strings.TrimSpace(text) + 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) +// 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) + } 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 +// 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:]) + n, err := strconv.Atoi(token[idx+1:]) if err != nil || n < 1 { return token, 1 } @@ -10599,22 +10604,22 @@ func parseKeyRepeat(token string) (string, int) { } // 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 +// 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 { +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" + 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 { + if err := sendKeys(paneID, nlKey); err != nil { return fmt.Errorf("newline after line %d failed: %w", i, err) } } @@ -10665,7 +10670,7 @@ func newClaudeAgent() *claudeAgent { sectionPat: `^─{5,}`, promptPat: `(?m)❯\s*(.+)$`, clearFirst: true, - clearKeys: "Escape gg C-v G d i", + clearKeys: "C-a C-k", newlineKeys: "S-Enter", submitKeys: "Enter", }} @@ -10750,16 +10755,16 @@ 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: "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", -- cgit v1.2.3