Skip to content
5 changes: 5 additions & 0 deletions lib/httpapi/claude.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ import (
st "github.com/coder/agentapi/lib/screentracker"
)

// formatPaste wraps message in bracketed paste escape sequences.
// These sequences start with ESC (\x1b), which TUI selection
// widgets (e.g. Claude Code's numbered-choice prompt) interpret
// as "cancel". For selection prompts, callers should use
// MessageTypeRaw to send raw keystrokes directly instead.
func formatPaste(message string) []st.MessagePart {
return []st.MessagePart{
// Bracketed paste mode start sequence
Expand Down
21 changes: 14 additions & 7 deletions lib/msgfmt/message_box.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,22 @@ import (
"strings"
)

// containsHorizontalBorder reports whether the line contains a
// horizontal border made of box-drawing characters (─ or ╌).
func containsHorizontalBorder(line string) bool {
return strings.Contains(line, "───────────────") ||
strings.Contains(line, "╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌")
}

// Usually something like
// ───────────────
// ─────────────── (or ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌)
// >
// ───────────────
// ─────────────── (or ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌)
// Used by Claude Code, Goose, and Aider.
func findGreaterThanMessageBox(lines []string) int {
for i := len(lines) - 1; i >= max(len(lines)-6, 0); i-- {
if strings.Contains(lines[i], ">") {
if i > 0 && strings.Contains(lines[i-1], "───────────────") {
if i > 0 && containsHorizontalBorder(lines[i-1]) {
return i - 1
}
return i
Expand All @@ -22,14 +29,14 @@ func findGreaterThanMessageBox(lines []string) int {
}

// Usually something like
// ───────────────
// ─────────────── (or ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌)
// |
// ───────────────
// ─────────────── (or ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌)
func findGenericSlimMessageBox(lines []string) int {
for i := len(lines) - 3; i >= max(len(lines)-9, 0); i-- {
if strings.Contains(lines[i], "───────────────") &&
if containsHorizontalBorder(lines[i]) &&
(strings.Contains(lines[i+1], "|") || strings.Contains(lines[i+1], "│") || strings.Contains(lines[i+1], "❯")) &&
strings.Contains(lines[i+2], "───────────────") {
containsHorizontalBorder(lines[i+2]) {
return i
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
1 function greet() {
2 - console.log("Hello, World!");
2 + console.log("Hello, Claude!");
3 }
╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌
> Try "what does this code do?"
╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌
Syntax theme: Monokai Extended (ctrl+t to disable)
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
╭────────────────────────────────────────────╮
│ ✻ Welcome to Claude Code! │
│ │
│ /help for help │
╰────────────────────────────────────────────╯
╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌
│ Type your message...
╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌
70 changes: 62 additions & 8 deletions lib/screentracker/pty_conversation.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package screentracker
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"os"
Expand All @@ -16,6 +17,26 @@ import (
"golang.org/x/xerrors"
)

const (
// writeStabilizeEchoTimeout is the timeout for the echo
// detection WaitFor loop in writeStabilize Phase 1. The
// effective ceiling may be slightly longer because the
// stability check inside the condition runs outside
// WaitFor's timeout select. Non-echoing agents (e.g. TUI
// agents using bracketed paste) will hit this timeout,
// which is non-fatal.
//
// TODO: move to PTYConversationConfig if agents need
// different echo detection windows.
writeStabilizeEchoTimeout = 2 * time.Second

// writeStabilizeProcessTimeout is the maximum time to wait
// for the screen to change after sending a carriage return.
// This detects whether the agent is actually processing the
// input.
writeStabilizeProcessTimeout = 15 * time.Second
)

// A screenSnapshot represents a snapshot of the PTY at a specific time.
type screenSnapshot struct {
timestamp time.Time
Expand Down Expand Up @@ -411,17 +432,30 @@ func (c *PTYConversation) sendMessage(ctx context.Context, messageParts ...Messa
return nil
}

// writeStabilize writes messageParts to the screen and waits for the screen to stabilize after the message is written.
// writeStabilize writes messageParts to the PTY and waits for
// the agent to process them. It operates in two phases:
//
// Phase 1 (echo detection): writes the message text and waits
// for the screen to change and stabilize. This detects agents
// that echo typed input. If the screen doesn't change within
// writeStabilizeEchoTimeout, this is non-fatal. Many TUI
// agents buffer bracketed-paste input without rendering it.
//
// Phase 2 (processing detection): writes a carriage return
// and waits for the screen to change, indicating the agent
// started processing. This phase is fatal on timeout: if the
// agent doesn't react to Enter, it's unresponsive.
func (c *PTYConversation) writeStabilize(ctx context.Context, messageParts ...MessagePart) error {
screenBeforeMessage := c.cfg.AgentIO.ReadScreen()
for _, part := range messageParts {
if err := part.Do(c.cfg.AgentIO); err != nil {
return xerrors.Errorf("failed to write message part: %w", err)
}
}
// wait for the screen to stabilize after the message is written
// Phase 1: wait for the screen to stabilize after the
// message is written (echo detection).
if err := util.WaitFor(ctx, util.WaitTimeout{
Timeout: 15 * time.Second,
Timeout: writeStabilizeEchoTimeout,
MinInterval: 50 * time.Millisecond,
InitialWait: true,
Clock: c.cfg.Clock,
Expand All @@ -441,14 +475,26 @@ func (c *PTYConversation) writeStabilize(ctx context.Context, messageParts ...Me
}
return false, nil
}); err != nil {
return xerrors.Errorf("failed to wait for screen to stabilize: %w", err)
}

// wait for the screen to change after the carriage return is written
if !errors.Is(err, util.WaitTimedOut) {
// Context cancellation or condition errors are fatal.
return xerrors.Errorf("failed to wait for screen to stabilize: %w", err)
}
// Phase 1 timeout is non-fatal: the agent may not echo
// input (e.g. TUI agents buffer bracketed-paste content
// internally). Proceed to Phase 2 to send the carriage
// return.
c.cfg.Logger.Info(
"echo detection timed out, sending carriage return",
"timeout", writeStabilizeEchoTimeout,
)
}

// Phase 2: wait for the screen to change after the
// carriage return is written (processing detection).
screenBeforeCarriageReturn := c.cfg.AgentIO.ReadScreen()
lastCarriageReturnTime := time.Time{}
if err := util.WaitFor(ctx, util.WaitTimeout{
Timeout: 15 * time.Second,
Timeout: writeStabilizeProcessTimeout,
MinInterval: 25 * time.Millisecond,
Clock: c.cfg.Clock,
}, func() (bool, error) {
Expand Down Expand Up @@ -527,6 +573,14 @@ func (c *PTYConversation) statusLocked() ConversationStatus {
return ConversationStatusChanging
}

// The send loop gates stableSignal on initialPromptReady.
// Report "changing" until readiness is detected so that Send()
// rejects with ErrMessageValidationChanging instead of blocking
// indefinitely on a stableSignal that will never fire.
if !c.initialPromptReady {
return ConversationStatusChanging
}

// Handle initial prompt readiness: report "changing" until the queue is drained
// to avoid the status flipping "changing" -> "stable" -> "changing"
if len(c.outboundQueue) > 0 || c.sendingMessage {
Expand Down
Loading
Loading