615 lines
20 KiB
Go
615 lines
20 KiB
Go
// Package tmux provides a wrapper for tmux session operations via subprocess.
|
|
package tmux
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Common errors
|
|
var (
|
|
ErrNoServer = errors.New("no tmux server running")
|
|
ErrSessionExists = errors.New("session already exists")
|
|
ErrSessionNotFound = errors.New("session not found")
|
|
)
|
|
|
|
// Tmux wraps tmux operations.
|
|
type Tmux struct{}
|
|
|
|
// NewTmux creates a new Tmux wrapper.
|
|
func NewTmux() *Tmux {
|
|
return &Tmux{}
|
|
}
|
|
|
|
// run executes a tmux command and returns stdout.
|
|
func (t *Tmux) run(args ...string) (string, error) {
|
|
cmd := exec.Command("tmux", args...)
|
|
var stdout, stderr bytes.Buffer
|
|
cmd.Stdout = &stdout
|
|
cmd.Stderr = &stderr
|
|
|
|
err := cmd.Run()
|
|
if err != nil {
|
|
return "", t.wrapError(err, stderr.String(), args)
|
|
}
|
|
|
|
return strings.TrimSpace(stdout.String()), nil
|
|
}
|
|
|
|
// wrapError wraps tmux errors with context.
|
|
func (t *Tmux) wrapError(err error, stderr string, args []string) error {
|
|
stderr = strings.TrimSpace(stderr)
|
|
|
|
// Detect specific error types
|
|
if strings.Contains(stderr, "no server running") ||
|
|
strings.Contains(stderr, "error connecting to") {
|
|
return ErrNoServer
|
|
}
|
|
if strings.Contains(stderr, "duplicate session") {
|
|
return ErrSessionExists
|
|
}
|
|
if strings.Contains(stderr, "session not found") ||
|
|
strings.Contains(stderr, "can't find session") {
|
|
return ErrSessionNotFound
|
|
}
|
|
|
|
if stderr != "" {
|
|
return fmt.Errorf("tmux %s: %s", args[0], stderr)
|
|
}
|
|
return fmt.Errorf("tmux %s: %w", args[0], err)
|
|
}
|
|
|
|
// NewSession creates a new detached tmux session.
|
|
func (t *Tmux) NewSession(name, workDir string) error {
|
|
args := []string{"new-session", "-d", "-s", name}
|
|
if workDir != "" {
|
|
args = append(args, "-c", workDir)
|
|
}
|
|
_, err := t.run(args...)
|
|
return err
|
|
}
|
|
|
|
// KillSession terminates a tmux session.
|
|
func (t *Tmux) KillSession(name string) error {
|
|
_, err := t.run("kill-session", "-t", name)
|
|
return err
|
|
}
|
|
|
|
// KillServer terminates the entire tmux server and all sessions.
|
|
func (t *Tmux) KillServer() error {
|
|
_, err := t.run("kill-server")
|
|
if errors.Is(err, ErrNoServer) {
|
|
return nil // Already dead
|
|
}
|
|
return err
|
|
}
|
|
|
|
// HasSession checks if a session exists.
|
|
func (t *Tmux) HasSession(name string) (bool, error) {
|
|
_, err := t.run("has-session", "-t", name)
|
|
if err != nil {
|
|
if errors.Is(err, ErrSessionNotFound) || errors.Is(err, ErrNoServer) {
|
|
return false, nil
|
|
}
|
|
return false, err
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
// ListSessions returns all session names.
|
|
func (t *Tmux) ListSessions() ([]string, error) {
|
|
out, err := t.run("list-sessions", "-F", "#{session_name}")
|
|
if err != nil {
|
|
if errors.Is(err, ErrNoServer) {
|
|
return nil, nil // No server = no sessions
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
if out == "" {
|
|
return nil, nil
|
|
}
|
|
|
|
return strings.Split(out, "\n"), nil
|
|
}
|
|
|
|
// SendKeys sends keystrokes to a session and presses Enter.
|
|
// Always sends Enter as a separate command for reliability.
|
|
// Uses a debounce delay between paste and Enter to ensure paste completes.
|
|
func (t *Tmux) SendKeys(session, keys string) error {
|
|
return t.SendKeysDebounced(session, keys, 100) // 100ms default debounce
|
|
}
|
|
|
|
// SendKeysDebounced sends keystrokes with a configurable delay before Enter.
|
|
// The debounceMs parameter controls how long to wait after paste before sending Enter.
|
|
// This prevents race conditions where Enter arrives before paste is processed.
|
|
func (t *Tmux) SendKeysDebounced(session, keys string, debounceMs int) error {
|
|
// Send text using literal mode (-l) to handle special chars
|
|
if _, err := t.run("send-keys", "-t", session, "-l", keys); err != nil {
|
|
return err
|
|
}
|
|
// Wait for paste to be processed
|
|
if debounceMs > 0 {
|
|
time.Sleep(time.Duration(debounceMs) * time.Millisecond)
|
|
}
|
|
// Send Enter separately - more reliable than appending to send-keys
|
|
_, err := t.run("send-keys", "-t", session, "Enter")
|
|
return err
|
|
}
|
|
|
|
// SendKeysRaw sends keystrokes without adding Enter.
|
|
func (t *Tmux) SendKeysRaw(session, keys string) error {
|
|
_, err := t.run("send-keys", "-t", session, keys)
|
|
return err
|
|
}
|
|
|
|
// SendKeysReplace sends keystrokes, clearing any pending input first.
|
|
// This is useful for "replaceable" notifications where only the latest matters.
|
|
// Uses Ctrl-U to clear the input line before sending the new message.
|
|
// The delay parameter controls how long to wait after clearing before sending (ms).
|
|
func (t *Tmux) SendKeysReplace(session, keys string, clearDelayMs int) error {
|
|
// Send Ctrl-U to clear any pending input on the line
|
|
if _, err := t.run("send-keys", "-t", session, "C-u"); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Small delay to let the clear take effect
|
|
if clearDelayMs > 0 {
|
|
time.Sleep(time.Duration(clearDelayMs) * time.Millisecond)
|
|
}
|
|
|
|
// Now send the actual message
|
|
return t.SendKeys(session, keys)
|
|
}
|
|
|
|
// SendKeysDelayed sends keystrokes after a delay (in milliseconds).
|
|
// Useful for waiting for a process to be ready before sending input.
|
|
func (t *Tmux) SendKeysDelayed(session, keys string, delayMs int) error {
|
|
time.Sleep(time.Duration(delayMs) * time.Millisecond)
|
|
return t.SendKeys(session, keys)
|
|
}
|
|
|
|
// SendKeysDelayedDebounced sends keystrokes after a pre-delay, with a custom debounce before Enter.
|
|
// Use this when sending input to a process that needs time to initialize AND the message
|
|
// needs extra time between paste and Enter (e.g., Claude prompt injection).
|
|
// preDelayMs: time to wait before sending text (for process readiness)
|
|
// debounceMs: time to wait between text paste and Enter key (for paste completion)
|
|
func (t *Tmux) SendKeysDelayedDebounced(session, keys string, preDelayMs, debounceMs int) error {
|
|
if preDelayMs > 0 {
|
|
time.Sleep(time.Duration(preDelayMs) * time.Millisecond)
|
|
}
|
|
return t.SendKeysDebounced(session, keys, debounceMs)
|
|
}
|
|
|
|
// NudgeSession sends a message to a Claude Code session reliably.
|
|
// This is the canonical way to send messages to Claude sessions.
|
|
// Uses: literal mode + 500ms debounce + separate Enter.
|
|
// Verification is the Witness's job (AI), not this function.
|
|
func (t *Tmux) NudgeSession(session, message string) error {
|
|
// 1. Send text in literal mode (handles special characters)
|
|
if _, err := t.run("send-keys", "-t", session, "-l", message); err != nil {
|
|
return err
|
|
}
|
|
|
|
// 2. Wait 500ms for paste to complete (tested, required)
|
|
time.Sleep(500 * time.Millisecond)
|
|
|
|
// 3. Send Enter as separate command (key to reliability)
|
|
if _, err := t.run("send-keys", "-t", session, "Enter"); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// NudgePane sends a message to a specific pane reliably.
|
|
// Same pattern as NudgeSession but targets a pane ID (e.g., "%9") instead of session name.
|
|
func (t *Tmux) NudgePane(pane, message string) error {
|
|
// 1. Send text in literal mode (handles special characters)
|
|
if _, err := t.run("send-keys", "-t", pane, "-l", message); err != nil {
|
|
return err
|
|
}
|
|
|
|
// 2. Wait 500ms for paste to complete (tested, required)
|
|
time.Sleep(500 * time.Millisecond)
|
|
|
|
// 3. Send Enter as separate command (key to reliability)
|
|
if _, err := t.run("send-keys", "-t", pane, "Enter"); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetPaneCommand returns the current command running in a pane.
|
|
// Returns "bash", "zsh", "claude", "node", etc.
|
|
func (t *Tmux) GetPaneCommand(session string) (string, error) {
|
|
out, err := t.run("list-panes", "-t", session, "-F", "#{pane_current_command}")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return strings.TrimSpace(out), nil
|
|
}
|
|
|
|
// GetPaneWorkDir returns the current working directory of a pane.
|
|
func (t *Tmux) GetPaneWorkDir(session string) (string, error) {
|
|
out, err := t.run("list-panes", "-t", session, "-F", "#{pane_current_path}")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return strings.TrimSpace(out), nil
|
|
}
|
|
|
|
// CapturePane captures the visible content of a pane.
|
|
func (t *Tmux) CapturePane(session string, lines int) (string, error) {
|
|
return t.run("capture-pane", "-p", "-t", session, "-S", fmt.Sprintf("-%d", lines))
|
|
}
|
|
|
|
// CapturePaneAll captures all scrollback history.
|
|
func (t *Tmux) CapturePaneAll(session string) (string, error) {
|
|
return t.run("capture-pane", "-p", "-t", session, "-S", "-")
|
|
}
|
|
|
|
// CapturePaneLines captures the last N lines of a pane as a slice.
|
|
func (t *Tmux) CapturePaneLines(session string, lines int) ([]string, error) {
|
|
out, err := t.CapturePane(session, lines)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if out == "" {
|
|
return nil, nil
|
|
}
|
|
return strings.Split(out, "\n"), nil
|
|
}
|
|
|
|
// AttachSession attaches to an existing session.
|
|
// Note: This replaces the current process with tmux attach.
|
|
func (t *Tmux) AttachSession(session string) error {
|
|
_, err := t.run("attach-session", "-t", session)
|
|
return err
|
|
}
|
|
|
|
// SelectWindow selects a window by index.
|
|
func (t *Tmux) SelectWindow(session string, index int) error {
|
|
_, err := t.run("select-window", "-t", fmt.Sprintf("%s:%d", session, index))
|
|
return err
|
|
}
|
|
|
|
// SetEnvironment sets an environment variable in the session.
|
|
func (t *Tmux) SetEnvironment(session, key, value string) error {
|
|
_, err := t.run("set-environment", "-t", session, key, value)
|
|
return err
|
|
}
|
|
|
|
// GetEnvironment gets an environment variable from the session.
|
|
func (t *Tmux) GetEnvironment(session, key string) (string, error) {
|
|
out, err := t.run("show-environment", "-t", session, key)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
// Output format: KEY=value
|
|
parts := strings.SplitN(out, "=", 2)
|
|
if len(parts) != 2 {
|
|
return "", nil
|
|
}
|
|
return parts[1], nil
|
|
}
|
|
|
|
// RenameSession renames a session.
|
|
func (t *Tmux) RenameSession(oldName, newName string) error {
|
|
_, err := t.run("rename-session", "-t", oldName, newName)
|
|
return err
|
|
}
|
|
|
|
// SessionInfo contains information about a tmux session.
|
|
type SessionInfo struct {
|
|
Name string
|
|
Windows int
|
|
Created string
|
|
Attached bool
|
|
Activity string // Last activity time
|
|
LastAttached string // Last time the session was attached
|
|
}
|
|
|
|
// DisplayMessage shows a message in the tmux status line.
|
|
// This is non-disruptive - it doesn't interrupt the session's input.
|
|
// Duration is specified in milliseconds.
|
|
func (t *Tmux) DisplayMessage(session, message string, durationMs int) error {
|
|
// Set display time temporarily, show message, then restore
|
|
// Use -d flag for duration in tmux 2.9+
|
|
_, err := t.run("display-message", "-t", session, "-d", fmt.Sprintf("%d", durationMs), message)
|
|
return err
|
|
}
|
|
|
|
// DisplayMessageDefault shows a message with default duration (5 seconds).
|
|
func (t *Tmux) DisplayMessageDefault(session, message string) error {
|
|
return t.DisplayMessage(session, message, 5000)
|
|
}
|
|
|
|
// SendNotificationBanner sends a visible notification banner to a tmux session.
|
|
// This interrupts the terminal to ensure the notification is seen.
|
|
// Uses echo to print a boxed banner with the notification details.
|
|
func (t *Tmux) SendNotificationBanner(session, from, subject string) error {
|
|
// Build the banner text
|
|
banner := fmt.Sprintf(`echo '
|
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
📬 NEW MAIL from %s
|
|
Subject: %s
|
|
Run: gt mail inbox
|
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
'`, from, subject)
|
|
|
|
return t.SendKeys(session, banner)
|
|
}
|
|
|
|
// IsClaudeRunning checks if Claude appears to be running in the session.
|
|
// Only trusts the pane command - UI markers in scrollback cause false positives.
|
|
func (t *Tmux) IsClaudeRunning(session string) bool {
|
|
// Check pane command - Claude runs as node
|
|
cmd, err := t.GetPaneCommand(session)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return cmd == "node"
|
|
}
|
|
|
|
// WaitForCommand polls until the pane is NOT running one of the excluded commands.
|
|
// Useful for waiting until a shell has started a new process (e.g., claude).
|
|
// Returns nil when a non-excluded command is detected, or error on timeout.
|
|
func (t *Tmux) WaitForCommand(session string, excludeCommands []string, timeout time.Duration) error {
|
|
deadline := time.Now().Add(timeout)
|
|
for time.Now().Before(deadline) {
|
|
cmd, err := t.GetPaneCommand(session)
|
|
if err != nil {
|
|
time.Sleep(100 * time.Millisecond)
|
|
continue
|
|
}
|
|
// Check if current command is NOT in the exclude list
|
|
excluded := false
|
|
for _, exc := range excludeCommands {
|
|
if cmd == exc {
|
|
excluded = true
|
|
break
|
|
}
|
|
}
|
|
if !excluded {
|
|
return nil
|
|
}
|
|
time.Sleep(100 * time.Millisecond)
|
|
}
|
|
return fmt.Errorf("timeout waiting for command (still running excluded command)")
|
|
}
|
|
|
|
// WaitForShellReady polls until the pane is running a shell command.
|
|
// Useful for waiting until a process has exited and returned to shell.
|
|
func (t *Tmux) WaitForShellReady(session string, timeout time.Duration) error {
|
|
shells := []string{"bash", "zsh", "sh", "fish", "tcsh", "ksh"}
|
|
deadline := time.Now().Add(timeout)
|
|
for time.Now().Before(deadline) {
|
|
cmd, err := t.GetPaneCommand(session)
|
|
if err != nil {
|
|
time.Sleep(100 * time.Millisecond)
|
|
continue
|
|
}
|
|
for _, shell := range shells {
|
|
if cmd == shell {
|
|
return nil
|
|
}
|
|
}
|
|
time.Sleep(100 * time.Millisecond)
|
|
}
|
|
return fmt.Errorf("timeout waiting for shell")
|
|
}
|
|
|
|
// WaitForClaudeReady polls until Claude's prompt indicator appears in the pane.
|
|
// Claude is ready when we see "> " at the start of a line (the input prompt).
|
|
// This is more reliable than just checking if node is running.
|
|
func (t *Tmux) WaitForClaudeReady(session string, timeout time.Duration) error {
|
|
deadline := time.Now().Add(timeout)
|
|
for time.Now().Before(deadline) {
|
|
// Capture last few lines of the pane
|
|
lines, err := t.CapturePaneLines(session, 10)
|
|
if err != nil {
|
|
time.Sleep(200 * time.Millisecond)
|
|
continue
|
|
}
|
|
// Look for Claude's prompt indicator "> " at start of line
|
|
for _, line := range lines {
|
|
trimmed := strings.TrimSpace(line)
|
|
if strings.HasPrefix(trimmed, "> ") || trimmed == ">" {
|
|
return nil
|
|
}
|
|
}
|
|
time.Sleep(200 * time.Millisecond)
|
|
}
|
|
return fmt.Errorf("timeout waiting for Claude prompt")
|
|
}
|
|
|
|
// GetSessionInfo returns detailed information about a session.
|
|
func (t *Tmux) GetSessionInfo(name string) (*SessionInfo, error) {
|
|
format := "#{session_name}|#{session_windows}|#{session_created_string}|#{session_attached}|#{session_activity}|#{session_last_attached}"
|
|
out, err := t.run("list-sessions", "-F", format, "-f", fmt.Sprintf("#{==:#{session_name},%s}", name))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if out == "" {
|
|
return nil, ErrSessionNotFound
|
|
}
|
|
|
|
parts := strings.Split(out, "|")
|
|
if len(parts) < 4 {
|
|
return nil, fmt.Errorf("unexpected session info format: %s", out)
|
|
}
|
|
|
|
windows := 0
|
|
_, _ = fmt.Sscanf(parts[1], "%d", &windows)
|
|
|
|
info := &SessionInfo{
|
|
Name: parts[0],
|
|
Windows: windows,
|
|
Created: parts[2],
|
|
Attached: parts[3] == "1",
|
|
}
|
|
|
|
// Activity and last attached are optional (may not be present in older tmux)
|
|
if len(parts) > 4 {
|
|
info.Activity = parts[4]
|
|
}
|
|
if len(parts) > 5 {
|
|
info.LastAttached = parts[5]
|
|
}
|
|
|
|
return info, nil
|
|
}
|
|
|
|
// ApplyTheme sets the status bar style for a session.
|
|
func (t *Tmux) ApplyTheme(session string, theme Theme) error {
|
|
_, err := t.run("set-option", "-t", session, "status-style", theme.Style())
|
|
return err
|
|
}
|
|
|
|
// roleIcons maps role names to display icons for the status bar.
|
|
var roleIcons = map[string]string{
|
|
"coordinator": "🎩", // Mayor
|
|
"health-check": "🦉", // Deacon
|
|
"witness": "👁",
|
|
"refinery": "🏭",
|
|
"crew": "👷",
|
|
"polecat": "😺",
|
|
}
|
|
|
|
// SetStatusFormat configures the left side of the status bar.
|
|
// Shows compact identity: icon + minimal context
|
|
func (t *Tmux) SetStatusFormat(session, rig, worker, role string) error {
|
|
// Get icon for role (empty string if not found)
|
|
icon := roleIcons[role]
|
|
|
|
// Compact format - icon already identifies role
|
|
// Mayor: 🎩 Mayor
|
|
// Crew: 👷 gastown/crew/max (full path)
|
|
// Polecat: 😺 gastown/Toast
|
|
var left string
|
|
if rig == "" {
|
|
// Town-level agent (Mayor, Deacon)
|
|
left = fmt.Sprintf("%s %s ", icon, worker)
|
|
} else if role == "crew" {
|
|
// Crew member - show full path: rig/crew/name
|
|
left = fmt.Sprintf("%s %s/crew/%s ", icon, rig, worker)
|
|
} else {
|
|
// Rig-level agent - show rig/worker
|
|
left = fmt.Sprintf("%s %s/%s ", icon, rig, worker)
|
|
}
|
|
|
|
if _, err := t.run("set-option", "-t", session, "status-left-length", "25"); err != nil {
|
|
return err
|
|
}
|
|
_, err := t.run("set-option", "-t", session, "status-left", left)
|
|
return err
|
|
}
|
|
|
|
// SetDynamicStatus configures the right side with dynamic content.
|
|
// Uses a shell command that tmux calls periodically to get current status.
|
|
func (t *Tmux) SetDynamicStatus(session string) error {
|
|
// tmux calls this command every status-interval seconds
|
|
// gt status-line reads env vars and mail to build the status
|
|
right := fmt.Sprintf(`#(gt status-line --session=%s 2>/dev/null) %%H:%%M`, session)
|
|
|
|
if _, err := t.run("set-option", "-t", session, "status-right-length", "80"); err != nil {
|
|
return err
|
|
}
|
|
// Set faster refresh for more responsive status
|
|
if _, err := t.run("set-option", "-t", session, "status-interval", "5"); err != nil {
|
|
return err
|
|
}
|
|
_, err := t.run("set-option", "-t", session, "status-right", right)
|
|
return err
|
|
}
|
|
|
|
// ConfigureGasTownSession applies full Gas Town theming to a session.
|
|
// This is a convenience method that applies theme, status format, and dynamic status.
|
|
func (t *Tmux) ConfigureGasTownSession(session string, theme Theme, rig, worker, role string) error {
|
|
if err := t.ApplyTheme(session, theme); err != nil {
|
|
return fmt.Errorf("applying theme: %w", err)
|
|
}
|
|
if err := t.SetStatusFormat(session, rig, worker, role); err != nil {
|
|
return fmt.Errorf("setting status format: %w", err)
|
|
}
|
|
if err := t.SetDynamicStatus(session); err != nil {
|
|
return fmt.Errorf("setting dynamic status: %w", err)
|
|
}
|
|
if err := t.SetMailClickBinding(session); err != nil {
|
|
return fmt.Errorf("setting mail click binding: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// LinkWindow links a window from another session into the current session.
|
|
// This allows viewing another session's window as a tab without switching sessions.
|
|
// Useful when already inside tmux and want to see another session.
|
|
// Uses -d flag to NOT auto-select the new window, keeping user in current window.
|
|
func (t *Tmux) LinkWindow(sourceSession string, windowIndex int) error {
|
|
source := fmt.Sprintf("%s:%d", sourceSession, windowIndex)
|
|
_, err := t.run("link-window", "-s", source, "-d")
|
|
return err
|
|
}
|
|
|
|
// IsInsideTmux checks if the current process is running inside a tmux session.
|
|
// This is detected by the presence of the TMUX environment variable.
|
|
func IsInsideTmux() bool {
|
|
return os.Getenv("TMUX") != ""
|
|
}
|
|
|
|
// SetMailClickBinding configures left-click on status-right to show mail preview.
|
|
// This creates a popup showing the first unread message when clicking the mail icon area.
|
|
func (t *Tmux) SetMailClickBinding(session string) error {
|
|
// Bind left-click on status-right to show mail popup
|
|
// The popup runs gt mail peek and closes on any key
|
|
_, err := t.run("bind-key", "-T", "root", "MouseDown1StatusRight",
|
|
"display-popup", "-E", "-w", "60", "-h", "15", "gt mail peek || echo 'No unread mail'")
|
|
return err
|
|
}
|
|
|
|
// RespawnPane kills all processes in a pane and starts a new command.
|
|
// This is used for "hot reload" of agent sessions - instantly restart in place.
|
|
// The pane parameter should be a pane ID (e.g., "%0") or session:window.pane format.
|
|
func (t *Tmux) RespawnPane(pane, command string) error {
|
|
_, err := t.run("respawn-pane", "-k", "-t", pane, command)
|
|
return err
|
|
}
|
|
|
|
// ClearHistory clears the scrollback history buffer for a pane.
|
|
// This resets copy-mode display from [0/N] to [0/0].
|
|
// The pane parameter should be a pane ID (e.g., "%0") or session:window.pane format.
|
|
func (t *Tmux) ClearHistory(pane string) error {
|
|
_, err := t.run("clear-history", "-t", pane)
|
|
return err
|
|
}
|
|
|
|
// SwitchClient switches the current tmux client to a different session.
|
|
// Used after remote recycle to move the user's view to the recycled session.
|
|
func (t *Tmux) SwitchClient(targetSession string) error {
|
|
_, err := t.run("switch-client", "-t", targetSession)
|
|
return err
|
|
}
|
|
|
|
// SetCrewCycleBindings sets up C-b n/p to cycle through crew sessions in the same rig.
|
|
// This allows quick switching between crew members without using the session picker.
|
|
func (t *Tmux) SetCrewCycleBindings(session string) error {
|
|
// C-b n → gt crew next (switch to next crew session)
|
|
if _, err := t.run("bind-key", "-T", "prefix", "n",
|
|
"run-shell", "gt crew next"); err != nil {
|
|
return err
|
|
}
|
|
// C-b p → gt crew prev (switch to previous crew session)
|
|
if _, err := t.run("bind-key", "-T", "prefix", "p",
|
|
"run-shell", "gt crew prev"); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|