claude basic chat support implemented
This commit is contained in:
parent
a0a94e952c
commit
e7059b94fb
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Reference in New Issue