summaryrefslogtreecommitdiff
path: root/internal/tmuxedit/agentutil.go
blob: 67351d3b310bff1ac5cfdd7c6679ae33f443c770 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
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 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-based agents 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
}