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
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
|
package tmuxedit
import (
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"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. The -d flag sets
// the working directory for the popup. 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"}
// Get current working directory to pass to the popup
if cwd, err := os.Getwd(); err == nil && cwd != "" {
args = append(args, "-d", cwd)
}
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 in the state directory
// (~/.local/state/hexai/hexai-tmux-edit.log). Returns a closer for the
// log file handle and an error if the state directory cannot be resolved.
// Silently skips logging (returns a no-op closer) if the log file cannot
// be opened.
func initDebugLog() (func(), error) {
stateDir, err := appconfig.StateDir()
if err != nil {
return nil, fmt.Errorf("cannot create state directory: %w", err)
}
logPath := filepath.Join(stateDir, "hexai-tmux-edit.log")
f, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
if err != nil {
return func() {}, nil
}
debugLog = log.New(f, "", log.LstdFlags|log.Lmicroseconds)
return func() { _ = f.Close() }, nil
}
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 {
closeLog, err := initDebugLog()
if err != nil {
return fmt.Errorf("init debug log: %w", err)
}
defer closeLog()
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
}
// Append to history (log errors but don't fail the operation)
cwd, err := os.Getwd()
if err != nil {
cwd = "unknown"
dbg("os.Getwd error (using 'unknown'): %v", err)
}
if err := AppendHistory(text, agent.Name(), cwd); err != nil {
dbg("AppendHistory error: %v", err)
// Non-fatal: log but continue
} else {
dbg("appended to history: agent=%q cwd=%q len=%d", agent.Name(), cwd, len(text))
}
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)
}
|