diff --git a/acme-q b/acme-q new file mode 100755 index 0000000..756b205 Binary files /dev/null and b/acme-q differ diff --git a/internal/backend/tmux/helpers.go b/internal/backend/tmux/helpers.go new file mode 100644 index 0000000..38ef165 --- /dev/null +++ b/internal/backend/tmux/helpers.go @@ -0,0 +1,94 @@ +// Package tmux provides tmux-based backend implementation for CLI tools +package tmux + +import ( + "fmt" + "os/exec" + "strings" + "syscall" +) + +// tmuxCmd executes a tmux command and returns the output +func tmuxCmd(args ...string) (string, error) { + cmd := exec.Command("tmux", args...) + out, err := cmd.CombinedOutput() + if err != nil { + return "", fmt.Errorf("tmux %s: %w: %s", strings.Join(args, " "), err, string(out)) + } + return string(out), nil +} + +// createSession creates a new detached tmux session +func createSession(name string, rows, cols uint16) error { + _, err := tmuxCmd("new-session", "-d", "-s", name, + "-x", fmt.Sprintf("%d", cols), + "-y", fmt.Sprintf("%d", rows)) + return err +} + +// setEnvironment sets an environment variable in the tmux session +func setEnvironment(session, key, value string) error { + _, err := tmuxCmd("set-environment", "-t", session, key, value) + return err +} + +// sendKeys sends keys to a tmux session +// Special keys: C-m (Enter), C-c (Ctrl+C), etc. +func sendKeys(session string, keys ...string) error { + args := []string{"send-keys", "-t", session} + args = append(args, keys...) + _, err := tmuxCmd(args...) + return err +} + +// sendLiteral sends literal text to a tmux session (doesn't interpret special chars) +func sendLiteral(session, text string) error { + _, err := tmuxCmd("send-keys", "-t", session, "-l", text) + return err +} + +// setupPipePane sets up pipe-pane to redirect output to a file/FIFO +func setupPipePane(session, target string) error { + // -o flag: only capture stdout (not stdin) + cmd := fmt.Sprintf("cat >> %s", target) + _, err := tmuxCmd("pipe-pane", "-o", "-t", session, cmd) + return err +} + +// closePipePane closes the pipe-pane for a session +func closePipePane(session string) error { + // Calling pipe-pane with no command closes it + _, err := tmuxCmd("pipe-pane", "-t", session) + return err +} + +// killSession kills a tmux session +func killSession(session string) error { + // Don't return error if session doesn't exist + cmd := exec.Command("tmux", "kill-session", "-t", session) + cmd.Run() + return nil +} + +// sessionExists checks if a tmux session exists +func sessionExists(name string) bool { + cmd := exec.Command("tmux", "has-session", "-t", name) + return cmd.Run() == nil +} + +// getSessionPID returns the PID of the process running in the tmux session +func getSessionPID(session string) (int, error) { + // Get the pane PID + out, err := tmuxCmd("display-message", "-t", session, "-p", "#{pane_pid}") + if err != nil { + return 0, err + } + var pid int + _, err = fmt.Sscanf(strings.TrimSpace(out), "%d", &pid) + return pid, err +} + +// makeNonBlocking sets a file descriptor to non-blocking mode +func makeNonBlocking(fd int) error { + return syscall.SetNonblock(fd, true) +} diff --git a/internal/backend/tmux/session.go b/internal/backend/tmux/session.go new file mode 100644 index 0000000..9858355 --- /dev/null +++ b/internal/backend/tmux/session.go @@ -0,0 +1,319 @@ +package tmux + +import ( + "acme-q/internal/backend" + "bytes" + "context" + "fmt" + "io" + "log" + "os" + "strings" + "sync" + "time" +) + +// Session implements backend.Session for tmux-based tools +type Session struct { + id string + sessionName string // tmux session name + cwd string + alias string + winID int + pid int + state string + createdAt time.Time + + fifoPath string + fifo *os.File + dataCh chan []byte + stopCh chan struct{} + output bytes.Buffer + + detector Detector + cleaner Cleaner + commands backend.CommandHandler + startupHandler StartupHandler + + mu sync.Mutex +} + +func (s *Session) ID() string { + return s.id +} + +func (s *Session) State() string { + s.mu.Lock() + defer s.mu.Unlock() + return s.state +} + +func (s *Session) SetAlias(alias string) { + s.mu.Lock() + defer s.mu.Unlock() + s.alias = alias +} + +func (s *Session) Metadata() backend.SessionMetadata { + s.mu.Lock() + defer s.mu.Unlock() + + return backend.SessionMetadata{ + Pid: s.pid, + Cwd: s.cwd, + Alias: s.alias, + WinID: s.winID, + CreatedAt: s.createdAt, + Extra: map[string]string{ + "tmux_session": s.sessionName, + }, + } +} + +func (s *Session) Commands() backend.CommandHandler { + return s.commands +} + +// SetWinID sets the Acme window ID +func (s *Session) SetWinID(winID int) { + s.mu.Lock() + defer s.mu.Unlock() + s.winID = winID +} + +// GetWinID gets the Acme window ID +func (s *Session) GetWinID() int { + s.mu.Lock() + defer s.mu.Unlock() + return s.winID +} + +// GetPid gets the process ID +func (s *Session) GetPid() int { + s.mu.Lock() + defer s.mu.Unlock() + return s.pid +} + +// reader continuously reads from FIFO and sends to dataCh +func (s *Session) reader() { + buf := make([]byte, 4096) + for { + n, err := s.fifo.Read(buf) + if err != nil { + // Only log unexpected errors (not EOF or closed file during shutdown) + if err != io.EOF && !strings.Contains(err.Error(), "file already closed") { + log.Printf("[session %s] reader error: %v", s.id, err) + } + close(s.dataCh) + s.mu.Lock() + s.state = "exited" + s.pid = 0 + s.mu.Unlock() + return + } + if n > 0 { + data := make([]byte, n) + copy(data, buf[:n]) + s.dataCh <- data + } + } +} + +func (s *Session) waitForReady(ctx context.Context, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + startupDone := s.startupHandler == nil // If no handler, startup is already "done" + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case data, ok := <-s.dataCh: + if !ok { + return fmt.Errorf("session closed") + } + s.output.Write(data) + + log.Printf("[session %s] startup: %d bytes accumulated", s.id, s.output.Len()) + + // If we have a startup handler and it hasn't completed, call it + if !startupDone && s.startupHandler != nil { + keys, done := s.startupHandler.HandleStartup(s.output.String()) + if keys != "" { + log.Printf("[session %s] startup handler sending keys: %q", s.id, keys) + if err := sendKeys(s.sessionName, keys); err != nil { + return fmt.Errorf("failed to send startup response: %w", err) + } + } + if done { + log.Printf("[session %s] startup handler complete", s.id) + startupDone = true + s.output.Reset() // Reset output after startup handling + } + } + + // Check if ready (only after startup is done) + if startupDone && s.detector.IsReady(s.output.String()) { + log.Printf("[session %s] ready detected", s.id) + return nil + } + case <-time.After(time.Until(deadline)): + if time.Now().After(deadline) { + log.Printf("[session %s] timeout, current output: %s", s.id, s.output.String()) + return fmt.Errorf("timeout waiting for ready") + } + } + } +} + +func (s *Session) Send(ctx context.Context, prompt string) (string, error) { + s.mu.Lock() + defer s.mu.Unlock() + + if s.fifo == nil { + return "", fmt.Errorf("session not running") + } + + if s.state == "starting" { + return "", fmt.Errorf("session still starting") + } + + // Check command support if applicable + if strings.HasPrefix(prompt, "/") && s.commands != nil { + if !s.commands.IsSupported(prompt) { + return "", fmt.Errorf("unsupported command") + } + } + + // Reset stopCh for this request + select { + case <-s.stopCh: + s.stopCh = make(chan struct{}) + default: + } + + s.state = "running" + s.output.Reset() + + log.Printf("[session %s] sending: %q", s.id, prompt) + + // Send prompt using literal mode to avoid special character interpretation + if err := sendLiteral(s.sessionName, prompt); err != nil { + s.state = "idle" + return "", fmt.Errorf("send literal failed: %w", err) + } + + // Send Enter + if err := sendKeys(s.sessionName, "C-m"); err != nil { + s.state = "idle" + return "", fmt.Errorf("send enter failed: %w", err) + } + + // Wait for completion + if err := s.waitForComplete(ctx, prompt); err != nil { + s.state = "idle" + return "", err + } + + s.state = "idle" + + // Clean output + rawLen := s.output.Len() + result := s.cleaner.Clean(prompt, s.output.String()) + log.Printf("[session %s] raw: %d bytes, cleaned: %d bytes", s.id, rawLen, len(result)) + if len(result) == 0 && rawLen > 0 { + log.Printf("[session %s] WARNING: cleaner returned 0 bytes", s.id) + } + + return result, nil +} + +func (s *Session) waitForComplete(ctx context.Context, prompt string) error { + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-s.stopCh: + return fmt.Errorf("interrupted") + case data, ok := <-s.dataCh: + if !ok { + return fmt.Errorf("session closed") + } + s.output.Write(data) + log.Printf("[session %s] +%d bytes, total %d", s.id, len(data), s.output.Len()) + + if s.detector.IsComplete(prompt, s.output.String()) { + // Drain any remaining data + for { + select { + case extra, ok := <-s.dataCh: + if ok { + s.output.Write(extra) + } + case <-time.After(10 * time.Millisecond): + log.Printf("[session %s] response complete, %d bytes", s.id, s.output.Len()) + return nil + } + } + } + } + } +} + +func (s *Session) SendStream(ctx context.Context, prompt string) (io.ReadCloser, error) { + // For tmux backends, fall back to Send + response, err := s.Send(ctx, prompt) + if err != nil { + return nil, err + } + return io.NopCloser(strings.NewReader(response)), nil +} + +func (s *Session) Stop(ctx context.Context) error { + s.mu.Lock() + defer s.mu.Unlock() + + if s.fifo == nil { + return fmt.Errorf("session not running") + } + + // Signal waiters + select { + case <-s.stopCh: + default: + close(s.stopCh) + } + + // Send CTRL+C + return sendKeys(s.sessionName, "C-c") +} + +func (s *Session) Close() error { + s.mu.Lock() + defer s.mu.Unlock() + + // 1. Kill tmux session first (closes pipe-pane writer) + if s.sessionName != "" { + killSession(s.sessionName) + s.sessionName = "" + } + + // 2. Give writer time to close, which will send EOF to reader + time.Sleep(50 * time.Millisecond) + + // 3. Close FIFO (reader will exit gracefully) + if s.fifo != nil { + s.fifo.Close() + s.fifo = nil + } + + // 4. Remove FIFO file + if s.fifoPath != "" { + os.Remove(s.fifoPath) + s.fifoPath = "" + } + + s.pid = 0 + s.state = "exited" + return nil +} diff --git a/internal/backend/tmux/tmux.go b/internal/backend/tmux/tmux.go new file mode 100644 index 0000000..cd6f495 --- /dev/null +++ b/internal/backend/tmux/tmux.go @@ -0,0 +1,226 @@ +// Package tmux provides tmux-based backend implementation for CLI tools +package tmux + +import ( + "acme-q/internal/backend" + "context" + "crypto/rand" + "encoding/hex" + "fmt" + "log" + "os" + "sync/atomic" + "syscall" + "time" +) + +// TmuxSize defines terminal dimensions +type TmuxSize struct { + Rows uint16 + Cols uint16 +} + +// Detector handles pattern matching for backend output +type Detector interface { + // IsReady checks if backend is ready for first prompt + IsReady(output string) bool + + // IsComplete checks if response to given prompt is complete + IsComplete(prompt string, output string) bool +} + +// Cleaner processes raw output from the backend +type Cleaner interface { + // Clean removes noise (ANSI codes, spinners, etc.) and extracts actual content + Clean(prompt string, rawOutput string) string +} + +// StartupHandler can send initial commands during startup +type StartupHandler interface { + // HandleStartup is called during waitForReady when output is received + // Returns tmux key sequence to send (e.g., "Down", "C-m"), or empty string if no action needed + // Returns done=true when startup is complete + HandleStartup(output string) (keys string, done bool) +} + +// Config holds tmux backend configuration +type Config struct { + Name string + Command []string + Environment map[string]string + TmuxSize TmuxSize + StartupTime time.Duration + Detector Detector + Cleaner Cleaner + Commands backend.CommandHandler + StartupHandler StartupHandler // Optional: handles startup dialogs +} + +// Backend implements backend.Backend for tmux-based CLI tools +type Backend struct { + cfg Config + counter uint64 +} + +// New creates a new tmux-based backend +func New(cfg Config) backend.Backend { + if cfg.StartupTime == 0 { + cfg.StartupTime = 30 * time.Second + } + if cfg.TmuxSize.Rows == 0 { + cfg.TmuxSize.Rows = 40 + } + if cfg.TmuxSize.Cols == 0 { + cfg.TmuxSize.Cols = 120 + } + return &Backend{cfg: cfg} +} + +func (b *Backend) Name() string { + return b.cfg.Name +} + +func (b *Backend) CreateSession(ctx context.Context, cwd string) (backend.Session, error) { + id := fmt.Sprintf("%d", atomic.AddUint64(&b.counter, 1)) + + // Generate unique session name with random suffix + randomBytes := make([]byte, 4) + rand.Read(randomBytes) + sessionName := fmt.Sprintf("%s-%d-%s", + b.cfg.Name, + time.Now().Unix(), + hex.EncodeToString(randomBytes)) + + log.Printf("[session %s] creating tmux session %s", id, sessionName) + + // 1. Create tmux session + if err := createSession(sessionName, b.cfg.TmuxSize.Rows, b.cfg.TmuxSize.Cols); err != nil { + return nil, fmt.Errorf("failed to create tmux session: %w", err) + } + + // 2. Set environment variables + for k, v := range b.cfg.Environment { + if err := setEnvironment(sessionName, k, v); err != nil { + killSession(sessionName) + return nil, fmt.Errorf("failed to set environment: %w", err) + } + } + + // 3. Create FIFO for output + fifoPath := fmt.Sprintf("/tmp/tmux-%s.fifo", sessionName) + if err := syscall.Mkfifo(fifoPath, 0600); err != nil { + killSession(sessionName) + return nil, fmt.Errorf("failed to create FIFO: %w", err) + } + + // 4. Setup pipe-pane + if err := setupPipePane(sessionName, fifoPath); err != nil { + os.Remove(fifoPath) + killSession(sessionName) + return nil, fmt.Errorf("failed to setup pipe-pane: %w", err) + } + + // 5. Open FIFO for reading (this blocks until writer connects) + // We need to do this in a goroutine to avoid blocking + fifoOpenCh := make(chan *os.File, 1) + fifoErrCh := make(chan error, 1) + go func() { + f, err := os.OpenFile(fifoPath, os.O_RDONLY, 0600) + if err != nil { + fifoErrCh <- err + return + } + fifoOpenCh <- f + }() + + // 6. Start the command in tmux + cmdStr := "" + for i, arg := range b.cfg.Command { + if i > 0 { + cmdStr += " " + } + // Quote arguments with spaces + if containsSpace(arg) { + cmdStr += fmt.Sprintf("\"%s\"", arg) + } else { + cmdStr += arg + } + } + + // Change to working directory first + if err := sendKeys(sessionName, "cd", fmt.Sprintf("\"%s\"", cwd), "C-m"); err != nil { + os.Remove(fifoPath) + killSession(sessionName) + return nil, fmt.Errorf("failed to change directory: %w", err) + } + + // Send the command + if err := sendKeys(sessionName, cmdStr, "C-m"); err != nil { + os.Remove(fifoPath) + killSession(sessionName) + return nil, fmt.Errorf("failed to start command: %w", err) + } + + // 7. Wait for FIFO to open (should happen quickly after command starts) + var fifo *os.File + select { + case fifo = <-fifoOpenCh: + // Success + case err := <-fifoErrCh: + os.Remove(fifoPath) + killSession(sessionName) + return nil, fmt.Errorf("failed to open FIFO: %w", err) + case <-time.After(5 * time.Second): + os.Remove(fifoPath) + killSession(sessionName) + return nil, fmt.Errorf("timeout opening FIFO") + } + + // 8. Get PID + pid, err := getSessionPID(sessionName) + if err != nil { + log.Printf("[session %s] warning: failed to get PID: %v", id, err) + pid = 0 + } + + sess := &Session{ + id: id, + sessionName: sessionName, + cwd: cwd, + pid: pid, + state: "starting", + createdAt: time.Now(), + fifoPath: fifoPath, + fifo: fifo, + dataCh: make(chan []byte, 100), + stopCh: make(chan struct{}), + detector: b.cfg.Detector, + cleaner: b.cfg.Cleaner, + commands: b.cfg.Commands, + startupHandler: b.cfg.StartupHandler, + } + + // 9. Start reader goroutine + go sess.reader() + + // 10. Wait for ready + if err := sess.waitForReady(ctx, b.cfg.StartupTime); err != nil { + sess.Close() + return nil, err + } + + sess.state = "idle" + sess.output.Reset() + log.Printf("[session %s] ready (tmux=%s, pid=%d)", sess.ID(), sessionName, sess.pid) + + return sess, nil +} + +func containsSpace(s string) bool { + for _, r := range s { + if r == ' ' || r == '\t' { + return true + } + } + return false +} diff --git a/internal/backends/claude.go b/internal/backends/claude.go index 3638e22..4c1ea1a 100644 --- a/internal/backends/claude.go +++ b/internal/backends/claude.go @@ -3,7 +3,7 @@ package backends import ( "acme-q/internal/backend" - "acme-q/internal/backend/pty" + "acme-q/internal/backend/tmux" "context" "fmt" "regexp" @@ -12,19 +12,22 @@ import ( ) // NewClaude creates a claude CLI backend +// +// Uses tmux backend to handle interactive TUI dialogs programmatically. +// The startup handler automatically accepts the --dangerously-skip-permissions dialog. func NewClaude() backend.Backend { - return pty.New(pty.Config{ + return tmux.New(tmux.Config{ Name: "claude", - Command: []string{"claude", "--permission-mode=bypassPermissions"}, + Command: []string{"claude", "--dangerously-skip-permissions"}, Environment: map[string]string{ "TERM": "xterm-256color", "NO_COLOR": "1", }, - PTYSize: pty.PTYSize{ + TmuxSize: tmux.TmuxSize{ Rows: 40, Cols: 120, }, - StartupTime: 45 * time.Second, + StartupTime: 15 * time.Second, Detector: &claudeDetector{}, Cleaner: &claudeCleaner{}, Commands: &claudeCommands{}, @@ -32,61 +35,13 @@ func NewClaude() backend.Backend { }) } -// claudeStartupHandler handles the startup confirmation dialogs -type claudeStartupHandler struct { - acceptedBypassWarning bool - acceptedWorkspaceTrust bool -} - -func (h *claudeStartupHandler) HandleStartup(output string) (data []byte, done bool) { - clean := stripANSI(output) - - // First dialog: Bypass permissions warning - if !h.acceptedBypassWarning && strings.Contains(clean, "WARNING: Claude Code running in Bypass Permissions mode") { - if strings.Contains(clean, "Yes, I accept") { - // Dialog is shown, send "2" + Enter to select "Yes, I accept" - // Or just press Enter if option 2 is already selected - // Looking at the dialog format, option 1 is default, so we need to select 2 - h.acceptedBypassWarning = true - return []byte("2\r"), false - } - } - - // Second dialog: Workspace trust - if h.acceptedBypassWarning && !h.acceptedWorkspaceTrust { - if strings.Contains(clean, "Do you trust the files in this folder") { - if strings.Contains(clean, "Yes, proceed") { - // Send "1" + Enter to select "Yes, proceed" - h.acceptedWorkspaceTrust = true - return []byte("1\r"), true // Done after this - } - } - } - - // If we've accepted both, we're done - if h.acceptedBypassWarning && h.acceptedWorkspaceTrust { - return nil, true - } - - // No action needed yet - return nil, false -} - // claudeDetector implements pattern detection for claude CLI type claudeDetector struct{} func (d *claudeDetector) IsReady(output string) bool { clean := stripANSI(output) - // Claude CLI is ready after startup when: - // 1. We've passed all the dialogs - // 2. And the prompt is ready for input - - // After startup dialogs, claude might show a welcome message or just be silent - // We'll consider it ready if we see certain patterns or if there's been - // substantial output after the dialogs - - // If we see any greeting or welcoming text, consider it ready + // Claude shows a greeting when ready greetingPatterns := []string{ "Hello", "Welcome", @@ -101,19 +56,11 @@ func (d *claudeDetector) IsReady(output string) bool { } } - // If we've passed dialogs and there's some output, consider ready - // (The startup handler will have reset output after dialogs) + // If we have substantial output, likely ready if len(clean) > 50 { return true } - // If output is minimal but doesn't contain dialog text, likely ready - if len(clean) > 0 && - !strings.Contains(clean, "WARNING:") && - !strings.Contains(clean, "Do you trust") { - return true - } - return false } @@ -121,74 +68,126 @@ func (d *claudeDetector) IsComplete(prompt string, output string) bool { clean := stripANSI(output) clean = strings.ReplaceAll(clean, "\r", "") - // Claude CLI typically outputs responses and then goes silent - // We'll look for patterns that indicate completion: - - // 1. If the output is very short (< 100 chars), it's not complete - if len(clean) < 100 { + // Need substantial output first + if len(clean) < 1000 { return false } - // 2. For slash commands (if claude supports them), wait for next prompt - if strings.HasPrefix(strings.TrimSpace(prompt), "/") { - // Look for command completion patterns - // This is a placeholder - adjust based on actual claude CLI behavior - return strings.Contains(clean, "Command completed") || len(clean) > 1000 - } + // Generic thinking indicator patterns: + // - "(esc to interrupt)" appears during processing + // - "∴" is the thought indicator + hasSeenThinking := strings.Contains(clean, "(esc to interrupt)") || + strings.Contains(clean, "∴") - // 3. For normal prompts, claude CLI might have different completion markers - // Common patterns: - // - Empty lines at the end - // - Specific markers - // - Or just wait for substantial output and a pause (handled by timeout) - - // Check if output seems complete (ends with newlines and has substantial content) - lines := strings.Split(clean, "\n") - if len(lines) < 5 { + if !hasSeenThinking { + // Not even started processing yet return false } - // Consider complete if we have substantial output - // The PTY backend will handle the timeout and draining - return len(clean) > 200 + // CRITICAL: Must have seen actual response content (● marker) + // Claude responses and tool usage both use ● + hasResponse := strings.Contains(clean, "●") + if !hasResponse { + // No response content yet, keep waiting + return false + } + + // Get the tail of the output (last 500 chars) + tailStart := len(clean) - 500 + if tailStart < 0 { + tailStart = 0 + } + tail := clean[tailStart:] + + // Check if thinking indicators have stopped in the recent output + noRecentThinking := !strings.Contains(tail, "(esc to interrupt)") && + !strings.Contains(tail, "∴") + + // Check if we have bottom chrome in the tail + hasChromeInTail := strings.Contains(tail, "bypass permissions on") || + strings.Contains(tail, "Thinking on (tab to toggle)") + + // Complete when: + // 1. We saw thinking indicators earlier (Claude started) + // 2. We saw response content (● appeared) + // 3. No thinking indicators in recent output (Claude finished) + // 4. Chrome is present in recent output (UI returned to ready state) + // 5. Substantial output accumulated + return noRecentThinking && hasChromeInTail && len(clean) > 2000 } // claudeCleaner implements output cleaning for claude CLI type claudeCleaner struct{} func (c *claudeCleaner) Clean(prompt string, rawOutput string) string { - // Strip ANSI codes + // Strip ANSI codes first clean := stripANSI(rawOutput) clean = strings.ReplaceAll(clean, "\r", "\n") + // Remove terminal title sequences (OSC sequences like ]0;...) + // These appear as ]0;✳ title in the output + titleRegex := regexp.MustCompile(`\]0;[^\n]*`) + clean = titleRegex.ReplaceAllString(clean, "") + lines := strings.Split(clean, "\n") var result []string - seenResponse := false + inResponse := false for _, line := range lines { trimmed := strings.TrimSpace(line) - // Skip empty lines at the start - if !seenResponse && trimmed == "" { + // Skip completely empty lines at start + if !inResponse && trimmed == "" { continue } - // Skip noise patterns + // Skip noise (includes thinking indicators, startup chrome, etc.) if c.isNoise(line) { + // If we hit bottom chrome after response started, stop + if inResponse && c.isBottomChrome(line) { + break + } continue } - // Skip the echo of the prompt - if strings.TrimSpace(line) == strings.TrimSpace(prompt) { + // Skip lines that are just ">" with optional whitespace + // These are cursor/prompt redraws + if trimmed == ">" || trimmed == "" { continue } - if trimmed != "" { - seenResponse = true - result = append(result, line) - } else if seenResponse { - // Keep empty lines within the response - result = append(result, "") + // Skip prompt echo + if strings.HasPrefix(trimmed, ">") && strings.Contains(line, prompt) { + continue + } + + // Look for actual response content + // Claude responses start with ● (bullet) + // Tool usage also uses ● + if strings.HasPrefix(trimmed, "●") { + inResponse = true + // Remove the ● prefix and keep the content + content := strings.TrimPrefix(trimmed, "●") + content = strings.TrimSpace(content) + if content != "" { + result = append(result, content) + } + continue + } + + // If we're in response mode, keep non-chrome content + // Continuation lines (indented content after ●) should be kept + if inResponse { + if c.isBottomChrome(line) { + // Hit bottom chrome, stop collecting + break + } + if trimmed != "" { + result = append(result, strings.TrimLeft(line, " \t")) + } else { + // Keep empty lines within response + result = append(result, "") + } } } @@ -203,19 +202,69 @@ func (c *claudeCleaner) Clean(prompt string, rawOutput string) string { func (c *claudeCleaner) isNoise(line string) bool { trimmed := strings.TrimSpace(line) - // Noise patterns specific to claude CLI + // Empty or whitespace-only lines + if trimmed == "" { + return true + } + + // Lines that are just ">" (prompt redraws) + if trimmed == ">" { + return true + } + + // Generic thinking/status indicators: + // - "(esc to interrupt)" - appears during any processing phase + // - "∴" - the "Thought for..." indicator + if strings.Contains(line, "(esc to interrupt)") { + return true + } + + // "∴" (three-dot triangle) followed by any text is a thought indicator + if strings.Contains(line, "∴") { + return true + } + + // Animated spinner characters (dot/asterisk morphing animation) + ellipsis + // Pattern: … (esc to interrupt) + // These are the thinking status lines + animatedChars := []rune{'·', '•', '*', '✶', '✻', '✽', '✢', '✺', '✸', '✹', '✦', '✧'} + for _, r := range animatedChars { + if strings.HasPrefix(trimmed, string(r)+" ") && strings.Contains(line, "…") { + return true + } + } + + // Short lines with ellipsis are likely status indicators + if strings.Contains(line, "…") && len(trimmed) < 50 { + return true + } + + // Startup/welcome chrome and UI elements noisePatterns := []string{ - "WARNING: Claude Code running in Bypass Permissions mode", - "In Bypass Permissions mode", - "This mode should only be used", - "By proceeding, you accept", "https://code.claude.com", "Learn more", - "Yes, I accept", - "No, exit", + "Press Ctrl-D", + "WARNING:", + "By proceeding, you accept", "Enter to confirm", "Esc to exit", - "Press Ctrl-D", + "❯ 1.", + "❯ 2.", + "In Bypass Permissions mode", + "Welcome back", + "Tips for getting started", + "Recent activity", + "No recent activity", + "Run /init to create", + "Sonnet 4.5", + "Claude Pro", + "Claude Code", + "Note: You have launched", + "▐▛███▜▌", // ASCII art logo + "▝▜█████▛▘", + "▘▘ ▝▝", + "Tip: Send messages to Claude", + "⎿", // UI decoration character } for _, pattern := range noisePatterns { @@ -224,7 +273,7 @@ func (c *claudeCleaner) isNoise(line string) bool { } } - // Check for spinner/loading characters + // Check for progress spinner characters at start of line spinners := []rune{'⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏', '⢀', '⡀', '✓', '⚠'} for _, r := range spinners { if strings.HasPrefix(trimmed, string(r)) { @@ -233,13 +282,42 @@ func (c *claudeCleaner) isNoise(line string) bool { } // Skip lines that are just box drawing characters or separators - if regexp.MustCompile(`^[─│┌┐└┘├┤┬┴┼═║╔╗╚╝╠╣╦╩╬]+$`).MatchString(trimmed) { + if regexp.MustCompile(`^[─│┌┐└┘├┤┬┴┼═║╔╗╚╝╠╣╦╩╬╭╮╰╯\s]+$`).MatchString(trimmed) { return true } return false } +func (c *claudeCleaner) isBottomChrome(line string) bool { + trimmed := strings.TrimSpace(line) + + // Long separator lines (like the one before bottom chrome) + if len(trimmed) > 100 && regexp.MustCompile(`^─+$`).MatchString(trimmed) { + return true + } + + // Bottom UI chrome patterns + chromePatterns := []string{ + "bypass permissions on", + "Thinking on (tab to toggle)", + "shift+tab to cycle", + "Auto-update", + "Auto-updating", + "claude doctor", + "npm i -g", + "⏵⏵", + } + + for _, pattern := range chromePatterns { + if strings.Contains(line, pattern) { + return true + } + } + + return false +} + // claudeCommands implements command handling for claude CLI type claudeCommands struct{} @@ -257,7 +335,7 @@ func (h *claudeCommands) IsSupported(command string) bool { } func (h *claudeCommands) Execute(ctx context.Context, command string) (string, error) { - // For claude CLI, commands are executed by sending them to the PTY + // For claude CLI, commands are executed by sending them to the session // This is handled by the Session.Send() method // We just validate the command here if !h.IsSupported(command) { @@ -265,3 +343,50 @@ func (h *claudeCommands) Execute(ctx context.Context, command string) (string, e } return "", fmt.Errorf("execute called directly - use Send() instead") } + +// claudeStartupHandler handles the --dangerously-skip-permissions dialog +type claudeStartupHandler struct { + sentDown bool + sentEnter bool +} + +func (h *claudeStartupHandler) HandleStartup(output string) (keys string, done bool) { + clean := stripANSI(output) + + // Look for the permissions dialog + if strings.Contains(clean, "WARNING: Claude Code running in Bypass Permissions mode") { + // Check if we're on "No, exit" (default selection) + if strings.Contains(clean, "❯ 1. No, exit") && !h.sentDown { + // Send Down arrow to select "Yes, I accept" + h.sentDown = true + return "Down", false + } + // Check if we're on "Yes, I accept" + if strings.Contains(clean, "❯ 2. Yes, I accept") && !h.sentEnter { + // Send Enter to confirm + h.sentEnter = true + return "C-m", false + } + // Still showing dialog but not on a specific selection + // Wait for more output + return "", false + } + + // Check if we're past the dialog (looking for greeting) + greetingPatterns := []string{ + "Hello", + "Welcome", + "I'm Claude", + "What can I help", + "How can I assist", + } + + for _, pattern := range greetingPatterns { + if strings.Contains(clean, pattern) { + return "", true // Done with startup + } + } + + // Not at dialog yet, keep waiting + return "", false +}