Files
2026-06-02 23:14:41 +08:00

439 lines
13 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package tmux
import (
"context"
"fmt"
"log/slog"
"os/exec"
"regexp"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/chenhg5/cc-connect/core"
)
type tmuxSession struct {
target string // e.g., "mywork:0"
sessionID string
workDir string
promptPat *regexp.Regexp
pollInt time.Duration
stripInputBlock bool
stripPatterns []*regexp.Regexp
events chan core.Event
ctx context.Context
cancel context.CancelFunc
alive atomic.Bool
closeOnce sync.Once
mu sync.Mutex
pollCancel context.CancelFunc
baselineCapture string // full captureScrollback output at the time of the last Send()
}
func newTmuxSession(ctx context.Context, target, sessionID, promptPattern string, pollInt time.Duration, stripInputBlock bool, stripPatternStrs []string, workDir string) (*tmuxSession, error) {
sessCtx, cancel := context.WithCancel(ctx)
var pat *regexp.Regexp
if promptPattern != "" {
var err error
pat, err = regexp.Compile(promptPattern)
if err != nil {
cancel()
return nil, fmt.Errorf("tmux: invalid prompt_pattern %q: %w", promptPattern, err)
}
}
var stripPats []*regexp.Regexp
for _, s := range stripPatternStrs {
re, err := regexp.Compile(s)
if err != nil {
cancel()
return nil, fmt.Errorf("tmux: invalid strip_pattern %q: %w", s, err)
}
stripPats = append(stripPats, re)
}
s := &tmuxSession{
target: target,
sessionID: sessionID,
workDir: workDir,
promptPat: pat,
pollInt: pollInt,
stripInputBlock: stripInputBlock,
stripPatterns: stripPats,
events: make(chan core.Event, 128),
ctx: sessCtx,
cancel: cancel,
}
s.alive.Store(true)
return s, nil
}
func (s *tmuxSession) Send(prompt string, _ []core.ImageAttachment, files []core.FileAttachment) error {
if !s.alive.Load() {
return fmt.Errorf("tmux: session closed")
}
// Save attached files and append their paths to the prompt
if len(files) > 0 {
paths := core.SaveFilesToDisk(s.workDir, files)
if len(paths) > 0 {
prompt = prompt + "\n# files: " + strings.Join(paths, ", ")
}
}
// Cancel any running poll from a previous Send
s.mu.Lock()
if s.pollCancel != nil {
s.pollCancel()
s.pollCancel = nil
}
// Snapshot the full scrollback (history + visible pane) before sending.
// extractResponse diffs against this to find exactly what the agent added,
// regardless of whether the TUI rewrites lines in-place or scrolls them.
baseline, _ := captureScrollback(s.target)
visibleBase, _ := capturePane(s.target) // for poll stability comparison
s.baselineCapture = baseline
pollCtx, pollCancel := context.WithCancel(s.ctx)
s.pollCancel = pollCancel
s.mu.Unlock()
if err := sendKeys(s.target, prompt); err != nil {
pollCancel()
return fmt.Errorf("tmux: send-keys: %w", err)
}
go s.poll(pollCtx, visibleBase)
return nil
}
func (s *tmuxSession) poll(ctx context.Context, baseline string) {
ticker := time.NewTicker(s.pollInt)
defer ticker.Stop()
prev := baseline
stable := 0
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
current, err := capturePane(s.target)
if err != nil {
slog.Warn("tmux: capture-pane error", "target", s.target, "err", err)
continue
}
if current == prev {
stable++
} else {
stable = 0
prev = current
}
// Done: pane stable AND changed from baseline.
// fast path — prompt pattern matched; slow path — 5 s idle fallback.
if stable >= 2 && current != baseline {
trimmed := strings.TrimRight(current, " \t\n")
promptOK := s.promptPat == nil || s.promptPat.MatchString(trimmed)
idleN := max(10, 5000/int(s.pollInt.Milliseconds()))
if promptOK || stable >= idleN {
// Guard against the race where Send() cancelled this poll
// just as we were about to emit — avoids duplicate responses.
select {
case <-ctx.Done():
return
default:
}
if !promptOK {
lastLine := trimmed
if nl := strings.LastIndex(trimmed, "\n"); nl >= 0 {
lastLine = trimmed[nl+1:]
}
slog.Info("tmux: idle-done (prompt pattern did not match)", "target", s.target, "last_line", lastLine)
}
response := s.extractResponse()
s.safeSend(core.Event{Type: core.EventResult, Content: response, Done: true})
return
}
}
}
}
}
func (s *tmuxSession) safeSend(ev core.Event) {
defer func() { _ = recover() }() // channel may be closed on session teardown
select {
case s.events <- ev:
case <-s.ctx.Done():
}
}
func (s *tmuxSession) RespondPermission(_ string, _ core.PermissionResult) error {
return fmt.Errorf("tmux: permission requests are not supported")
}
func (s *tmuxSession) Events() <-chan core.Event { return s.events }
func (s *tmuxSession) CurrentSessionID() string { return s.sessionID }
func (s *tmuxSession) Alive() bool { return s.alive.Load() }
func (s *tmuxSession) Close() error {
s.closeOnce.Do(func() {
s.alive.Store(false)
s.mu.Lock()
if s.pollCancel != nil {
s.pollCancel()
s.pollCancel = nil
}
s.mu.Unlock()
s.cancel()
close(s.events)
})
return nil
}
// ── tmux helpers ──────────────────────────────────────────────────────────────
func tmuxSessionExists(name string) bool {
return exec.Command("tmux", "has-session", "-t", name).Run() == nil
}
// tmuxWindowExists checks whether a window or pane target (e.g. "sess:win") exists.
func tmuxWindowExists(target string) bool {
return exec.Command("tmux", "has-session", "-t", target).Run() == nil
}
// createTmuxSession creates a new detached tmux session with the given window name.
func createTmuxSession(name, windowName, workDir, shell string) error {
args := []string{"new-session", "-d", "-s", name, "-n", windowName}
if workDir != "" && workDir != "." {
args = append(args, "-c", workDir)
}
if shell != "" {
args = append(args, shell)
}
out, err := exec.Command("tmux", args...).CombinedOutput()
if err != nil {
return fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out)))
}
// Enable focus events so Claude Code doesn't warn about them being off.
_ = exec.Command("tmux", "set-option", "-t", name, "-g", "focus-events", "on").Run()
return nil
}
// createTmuxWindow adds a new window to an existing session.
// Using "session:" (trailing colon) tells tmux to pick the next free index,
// avoiding index collisions when multiple windows are created concurrently.
func createTmuxWindow(session, windowName, workDir string) error {
args := []string{"new-window", "-d", "-t", session + ":", "-n", windowName}
if workDir != "" && workDir != "." {
args = append(args, "-c", workDir)
}
out, err := exec.Command("tmux", args...).CombinedOutput()
if err != nil {
return fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out)))
}
return nil
}
func capturePane(target string) (string, error) {
out, err := exec.Command("tmux", "capture-pane", "-t", target, "-p").Output()
if err != nil {
return "", err
}
return normalizeCapture(string(out)), nil
}
func sendKeys(target, keys string) error {
// -l (literal) prevents tmux from interpreting key names (C-c, Enter, Up, …)
// embedded in the user's text. Enter is sent as a separate keystroke afterwards.
out, err := exec.Command("tmux", "send-keys", "-t", target, "-l", keys).CombinedOutput()
if err != nil {
return fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out)))
}
out, err = exec.Command("tmux", "send-keys", "-t", target, "Enter").CombinedOutput()
if err != nil {
return fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out)))
}
return nil
}
// extractResponse diffs the current full scrollback against the snapshot taken
// in Send() and returns only what the agent added.
//
// Using extractNew on full captures (history + visible pane) handles both TUI
// rendering modes correctly:
//
// - Append mode (agent scrolls content up): the baseline is a prefix of the
// new capture, so the fast-path HasPrefix strips it and returns only the
// new lines — no old visible-pane content leaks in.
//
// - In-place rewrite mode (agent overwrites pane rows then scrolls): the
// first divergence is at line N (the first pane row, now containing the
// first response line), so extractNew returns everything from that point —
// no lines are skipped.
func (s *tmuxSession) extractResponse() string {
current, err := captureScrollback(s.target)
if err != nil {
slog.Warn("tmux: captureScrollback failed", "err", err)
pane, _ := capturePane(s.target)
return s.cleanTUIContent(pane)
}
s.mu.Lock()
baseline := s.baselineCapture
s.mu.Unlock()
response := s.cleanTUIContent(extractNew(baseline, current))
if response != "" {
response = "```\n" + response + "\n```"
}
return response
}
// captureScrollback captures the full scrollback history plus the visible pane.
// Using "-S -" (start of history) instead of a fixed line count avoids the bug
// where a long response pushes the capture window past the response start,
// causing the first N lines of the response to be silently dropped.
func captureScrollback(target string) (string, error) {
out, err := exec.Command("tmux", "capture-pane", "-t", target, "-p", "-S", "-").Output()
if err != nil {
return "", err
}
return normalizeCapture(string(out)), nil
}
// shellQuote wraps a path in single quotes and escapes any embedded single quotes.
func shellQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
}
// cleanTUIContent removes Claude Code TUI frame lines from captured output:
// - horizontal separator lines made of ─ (U+2500)
// - bare prompt lines (, >, $, #, %)
//
// tuiInputBlockRe matches Claude Code's 3-line input area:
//
// ──────────────── (U+2500 separator line)
// … (U+276F prompt, any trailing chars)
// ────────────────
//
// Uses explicit Unicode codepoints and [^\n]* to be immune to invisible
// trailing characters on the prompt line.
var tuiInputBlockRe = regexp.MustCompile("(?m)^─+\n[^\n]*\n─+")
func (s *tmuxSession) cleanTUIContent(text string) string {
if s.stripInputBlock {
text = tuiInputBlockRe.ReplaceAllString(text, "")
}
if len(s.stripPatterns) == 0 {
return strings.TrimRight(text, "\n")
}
lines := strings.Split(text, "\n")
out := lines[:0]
for _, line := range lines {
drop := false
for _, re := range s.stripPatterns {
if re.MatchString(line) {
drop = true
break
}
}
if !drop {
out = append(out, line)
}
}
return strings.TrimRight(strings.Join(out, "\n"), "\n")
}
// normalizeCapture trims trailing whitespace per line and strips ANSI codes.
func normalizeCapture(raw string) string {
raw = ansiRe.ReplaceAllString(raw, "")
lines := strings.Split(raw, "\n")
for i, line := range lines {
lines[i] = strings.TrimRight(line, " \t\r")
}
return strings.TrimRight(strings.Join(lines, "\n"), "\n")
}
// ansiRe matches common ANSI/VT escape sequences.
// OSC must come first so "\x1b]" is consumed fully (not as a generic two-char sequence).
var ansiRe = regexp.MustCompile(
`\x1b\][^\x07\x1b]*\x07` + // OSC: ESC ] ... BEL
`|\x1b\[[0-9;]*[a-zA-Z]` + // CSI: ESC [ params letter
`|\x1b.`, // Other two-char escape sequences
)
// extractNew returns the response text that appeared in current after the baseline.
// It handles three cases:
// 1. Linear shell output — current is baseline + new lines (HasPrefix fast path).
// 2. TUI redraws (e.g. Claude Code) — terminal overwrites lines in place; find the
// longest common line prefix shared by both snapshots, then return the new lines
// that follow it in current, stripping the repeated trailing prompt lines.
// 3. Terminal scrolled — baseline has partially scrolled off; use a shrinking anchor.
func extractNew(baseline, current string) string {
if current == baseline {
return ""
}
if baseline == "" {
return current
}
// Fast path: linear output, content only grew.
if strings.HasPrefix(current, baseline) {
return strings.TrimLeft(current[len(baseline):], "\n")
}
baseLines := strings.Split(baseline, "\n")
curLines := strings.Split(current, "\n")
// TUI path: find how many leading lines the two snapshots share (the static
// frame/header), then return the new lines that follow in current.
commonLen := 0
for i := 0; i < len(baseLines) && i < len(curLines); i++ {
if baseLines[i] != curLines[i] {
break
}
commonLen = i + 1
}
if commonLen > 0 && commonLen < len(curLines) {
newLines := curLines[commonLen:]
// Strip trailing lines that duplicate the baseline's suffix (e.g. the prompt ">").
bl := baseLines
for len(newLines) > 0 && len(bl) > 0 && newLines[len(newLines)-1] == bl[len(bl)-1] {
newLines = newLines[:len(newLines)-1]
bl = bl[:len(bl)-1]
}
result := strings.TrimRight(strings.Join(newLines, "\n"), "\n")
if result != "" {
return result
}
}
// Scroll path: baseline has partially scrolled off the top; try progressively
// shorter anchors from the end of baseline to find where new content begins.
maxAnchor := 5
if len(baseLines) < maxAnchor {
maxAnchor = len(baseLines)
}
for n := maxAnchor; n >= 1; n-- {
anchor := strings.Join(baseLines[len(baseLines)-n:], "\n")
if idx := strings.Index(current, anchor); idx >= 0 {
rest := strings.TrimLeft(current[idx+len(anchor):], "\n")
if rest != "" {
return rest
}
}
}
return current
}