claude basic chat support implemented

This commit is contained in:
Levi Neely 2026-02-10 11:55:10 +01:00
parent a0a94e952c
commit e7059b94fb
5 changed files with 874 additions and 110 deletions

BIN
acme-q Executable file

Binary file not shown.

View File

@ -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)
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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: <animated char> <Word>… (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
}