Files
cline/cli/pkg/cli/task/manager.go
T
2026-01-07 16:11:44 -08:00

1395 lines
41 KiB
Go

package task
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"os/signal"
"sync"
"syscall"
"time"
"github.com/cline/cli/pkg/cli/display"
"github.com/cline/cli/pkg/cli/global"
"github.com/cline/cli/pkg/cli/handlers"
"github.com/cline/cli/pkg/cli/slash"
"github.com/cline/cli/pkg/cli/types"
"github.com/cline/grpc-go/client"
"github.com/cline/grpc-go/cline"
)
// Sentinel errors for CheckSendEnabled
var (
ErrNoActiveTask = fmt.Errorf("no active task")
ErrTaskBusy = fmt.Errorf("task is currently busy")
)
// Manager handles task execution and message display
type Manager struct {
mu sync.RWMutex
client *client.ClineClient
clientAddress string
state *types.ConversationState
renderer *display.Renderer
hookRenderer *display.HookRenderer
toolRenderer *display.ToolRenderer
systemRenderer *display.SystemMessageRenderer
streamingDisplay *display.StreamingDisplay
handlerRegistry *handlers.HandlerRegistry
slashRegistry *slash.Registry
isStreamingMode bool
isInteractive bool
currentMode string // "plan" or "act"
}
// NewManager creates a new task manager
func NewManager(client *client.ClineClient) *Manager {
state := types.NewConversationState()
renderer := display.NewRenderer(global.Config.OutputFormat)
toolRenderer := display.NewToolRenderer(renderer.GetMdRenderer(), global.Config.OutputFormat)
hookRenderer := display.NewHookRenderer(renderer.GetMdRenderer(), global.Config.OutputFormat)
systemRenderer := display.NewSystemMessageRenderer(renderer, renderer.GetMdRenderer(), global.Config.OutputFormat)
streamingDisplay := display.NewStreamingDisplay(state, renderer)
// Create handler registry and register handlers
registry := handlers.NewHandlerRegistry()
registry.Register(handlers.NewAskHandler())
registry.Register(handlers.NewSayHandler())
return &Manager{
client: client,
clientAddress: "", // Will be set when client is provided
state: state,
renderer: renderer,
hookRenderer: hookRenderer,
toolRenderer: toolRenderer,
systemRenderer: systemRenderer,
streamingDisplay: streamingDisplay,
handlerRegistry: registry,
slashRegistry: slash.NewRegistry(),
currentMode: "plan", // Default mode
}
}
// NewManagerForAddress creates a new task manager for a specific instance address
func NewManagerForAddress(ctx context.Context, address string) (*Manager, error) {
client, err := global.GetClientForAddress(ctx, address)
if err != nil {
return nil, fmt.Errorf("failed to get client for address %s: %w", address, err)
}
manager := NewManager(client)
manager.clientAddress = address
// Fetch slash commands from backend (non-blocking, errors are logged)
manager.fetchSlashCommands(ctx)
return manager, nil
}
// NewManagerForDefault creates a new task manager using the default instance
func NewManagerForDefault(ctx context.Context) (*Manager, error) {
client, err := global.GetDefaultClient(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get default client: %w", err)
}
manager := NewManager(client)
// Get the default instance address
if global.Clients != nil {
manager.clientAddress = global.Clients.GetRegistry().GetDefaultInstance()
}
// Fetch slash commands from backend (non-blocking, errors are logged)
manager.fetchSlashCommands(ctx)
return manager, nil
}
// fetchSlashCommands fetches available slash commands from the backend
// This is non-blocking and errors are logged but don't prevent manager creation
func (m *Manager) fetchSlashCommands(ctx context.Context) {
if err := m.slashRegistry.FetchFromBackend(ctx, m.client); err != nil {
if global.Config.Verbose {
m.renderer.RenderDebug("Failed to fetch slash commands: %v", err)
}
// Non-fatal: CLI-local commands are still available
} else if global.Config.Verbose {
m.renderer.RenderDebug("Loaded %d slash commands", len(m.slashRegistry.GetCommands()))
}
}
// SwitchToInstance switches the manager to use a different Cline instance
func (m *Manager) SwitchToInstance(ctx context.Context, address string) error {
m.mu.Lock()
defer m.mu.Unlock()
// Get client for the new address
newClient, err := global.GetClientForAddress(ctx, address)
if err != nil {
return fmt.Errorf("failed to get client for address %s: %w", address, err)
}
// Update the client and address
m.client = newClient
m.clientAddress = address
if global.Config.Verbose {
m.renderer.RenderDebug("Switched to instance: %s", address)
}
return nil
}
// GetCurrentInstance returns the address of the current instance
func (m *Manager) GetCurrentInstance() string {
m.mu.RLock()
defer m.mu.RUnlock()
return m.clientAddress
}
// CreateTask creates a new task
func (m *Manager) CreateTask(ctx context.Context, prompt string, images, files []string, settingsFlags []string) (string, error) {
m.mu.Lock()
defer m.mu.Unlock()
if global.Config.Verbose {
m.renderer.RenderDebug("Creating task: %s", prompt)
if len(files) > 0 {
m.renderer.RenderDebug("Files: %v", files)
}
if len(images) > 0 {
m.renderer.RenderDebug("Images: %v", images)
}
if len(settingsFlags) > 0 {
m.renderer.RenderDebug("Settings: %v", settingsFlags)
}
}
// Check if there's an active task and cancel it first
if err := m.cancelExistingTaskIfNeeded(ctx); err != nil {
return "", fmt.Errorf("failed to cancel existing task: %w", err)
}
// Parse task settings if provided
var taskSettings *cline.Settings
if len(settingsFlags) > 0 {
var err error
taskSettings, _, err = ParseTaskSettings(settingsFlags)
if err != nil {
return "", fmt.Errorf("failed to parse task settings: %w", err)
}
}
// Create task request
req := &cline.NewTaskRequest{
Text: prompt,
Images: images,
Files: files,
TaskSettings: taskSettings,
}
resp, err := m.client.Task.NewTask(ctx, req)
if err != nil {
return "", fmt.Errorf("failed to create task: %w", err)
}
taskID := resp.Value
return taskID, nil
}
// cancelExistingTaskIfNeeded checks if there's an active task and cancels it
func (m *Manager) cancelExistingTaskIfNeeded(ctx context.Context) error {
// Try to get the current state to check if there's an active task
state, err := m.client.State.GetLatestState(ctx, &cline.EmptyRequest{})
if err != nil {
// If we can't get state, assume no active task and continue
if global.Config.Verbose {
m.renderer.RenderDebug("Could not get state to check for active task: %v", err)
}
return nil
}
// Properly parse the state to check if there's actually an active task
if state.StateJson != "" {
var stateData types.ExtensionState
if err := json.Unmarshal([]byte(state.StateJson), &stateData); err != nil {
// If we can't parse state, assume no active task
if global.Config.Verbose {
m.renderer.RenderDebug("Could not parse state JSON: %v", err)
}
return nil
}
// Check if there's actually an active task
if stateData.CurrentTaskItem != nil && stateData.CurrentTaskItem.Id != "" {
if global.Config.Verbose {
m.renderer.RenderDebug("Found active task %s, cancelling...", stateData.CurrentTaskItem.Id)
}
// Cancel the existing task
_, err := m.client.Task.CancelTask(ctx, &cline.EmptyRequest{})
if err != nil {
if global.Config.Verbose {
m.renderer.RenderDebug("Cancel task returned error: %v", err)
}
} else {
fmt.Println("Cancelled existing task to start new one")
}
}
}
return nil
}
// ValidateCheckpointExists checks if a checkpoint ID is valid
func (m *Manager) ValidateCheckpointExists(ctx context.Context, checkpointID int64) error {
// Get current state
state, err := m.client.State.GetLatestState(ctx, &cline.EmptyRequest{})
if err != nil {
return fmt.Errorf("failed to get state: %w", err)
}
// Extract messages
messages, err := m.extractMessagesFromState(state.StateJson)
if err != nil {
return fmt.Errorf("failed to extract messages: %w", err)
}
// Find and validate the checkpoint message
for _, msg := range messages {
if msg.Timestamp == checkpointID {
if msg.Say != string(types.SayTypeCheckpointCreated) {
return fmt.Errorf("timestamp %d is not a checkpoint (type: %s)", checkpointID, msg.Type)
}
return nil // Valid checkpoint
}
}
return fmt.Errorf("checkpoint ID %d not found in task history", checkpointID)
}
// CheckSendEnabled checks if we can send a message to the current task
// Returns nil if sending is allowed, or an error indicating why it's not allowed
// We duplicate the logic from buttonConfig::getButtonConfig
func (m *Manager) CheckSendEnabled(ctx context.Context) error {
state, err := m.client.State.GetLatestState(ctx, &cline.EmptyRequest{})
if err != nil {
return fmt.Errorf("failed to get latest state: %w", err)
}
var stateData types.ExtensionState
if err := json.Unmarshal([]byte(state.StateJson), &stateData); err != nil {
return fmt.Errorf("failed to parse state: %w", err)
}
// Check if there is an active task
if stateData.CurrentTaskItem == nil {
return ErrNoActiveTask
}
messages, err := m.extractMessagesFromState(state.StateJson)
if err != nil {
return fmt.Errorf("failed to extract messages: %w", err)
}
if len(messages) == 0 {
return nil
}
// Use final message to perform validation
lastMessage := messages[len(messages)-1]
// Error types which we allow sending on
errorTypes := []string{
string(types.AskTypeAPIReqFailed), // "api_req_failed"
string(types.AskTypeMistakeLimitReached), // "mistake_limit_reached"
}
isError := false
// Check if message is an error type
if lastMessage.Type == types.MessageTypeAsk {
for _, errType := range errorTypes {
if lastMessage.Ask == errType {
isError = true
break
}
}
}
// Streaming and error check
if lastMessage.Partial && !isError {
if global.Config.Verbose {
m.renderer.RenderDebug("Send disabled: task is streaming and non-error")
}
return ErrTaskBusy
}
// All ask messages allow sending, EXCEPT command_output
if lastMessage.Type == types.MessageTypeAsk {
// Special case: command_output means command is actively streaming
// In the CLI, we don't want to show input during streaming output (too messy)
// The webview can show "Proceed While Running" button, but CLI should wait
if lastMessage.Ask == string(types.AskTypeCommandOutput) {
if global.Config.Verbose {
m.renderer.RenderDebug("Send disabled: command output is streaming")
}
return ErrTaskBusy
}
if global.Config.Verbose {
m.renderer.RenderDebug("Send enabled: ask message")
}
return nil
}
// Technically unnecessary but implements getButtonConfig 1-1
if lastMessage.Type == types.MessageTypeSay && lastMessage.Say == string(types.SayTypeAPIReqStarted) {
if global.Config.Verbose {
m.renderer.RenderDebug("Send disabled: API request is active")
}
return ErrTaskBusy
}
if global.Config.Verbose {
m.renderer.RenderDebug("Send disabled: default fallback")
}
return ErrTaskBusy
}
// CheckNeedsApproval determines if the current task is waiting for approval
// Returns (needsApproval, lastMessage, error)
func (m *Manager) CheckNeedsApproval(ctx context.Context) (bool, *types.ClineMessage, error) {
state, err := m.client.State.GetLatestState(ctx, &cline.EmptyRequest{})
if err != nil {
return false, nil, fmt.Errorf("failed to get latest state: %w", err)
}
messages, err := m.extractMessagesFromState(state.StateJson)
if err != nil {
return false, nil, fmt.Errorf("failed to extract messages: %w", err)
}
if len(messages) == 0 {
return false, nil, nil
}
// Use final message to check if approval is needed
lastMessage := messages[len(messages)-1]
// Only check non-partial ask messages
if lastMessage.Partial {
return false, nil, nil
}
// Check if this is an approval-required ask type
if lastMessage.Type == types.MessageTypeAsk {
approvalTypes := []string{
string(types.AskTypeTool),
string(types.AskTypeCommand),
string(types.AskTypeBrowserActionLaunch),
string(types.AskTypeUseMcpServer),
}
for _, approvalType := range approvalTypes {
if lastMessage.Ask == approvalType {
return true, lastMessage, nil
}
}
}
return false, nil, nil
}
// SendMessage sends a followup message to the current task
func (m *Manager) SendMessage(ctx context.Context, message string, images, files []string, approve string) error {
responseType := "messageResponse"
if approve == "true" {
responseType = "yesButtonClicked"
}
if approve == "false" {
responseType = "noButtonClicked"
}
if global.Config.Verbose {
m.renderer.RenderDebug("Sending message: %s", message)
if len(files) > 0 {
m.renderer.RenderDebug("Files: %v", files)
}
if len(images) > 0 {
m.renderer.RenderDebug("Images: %v", images)
}
}
// Send the followup message using AskResponse
req := &cline.AskResponseRequest{
ResponseType: responseType,
Text: message,
Images: images,
Files: files,
}
_, err := m.client.Task.AskResponse(ctx, req)
if err != nil {
return fmt.Errorf("failed to send message: %w", err)
}
return nil
}
// SetMode sets the Plan/Act mode for the current Cline instance and optionally sends message
func (m *Manager) SetMode(ctx context.Context, mode string, message *string, images, files []string) error {
if mode != "act" && mode != "plan" {
return fmt.Errorf("invalid mode '%s': must be 'act' or 'plan'", mode)
}
var protoMode cline.PlanActMode
if mode == "plan" {
protoMode = cline.PlanActMode_PLAN
} else {
protoMode = cline.PlanActMode_ACT
}
req := &cline.TogglePlanActModeRequest{
Metadata: &cline.Metadata{},
Mode: protoMode,
}
if message != nil {
req.ChatContent = &cline.ChatContent{
Message: message,
Images: images,
Files: files,
}
}
_, err := m.client.State.TogglePlanActModeProto(ctx, req)
if err != nil {
return fmt.Errorf("failed to set mode to '%s': %w", mode, err)
}
return nil
}
// SetModeAndSendMessage sets the mode and sends a message in one operation
// Handles task restoration internally if the mode switch cancels the current task
func (m *Manager) SetModeAndSendMessage(ctx context.Context, mode, message string, images, files []string) error {
if mode != "act" && mode != "plan" {
return fmt.Errorf("invalid mode '%s': must be 'act' or 'plan'", mode)
}
taskId, err := m.getCurrentTaskId(ctx)
if err != nil {
return fmt.Errorf("failed to get current task ID: %w", err)
}
fmt.Printf("Current task ID: %s\n", taskId)
var protoMode cline.PlanActMode
if mode == "plan" {
protoMode = cline.PlanActMode_PLAN
} else {
protoMode = cline.PlanActMode_ACT
}
req := &cline.TogglePlanActModeRequest{
Metadata: &cline.Metadata{},
Mode: protoMode,
ChatContent: &cline.ChatContent{
Message: &message,
Images: images,
Files: files,
},
}
result, err := m.client.State.TogglePlanActModeProto(ctx, req)
if err != nil {
return fmt.Errorf("failed to set mode to '%s': %w", mode, err)
}
taskPreserved := result.Value
if taskPreserved {
fmt.Printf("Message sent as part of mode change\n")
return nil
} else {
if message != "" || len(images) > 0 || len(files) > 0 {
fmt.Printf("Task was cancelled, restoring task ID: %s\n", taskId)
err = m.ReinitExistingTaskFromId(ctx, taskId)
if err != nil {
return fmt.Errorf("Failed to restore task: %w", err)
}
fmt.Printf("Task restored successfully\n")
// Hardcoded sleep should be replaced with a way to fetch whether task is ready algorithmically
time.Sleep(1 * time.Second)
err = m.SendMessage(ctx, message, images, files, "")
if err != nil {
return fmt.Errorf("Failed to send message: %w", err)
}
fmt.Printf("Message sent to restored task\n")
}
}
return nil
}
// getCurrentTaskId extracts the current task ID from the server state
func (m *Manager) getCurrentTaskId(ctx context.Context) (string, error) {
// Get the latest state
state, err := m.client.State.GetLatestState(ctx, &cline.EmptyRequest{})
if err != nil {
return "", fmt.Errorf("failed to get state: %w", err)
}
// Parse the server state JSON
var stateData types.ExtensionState
if err := json.Unmarshal([]byte(state.StateJson), &stateData); err != nil {
return "", fmt.Errorf("failed to parse state JSON: %w", err)
}
// Extract current task ID
if stateData.CurrentTaskItem != nil && stateData.CurrentTaskItem.Id != "" {
return stateData.CurrentTaskItem.Id, nil
}
return "", fmt.Errorf("no current task found in state")
}
// ReinitExistingTaskFromId reinitializes an existing task from the given task ID
func (m *Manager) ReinitExistingTaskFromId(ctx context.Context, taskId string) error {
req := &cline.StringRequest{Value: taskId}
resp, err := m.client.Task.ShowTaskWithId(ctx, req)
if err != nil {
return fmt.Errorf("Failed to reinitialize task %s: %w", taskId, err)
}
fmt.Printf("Successfully reinitialized task: %s (ID: %s)\n", taskId, resp.Id)
return nil
}
// ResumeTask resumes an existing task by ID
func (m *Manager) ResumeTask(ctx context.Context, taskID string) error {
m.mu.Lock()
defer m.mu.Unlock()
if global.Config.Verbose {
m.renderer.RenderDebug("Resuming task: %s", taskID)
}
// This call handles cancellation of any active task
if err := m.ReinitExistingTaskFromId(ctx, taskID); err != nil {
return fmt.Errorf("failed to resume task %s: %w", taskID, err)
}
fmt.Printf("Task %s resumed successfully\n", taskID)
return nil
}
// RestoreCheckpoint restores the task to a specific checkpoint
func (m *Manager) RestoreCheckpoint(ctx context.Context, checkpointID int64, restoreType string) error {
if global.Config.Verbose {
m.renderer.RenderDebug("Restoring checkpoint: %d (type: %s)", checkpointID, restoreType)
}
// Create the checkpoint restore request
req := &cline.CheckpointRestoreRequest{
Metadata: &cline.Metadata{},
Number: checkpointID,
RestoreType: restoreType,
}
_, err := m.client.Checkpoints.CheckpointRestore(ctx, req)
if err != nil {
return fmt.Errorf("failed to restore checkpoint %d: %w", checkpointID, err)
}
return nil
}
// CancelTask cancels the current task
func (m *Manager) CancelTask(ctx context.Context) error {
m.mu.Lock()
defer m.mu.Unlock()
_, err := m.client.Task.CancelTask(ctx, &cline.EmptyRequest{})
if err != nil {
return fmt.Errorf("failed to cancel task: %w", err)
}
return nil
}
// ShowConversation displays the current conversation
func (m *Manager) ShowConversation(ctx context.Context) error {
// Check if there's an active task before showing conversation
err := m.CheckSendEnabled(ctx)
if err != nil {
// Handle specific error cases
if errors.Is(err, ErrNoActiveTask) {
fmt.Println("No active task found. Use 'cline task new' to create a task first.")
return nil
}
// For other errors (like task busy), we can still show the conversation
}
// Disable streaming mode for static view
m.mu.Lock()
m.isStreamingMode = false
m.mu.Unlock()
m.mu.RLock()
defer m.mu.RUnlock()
// Get the latest state which contains messages
state, err := m.client.State.GetLatestState(ctx, &cline.EmptyRequest{})
if err != nil {
return fmt.Errorf("failed to get state: %w", err)
}
// Parse the state JSON to extract messages
messages, err := m.extractMessagesFromState(state.StateJson)
if err != nil {
return fmt.Errorf("failed to extract messages: %w", err)
}
if len(messages) == 0 {
fmt.Println("No conversation history found.")
return nil
}
for i, msg := range messages {
if msg.Partial {
continue
}
m.displayMessage(msg, false, false, i)
}
return nil
}
func (m *Manager) FollowConversation(ctx context.Context, instanceAddress string, interactive bool) error {
// Enable streaming mode
m.mu.Lock()
m.isStreamingMode = true
m.isInteractive = interactive
m.mu.Unlock()
if global.Config.OutputFormat != "plain" {
markdown := fmt.Sprintf("*Using instance: %s*\n*Press Ctrl+C to exit*", instanceAddress)
rendered := m.renderer.RenderMarkdown(markdown)
fmt.Printf("%s", rendered)
} else {
fmt.Printf("Using instance: %s\n", instanceAddress)
if interactive {
fmt.Println("Following task conversation in interactive mode... (Press Ctrl+C to exit)")
} else {
fmt.Println("Following task conversation... (Press Ctrl+C to exit)")
}
}
ctx, cancel := context.WithCancel(ctx)
defer cancel()
// Create stream coordinator
coordinator := NewStreamCoordinator()
// Load history first
totalMessageCount, err := m.loadAndDisplayRecentHistory(ctx)
if err != nil {
m.renderer.RenderDebug("Warning: Failed to load conversation history: %v", err)
totalMessageCount = 0
}
coordinator.SetConversationTurnStartIndex(totalMessageCount)
// Start both streams concurrently
errChan := make(chan error, 3)
if global.Config.OutputFormat == "json" {
go m.handleStateStream(ctx, coordinator, errChan, nil)
} else {
go m.handleStateStream(ctx, coordinator, errChan, nil)
go m.handlePartialMessageStream(ctx, coordinator, errChan)
// Start input handler if interactive mode is enabled
if interactive {
inputHandler := NewInputHandler(m, coordinator, cancel)
go inputHandler.Start(ctx, errChan)
}
}
// Handle Ctrl+C signals
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
go func() {
defer signal.Stop(sigChan) // Clean up signal handler when goroutine exits
for {
select {
case <-ctx.Done():
return
case <-sigChan:
if interactive {
// Interactive mode (task chat)
// Check if input is currently being shown
if coordinator.IsInputAllowed() {
// Input form is showing - huh will handle the signal via ErrUserAborted
// Do nothing here, let the input handler deal with it
} else {
// Streaming mode - cancel the task and stay in follow mode
m.renderer.RenderTaskCancelled()
if err := m.CancelTask(context.Background()); err != nil {
fmt.Printf("Error cancelling task: %v\n", err)
}
// Don't cancel main context - stay in follow mode
}
} else {
// Non-interactive mode (task view --follow)
// Just exit without canceling the task
cancel()
return // Exit the loop after canceling in non-interactive mode
}
}
}
}()
// Wait for either stream to error or context cancellation
select {
case <-ctx.Done():
// Check if this was a user-initiated cancellation (Ctrl+C)
// Return nil for clean exit instead of context.Canceled error
if ctx.Err() == context.Canceled {
return nil
}
return ctx.Err()
case err := <-errChan:
cancel()
return err
}
}
// FollowConversationUntilCompletion streams conversation updates until task completion
func (m *Manager) FollowConversationUntilCompletion(ctx context.Context, opts FollowOptions) error {
// Check if there's an active task before entering follow mode
// Skip this check if we just created a task (to avoid race condition where task isn't active yet)
if !opts.SkipActiveTaskCheck {
err := m.CheckSendEnabled(ctx)
if err != nil {
if errors.Is(err, ErrNoActiveTask) {
fmt.Println("No task is currently running.")
return nil
}
// For other errors (like task busy), we can still enter follow mode
// as the user may want to observe the task
}
}
// Enable streaming mode
m.mu.Lock()
m.isStreamingMode = true
m.mu.Unlock()
fmt.Println("Following task conversation until completion... (Press Ctrl+C to exit)")
ctx, cancel := context.WithCancel(ctx)
defer cancel()
// Create stream coordinator
coordinator := NewStreamCoordinator()
// Load history first
totalMessageCount, err := m.loadAndDisplayRecentHistory(ctx)
if err != nil {
m.renderer.RenderDebug("Warning: Failed to load conversation history: %v", err)
totalMessageCount = 0
}
coordinator.SetConversationTurnStartIndex(totalMessageCount)
// Start both streams concurrently
errChan := make(chan error, 2)
completionChan := make(chan bool, 1)
if global.Config.OutputFormat == "json" {
go m.handleStateStream(ctx, coordinator, errChan, completionChan)
} else {
go m.handleStateStream(ctx, coordinator, errChan, completionChan)
go m.handlePartialMessageStream(ctx, coordinator, errChan)
}
// Wait for completion, error, or context cancellation
select {
case <-ctx.Done():
return ctx.Err()
case <-completionChan:
cancel()
return nil
case err := <-errChan:
cancel()
return err
}
}
// handleStateStream handles the SubscribeToState stream
func (m *Manager) handleStateStream(ctx context.Context, coordinator *StreamCoordinator, errChan chan error, completionChan chan bool) {
stateStream, err := m.client.State.SubscribeToState(ctx, &cline.EmptyRequest{})
if err != nil {
errChan <- fmt.Errorf("failed to subscribe to state: %w", err)
return
}
for {
select {
case <-ctx.Done():
return
default:
stateUpdate, err := stateStream.Recv()
if err != nil {
m.renderer.RenderDebug("State stream receive error: %v", err)
errChan <- fmt.Errorf("failed to receive state update: %w", err)
return
}
var pErr error
if global.Config.OutputFormat == "json" {
pErr = m.processStateUpdateJsonMode(stateUpdate, coordinator, completionChan)
} else {
pErr = m.processStateUpdate(stateUpdate, coordinator, completionChan)
}
if pErr != nil {
m.renderer.RenderDebug("State processing error: %v", pErr)
}
}
}
}
func (m *Manager) processStateUpdateJsonMode(stateUpdate *cline.State, coordinator *StreamCoordinator, completionChan chan bool) error {
messages, err := m.extractMessagesFromState(stateUpdate.StateJson)
if err != nil {
return err
}
// Process messages from current conversation turn onwards
startIndex := coordinator.GetConversationTurnStartIndex()
var foundCompletion bool
var displayedUsage bool
for i := startIndex; i < len(messages); i++ {
msg := messages[i]
if global.Config.Verbose {
m.renderer.RenderDebug("State message %d: type=%s, say=%s", i, msg.Type, msg.Say)
}
// Exit after we've seen a task completion & printed out the usage info
if msg.Say == string(types.SayTypeCompletionResult) {
foundCompletion = true
}
// Determine if message is ready to be displayed now
shouldDisplay := true
switch {
case msg.Say == string(types.SayTypeAPIReqStarted):
shouldDisplay = false
apiInfo := types.APIRequestInfo{Cost: -1}
if err := json.Unmarshal([]byte(msg.Text), &apiInfo); err == nil && apiInfo.Cost >= 0 {
shouldDisplay = true
displayedUsage = true
}
}
// Skip if message is partial, except for a specific edge case
if msg.Partial {
// Exception: display if type=say, text="", say="text"
if msg.IsSay() && msg.Text == "" && msg.Say == string(types.SayTypeText) {
shouldDisplay = true
} else {
shouldDisplay = false
}
}
// Display valid messages, exit as soon as we hit a non-valid message
if shouldDisplay {
coordinator.CompleteTurn(i + 1) // Mark the message as complete as soon as we print it
m.displayMessage(msg, false, false, i)
} else {
break
}
}
// We only want to exit after we've displayed the usage, for the case of seeing completion result
if completionChan != nil && foundCompletion && displayedUsage {
completionChan <- true
}
return nil
}
// processStateUpdate processes state updates and supports logic for handling task competion markers
func (m *Manager) processStateUpdate(stateUpdate *cline.State, coordinator *StreamCoordinator, completionChan chan bool) error {
// Update current mode from state
m.updateMode(stateUpdate.StateJson)
messages, err := m.extractMessagesFromState(stateUpdate.StateJson)
if err != nil {
return err
}
// Process messages from current conversation turn onwards
startIndex := coordinator.GetConversationTurnStartIndex()
var foundCompletion bool
var displayedUsage bool
for i := startIndex; i < len(messages); i++ {
msg := messages[i]
if global.Config.Verbose {
m.renderer.RenderDebug("State message %d: type=%s, say=%s", i, msg.Type, msg.Say)
}
// Exit after we've seen a task completion & printed out the usage info
if msg.Say == string(types.SayTypeCompletionResult) {
foundCompletion = true
}
switch {
case msg.Say == string(types.SayTypeUserFeedback):
msgKey := fmt.Sprintf("%d", msg.Timestamp)
if !coordinator.IsProcessedInCurrentTurn(msgKey) {
fmt.Println()
m.displayMessage(msg, false, false, i)
coordinator.MarkProcessedInCurrentTurn(msgKey)
}
case msg.Say == string(types.SayTypeCommand):
msgKey := fmt.Sprintf("%d", msg.Timestamp)
if !coordinator.IsProcessedInCurrentTurn(msgKey) {
fmt.Println()
m.displayMessage(msg, false, false, i)
coordinator.MarkProcessedInCurrentTurn(msgKey)
}
case msg.Say == string(types.SayTypeCommandOutput):
msgKey := fmt.Sprintf("%d", msg.Timestamp)
if !coordinator.IsProcessedInCurrentTurn(msgKey) {
m.displayMessage(msg, false, false, i)
coordinator.MarkProcessedInCurrentTurn(msgKey)
}
case msg.Say == string(types.SayTypeCommandPermissionDenied):
msgKey := fmt.Sprintf("%d", msg.Timestamp)
if !coordinator.IsProcessedInCurrentTurn(msgKey) {
fmt.Println()
m.displayMessage(msg, false, false, i)
coordinator.MarkProcessedInCurrentTurn(msgKey)
}
case msg.Say == string(types.SayTypeBrowserActionLaunch):
msgKey := fmt.Sprintf("%d", msg.Timestamp)
if !coordinator.IsProcessedInCurrentTurn(msgKey) {
fmt.Println()
m.displayMessage(msg, false, false, i)
coordinator.MarkProcessedInCurrentTurn(msgKey)
}
case msg.Say == string(types.SayTypeMcpServerRequestStarted):
msgKey := fmt.Sprintf("%d", msg.Timestamp)
if !coordinator.IsProcessedInCurrentTurn(msgKey) {
fmt.Println()
m.displayMessage(msg, false, false, i)
coordinator.MarkProcessedInCurrentTurn(msgKey)
}
case msg.Say == string(types.SayTypeMcpServerResponse):
msgKey := fmt.Sprintf("%d", msg.Timestamp)
if !coordinator.IsProcessedInCurrentTurn(msgKey) {
fmt.Println()
m.displayMessage(msg, false, false, i)
coordinator.MarkProcessedInCurrentTurn(msgKey)
}
case msg.Say == string(types.SayTypeMcpNotification):
msgKey := fmt.Sprintf("%d", msg.Timestamp)
if !coordinator.IsProcessedInCurrentTurn(msgKey) {
fmt.Println()
m.displayMessage(msg, false, false, i)
coordinator.MarkProcessedInCurrentTurn(msgKey)
}
case msg.Say == string(types.SayTypeUseMcpServer):
msgKey := fmt.Sprintf("%d", msg.Timestamp)
if !coordinator.IsProcessedInCurrentTurn(msgKey) {
fmt.Println()
m.displayMessage(msg, false, false, i)
coordinator.MarkProcessedInCurrentTurn(msgKey)
}
case msg.Say == string(types.SayTypeCheckpointCreated):
msgKey := fmt.Sprintf("%d", msg.Timestamp)
if !coordinator.IsProcessedInCurrentTurn(msgKey) {
fmt.Println()
m.displayMessage(msg, false, false, i)
coordinator.MarkProcessedInCurrentTurn(msgKey)
}
case msg.Say == string(types.SayTypeHookStatus):
msgKey := fmt.Sprintf("%d", msg.Timestamp)
if !coordinator.IsProcessedInCurrentTurn(msgKey) {
fmt.Println()
m.displayMessage(msg, false, false, i)
coordinator.MarkProcessedInCurrentTurn(msgKey)
}
case msg.Say == string(types.SayTypeHookOutputStream):
// Hook stdout/stderr streaming arrives as hook_output_stream messages.
// These are intentionally suppressed unless verbose (see SayHandler.handleHookOutputStream),
// but we still need to route them through the normal handler pipeline in streaming/follow
// mode so verbose users actually see `HOOK> ...` lines.
msgKey := fmt.Sprintf("%d", msg.Timestamp)
if !coordinator.IsProcessedInCurrentTurn(msgKey) {
m.displayMessage(msg, false, false, i)
coordinator.MarkProcessedInCurrentTurn(msgKey)
}
case msg.Say == string(types.SayTypeAPIReqStarted):
msgKey := fmt.Sprintf("%d", msg.Timestamp)
apiInfo := types.APIRequestInfo{Cost: -1}
if err := json.Unmarshal([]byte(msg.Text), &apiInfo); err == nil && apiInfo.Cost >= 0 {
if !coordinator.IsProcessedInCurrentTurn(msgKey) {
fmt.Println() // adds a separator between cline message and usage message
m.displayMessage(msg, false, false, i)
coordinator.MarkProcessedInCurrentTurn(msgKey)
coordinator.CompleteTurn(len(messages))
displayedUsage = true
}
}
case msg.Say == string(types.SayTypeCompletionResult):
msgKey := fmt.Sprintf("%d", msg.Timestamp)
if !msg.Partial && !coordinator.IsProcessedInCurrentTurn(msgKey) {
fmt.Println()
m.displayMessage(msg, false, false, i)
coordinator.MarkProcessedInCurrentTurn(msgKey)
}
case msg.Ask == string(types.AskTypeCommandOutput):
msgKey := fmt.Sprintf("%d", msg.Timestamp)
if !coordinator.IsProcessedInCurrentTurn(msgKey) {
m.displayMessage(msg, false, false, i)
coordinator.MarkProcessedInCurrentTurn(msgKey)
}
case msg.Ask == string(types.AskTypePlanModeRespond):
msgKey := fmt.Sprintf("%d", msg.Timestamp)
// Non-streaming mode: render normally when message is complete
if !msg.Partial && !coordinator.IsProcessedInCurrentTurn(msgKey) {
m.displayMessage(msg, false, false, i)
coordinator.MarkProcessedInCurrentTurn(msgKey)
}
case msg.Type == types.MessageTypeAsk:
msgKey := fmt.Sprintf("%d", msg.Timestamp)
// Only render if not already handled by partial stream
if !msg.Partial && !coordinator.IsProcessedInCurrentTurn(msgKey) {
m.displayMessage(msg, false, false, i)
coordinator.MarkProcessedInCurrentTurn(msgKey)
}
}
}
// We only want to exit after we've displayed the usage, for the case of seeing completion result
if completionChan != nil && foundCompletion && displayedUsage {
completionChan <- true
}
return nil
}
// handlePartialMessageStream handles the SubscribeToPartialMessage stream for streaming assistant text
func (m *Manager) handlePartialMessageStream(ctx context.Context, coordinator *StreamCoordinator, errChan chan error) {
partialStream, err := m.client.Ui.SubscribeToPartialMessage(ctx, &cline.EmptyRequest{})
if err != nil {
errChan <- fmt.Errorf("failed to subscribe to partial messages: %w", err)
return
}
defer func() {
m.streamingDisplay.FreezeActiveSegment()
}()
for {
select {
case <-ctx.Done():
return
default:
protoMsg, err := partialStream.Recv()
if err != nil {
m.renderer.RenderDebug("Partial stream receive error: %v", err)
errChan <- fmt.Errorf("failed to receive partial message: %w", err)
return
}
// Convert proto message to our Message struct
msg := types.ConvertProtoToMessage(protoMsg)
// Debug: Log received message (always show for debugging)
m.renderer.RenderDebug("Received streaming message: type=%s, partial=%v, text_len=%d",
msg.Type, msg.Partial, len(msg.Text))
// Handle the message with streaming support for de-dupping
if err := m.handleStreamingMessage(msg, coordinator); err != nil {
m.renderer.RenderDebug("Error handling streaming message: %v", err)
}
}
}
}
// handleStreamingMessage handles a streaming message
func (m *Manager) handleStreamingMessage(msg *types.ClineMessage, coordinator *StreamCoordinator) error {
// Debug: Always log what we're processing
m.renderer.RenderDebug("Processing message: timestamp=%d, partial=%v, type=%s, text_preview=%s",
msg.Timestamp, msg.Partial, msg.Type, m.truncateText(msg.Text, 50))
// Use streaming display which handles deduplication internally
if err := m.streamingDisplay.HandlePartialMessage(msg); err != nil {
m.renderer.RenderDebug("Streaming display failed, using fallback: %v", err)
// Fallback to regular display
m.displayMessage(msg, true, false, -1)
}
return nil
}
// truncateText truncates text for debug display
func (m *Manager) truncateText(text string, maxLen int) string {
if len(text) <= maxLen {
return text
}
return text[:maxLen] + "..."
}
// displayMessage displays a single message using the handler system
func (m *Manager) displayMessage(msg *types.ClineMessage, isLast, isPartial bool, messageIndex int) error {
if global.Config.OutputFormat == "json" {
return m.outputMessageAsJSON(msg)
} else {
m.mu.RLock()
isStreaming := m.isStreamingMode
isInteractive := m.isInteractive
m.mu.RUnlock()
dc := &handlers.DisplayContext{
State: m.state,
Renderer: m.renderer,
ToolRenderer: m.toolRenderer,
HookRenderer: m.hookRenderer,
SystemRenderer: m.systemRenderer,
IsLast: isLast,
IsPartial: isPartial,
Verbose: global.Config.Verbose,
MessageIndex: messageIndex,
IsStreamingMode: isStreaming,
IsInteractive: isInteractive,
}
return m.handlerRegistry.Handle(msg, dc)
}
}
// outputMessageAsJSON prints a single cline message as json
func (m *Manager) outputMessageAsJSON(msg *types.ClineMessage) error {
jsonBytes, err := json.MarshalIndent(msg, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal message as JSON: %w", err)
}
fmt.Println(string(jsonBytes))
return nil
}
// loadAndDisplayRecentHistory loads and displays recent conversation history and returns the total number of existing messages
func (m *Manager) loadAndDisplayRecentHistory(ctx context.Context) (int, error) {
// Get the latest state which contains messages
state, err := m.client.State.GetLatestState(ctx, &cline.EmptyRequest{})
if err != nil {
return 0, fmt.Errorf("failed to get state: %w", err)
}
// Parse the state JSON to extract messages
messages, err := m.extractMessagesFromState(state.StateJson)
if err != nil {
return 0, fmt.Errorf("failed to extract messages: %w", err)
}
if len(messages) == 0 {
fmt.Println("No conversation history found.")
return 0, nil
}
// Show only the last 100 messages by default
const maxHistoryMessages = 100
totalMessages := len(messages)
startIndex := 0
if totalMessages > maxHistoryMessages {
startIndex = totalMessages - maxHistoryMessages
if global.Config.OutputFormat != "plain" {
markdown := fmt.Sprintf("*Conversation history (%d of %d messages)*", maxHistoryMessages, totalMessages)
rendered := m.renderer.RenderMarkdown(markdown)
fmt.Printf("\n%s\n\n", rendered)
} else {
fmt.Printf("--- Conversation history (%d of %d messages) ---\n", maxHistoryMessages, totalMessages)
}
} else {
if global.Config.OutputFormat != "plain" {
markdown := fmt.Sprintf("*Conversation history (%d messages)*", totalMessages)
rendered := m.renderer.RenderMarkdown(markdown)
fmt.Printf("\n%s\n\n", rendered)
} else {
fmt.Printf("--- Conversation history (%d messages) ---\n", totalMessages)
}
}
for i := startIndex; i < len(messages); i++ {
msg := messages[i]
if msg.Partial {
continue
}
m.displayMessage(msg, false, false, i)
}
// Return the total number of messages in the conversation
return totalMessages, nil
}
// extractMessagesFromState parses the state JSON and extracts messages
func (m *Manager) extractMessagesFromState(stateJson string) ([]*types.ClineMessage, error) {
return types.ExtractMessagesFromStateJSON(stateJson)
}
// GetState returns the current conversation state
func (m *Manager) GetState() *types.ConversationState {
return m.state
}
// GetClient returns the underlying ClineClient for direct gRPC calls
func (m *Manager) GetClient() *client.ClineClient {
m.mu.RLock()
defer m.mu.RUnlock()
return m.client
}
// GetRenderer returns the renderer for formatting output
func (m *Manager) GetRenderer() *display.Renderer {
return m.renderer
}
// GetCurrentMode returns the current plan/act mode
func (m *Manager) GetCurrentMode() string {
m.mu.RLock()
defer m.mu.RUnlock()
return m.currentMode
}
// GetSlashRegistry returns the slash command registry
func (m *Manager) GetSlashRegistry() *slash.Registry {
return m.slashRegistry
}
// extractModeFromState extracts the current mode from state JSON
func (m *Manager) extractModeFromState(stateJson string) string {
var rawState map[string]interface{}
if err := json.Unmarshal([]byte(stateJson), &rawState); err != nil {
return m.currentMode // Return current mode if parsing fails
}
if mode, ok := rawState["mode"].(string); ok {
return mode
}
return m.currentMode // Return current mode if not found in state
}
// updateMode updates the current mode from state
func (m *Manager) updateMode(stateJson string) {
mode := m.extractModeFromState(stateJson)
m.mu.Lock()
m.currentMode = mode
m.mu.Unlock()
}
// UpdateTaskAutoApprovalAction enables a specific auto-approval action for the current task
func (m *Manager) UpdateTaskAutoApprovalAction(ctx context.Context, actionKey string) error {
boolPtr := func(b bool) *bool { return &b }
settings := &cline.Settings{
AutoApprovalSettings: &cline.AutoApprovalSettings{
Actions: &cline.AutoApprovalActions{},
},
}
// Set the specific action to true based on actionKey
truePtr := boolPtr(true)
switch actionKey {
case "read_files":
settings.AutoApprovalSettings.Actions.ReadFiles = truePtr
case "edit_files":
settings.AutoApprovalSettings.Actions.EditFiles = truePtr
case "execute_all_commands":
settings.AutoApprovalSettings.Actions.ExecuteAllCommands = truePtr
case "use_browser":
settings.AutoApprovalSettings.Actions.UseBrowser = truePtr
case "use_mcp":
settings.AutoApprovalSettings.Actions.UseMcp = truePtr
default:
return fmt.Errorf("unknown auto-approval action: %s", actionKey)
}
_, err := m.client.State.UpdateTaskSettings(ctx, &cline.UpdateTaskSettingsRequest{
Settings: settings,
})
if err != nil {
return fmt.Errorf("failed to update task settings: %w", err)
}
return nil
}
// Cleanup cleans up resources
func (m *Manager) Cleanup() {
// Clean up streaming display resources if needed
if m.streamingDisplay != nil {
m.streamingDisplay.Cleanup()
}
}