Compare commits

...

1 Commits

Author SHA1 Message Date
pashpashpash 020b13c87d trying out bubbletea 2025-10-14 22:02:59 -07:00
17 changed files with 688 additions and 511 deletions
+9
View File
@@ -4,8 +4,11 @@ go 1.23.0
require (
github.com/atotto/clipboard v0.1.4
github.com/charmbracelet/bubbles/v2 v2.0.0-00010101000000-000000000000
github.com/charmbracelet/bubbletea/v2 v2.0.0-00010101000000-000000000000
github.com/charmbracelet/glamour v0.10.0
github.com/charmbracelet/huh v0.7.1-0.20251005153135-a01a1e304532
github.com/charmbracelet/lipgloss/v2 v2.0.0-00010101000000-000000000000
github.com/cline/grpc-go v0.0.0
github.com/mattn/go-sqlite3 v1.14.24
github.com/spf13/cobra v1.8.0
@@ -16,6 +19,12 @@ require (
replace github.com/cline/grpc-go => ../src/generated/grpc-go
replace github.com/charmbracelet/bubbletea/v2 => github.com/charmbracelet/bubbletea/v2 v2.0.0-20251011205917-3b687ffc1619
replace github.com/charmbracelet/bubbles/v2 => github.com/charmbracelet/bubbles/v2 v2.0.0-20251001202932-f03bfcc799df
replace github.com/charmbracelet/lipgloss/v2 => github.com/charmbracelet/lipgloss/v2 v2.0.0-20250917201909-41ff0bf215ea
require (
github.com/alecthomas/chroma/v2 v2.14.0 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
+2 -3
View File
@@ -1,7 +1,6 @@
package display
import (
"fmt"
"os"
"golang.org/x/term"
@@ -15,7 +14,7 @@ func ClearLine() {
if !isTTY() {
return
}
fmt.Print("\r\033[K")
Print("\r\033[K")
}
// ClearToEnd clears from cursor to end of screen
@@ -23,5 +22,5 @@ func ClearToEnd() {
if !isTTY() {
return
}
fmt.Print("\033[J")
Print("\033[J")
}
+2 -2
View File
@@ -24,8 +24,8 @@ type MarkdownRenderer struct {
// word wrap, the indentation looks weird so you have to turn it off
// and everything will be right next to the left margin
// but if you DO use glamours word wrap, it also means if you resize the terminal,
// it will scuff everything. but given that this is the case for the input anyway,
// i figure we just make things as beautiful as possible
// it will scuff everything. but given that this is the case for the input anyway,
// i figure we just make things as beautiful as possible
// and if you resize the terminal, you'll learn real quick.
// anyway, you can set this to true or false to experiment
const USETERMINALWORDWRAP = true
+73
View File
@@ -0,0 +1,73 @@
package display
import (
"fmt"
tea "github.com/charmbracelet/bubbletea/v2"
)
// OutputWriter is the interface for writing output
type OutputWriter interface {
Printf(format string, args ...interface{})
Print(text string)
}
// Global output writer - defaults to stdout
var globalOutput OutputWriter = &StdoutWriter{}
// SetOutputWriter sets the global output writer
// Should be called once before any concurrent output operations
func SetOutputWriter(w OutputWriter) {
globalOutput = w
}
// Printf writes formatted output using the global output writer
func Printf(format string, args ...interface{}) {
globalOutput.Printf(format, args...)
}
// Print writes text using the global output writer
func Print(text string) {
globalOutput.Print(text)
}
// StdoutWriter writes to stdout using fmt
type StdoutWriter struct{}
func (w *StdoutWriter) Printf(format string, args ...interface{}) {
fmt.Printf(format, args...)
}
func (w *StdoutWriter) Print(text string) {
fmt.Print(text)
}
// PrintMsg is a message type that triggers tea.Printf output
type PrintMsg string
// BubbleTeaWriter writes using tea.Printf for coordination with BubbleTea UI
type BubbleTeaWriter struct {
program *tea.Program
}
func NewBubbleTeaWriter(program *tea.Program) *BubbleTeaWriter {
return &BubbleTeaWriter{program: program}
}
func (w *BubbleTeaWriter) Printf(format string, args ...interface{}) {
if w.program != nil {
text := fmt.Sprintf(format, args...)
w.program.Send(PrintMsg(text))
} else {
// Fallback if program is nil
fmt.Printf(format, args...)
}
}
func (w *BubbleTeaWriter) Print(text string) {
if w.program != nil {
w.program.Send(PrintMsg(text))
} else {
fmt.Print(text)
}
}
+7 -7
View File
@@ -39,9 +39,9 @@ func (r *Renderer) RenderMessage(prefix, text string, newline bool) error {
}
if newline {
fmt.Printf("%s: %s\n", prefix, clean)
Printf("%s: %s\n", prefix, clean)
} else {
fmt.Printf("%s: %s", prefix, clean)
Printf("%s: %s", prefix, clean)
}
return nil
}
@@ -86,12 +86,12 @@ func (r *Renderer) RenderAPI(status string, apiInfo *types.APIRequestInfo) error
usageInfo := r.formatUsageInfo(apiInfo.TokensIn, apiInfo.TokensOut, apiInfo.CacheReads, apiInfo.CacheWrites, apiInfo.Cost)
markdown := fmt.Sprintf("## API %s `%s`", status, usageInfo)
rendered := r.RenderMarkdown(markdown)
fmt.Printf(rendered)
Printf("%s", rendered)
} else {
// honestly i see no point in showing "### API processing request" here...
// markdown := fmt.Sprintf("## API %s", status)
// rendered := r.RenderMarkdown(markdown)
// fmt.Printf("\n%s\n", rendered)
// Printf("\n%s\n", rendered)
}
return nil
}
@@ -109,7 +109,7 @@ func (r *Renderer) RenderRetry(attempt, maxAttempts, delaySec int) error {
func (r *Renderer) RenderTaskCancelled() error {
markdown := "## Task cancelled"
rendered := r.RenderMarkdown(markdown)
fmt.Printf("\n%s\n", rendered)
Printf("\n%s\n", rendered)
return nil
}
@@ -156,11 +156,11 @@ func (r *Renderer) RenderDebug(format string, args ...interface{}) error {
}
func (r *Renderer) ClearLine() {
fmt.Print("\r\033[K")
Print("\r\033[K")
}
func (r *Renderer) MoveCursorUp(n int) {
fmt.Printf("\033[%dA", n)
Printf("\033[%dA", n)
}
func (r *Renderer) sanitizeText(text string) string {
+16 -8
View File
@@ -6,6 +6,7 @@ import (
"strings"
"sync"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/cline/cli/pkg/cli/types"
)
@@ -21,9 +22,10 @@ type StreamingSegment struct {
outputFormat string
msg *types.ClineMessage
toolParser *ToolResultParser
teaProgram *tea.Program
}
func NewStreamingSegment(sayType, prefix string, mdRenderer *MarkdownRenderer, shouldMarkdown bool, msg *types.ClineMessage, outputFormat string) *StreamingSegment {
func NewStreamingSegment(sayType, prefix string, mdRenderer *MarkdownRenderer, shouldMarkdown bool, msg *types.ClineMessage, outputFormat string, teaProgram *tea.Program) *StreamingSegment {
ss := &StreamingSegment{
sayType: sayType,
prefix: prefix,
@@ -33,16 +35,17 @@ func NewStreamingSegment(sayType, prefix string, mdRenderer *MarkdownRenderer, s
outputFormat: outputFormat,
msg: msg,
toolParser: NewToolResultParser(mdRenderer),
teaProgram: teaProgram,
}
// Render rich header immediately when creating segment (if in rich mode)
if shouldMarkdown && outputFormat != "plain" {
header := ss.generateRichHeader()
rendered, _ := mdRenderer.Render(header)
fmt.Println()
fmt.Print(rendered)
ss.printf("\n")
ss.printf("%s", rendered)
}
return ss
}
@@ -136,14 +139,19 @@ func (ss *StreamingSegment) renderFinal(currentBuffer string) {
// Print the body content
if bodyContent != "" {
if !strings.HasSuffix(bodyContent, "\n") {
fmt.Print(bodyContent)
fmt.Println()
ss.printf("%s", bodyContent)
ss.printf("\n")
} else {
fmt.Print(bodyContent)
ss.printf("%s", bodyContent)
}
}
}
// printf outputs text using the global display singleton
func (ss *StreamingSegment) printf(format string, args ...interface{}) {
Printf(format, args...)
}
// generateRichHeader generates a contextual header for the segment
func (ss *StreamingSegment) generateRichHeader() string {
+20 -2
View File
@@ -5,6 +5,7 @@ import (
"strings"
"sync"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/cline/cli/pkg/cli/types"
)
@@ -16,6 +17,8 @@ type StreamingDisplay struct {
dedupe *MessageDeduplicator
activeSegment *StreamingSegment
mdRenderer *MarkdownRenderer
teaProgram *tea.Program
teaMu sync.RWMutex // Protects teaProgram access
}
// NewStreamingDisplay creates a new streaming display manager
@@ -30,9 +33,24 @@ func NewStreamingDisplay(state *types.ConversationState, renderer *Renderer) *St
renderer: renderer,
dedupe: NewMessageDeduplicator(),
mdRenderer: mdRenderer,
teaProgram: nil, // Will be set later via SetTeaProgram
}
}
// SetTeaProgram sets the BubbleTea program reference for Printf output
func (s *StreamingDisplay) SetTeaProgram(program *tea.Program) {
s.teaMu.Lock()
defer s.teaMu.Unlock()
s.teaProgram = program
}
// GetTeaProgram safely retrieves the tea program reference
func (s *StreamingDisplay) GetTeaProgram() *tea.Program {
s.teaMu.RLock()
defer s.teaMu.RUnlock()
return s.teaProgram
}
// HandlePartialMessage processes partial messages with streaming support
func (s *StreamingDisplay) HandlePartialMessage(msg *types.ClineMessage) error {
s.mu.Lock()
@@ -61,7 +79,7 @@ func (s *StreamingDisplay) HandlePartialMessage(msg *types.ClineMessage) error {
shouldMd := s.shouldRenderMarkdown(sayType)
prefix := s.getPrefix(sayType)
// NewStreamingSegment prints the header immediately
s.activeSegment = NewStreamingSegment(sayType, prefix, s.mdRenderer, shouldMd, msg, s.renderer.outputFormat)
s.activeSegment = NewStreamingSegment(sayType, prefix, s.mdRenderer, shouldMd, msg, s.renderer.outputFormat, s.GetTeaProgram())
// Header printed, done - don't append text or freeze
return nil
}
@@ -81,7 +99,7 @@ func (s *StreamingDisplay) HandlePartialMessage(msg *types.ClineMessage) error {
// Message arrived complete without partial phase - create segment and render immediately
shouldMd := s.shouldRenderMarkdown(sayType)
prefix := s.getPrefix(sayType)
segment := NewStreamingSegment(sayType, prefix, s.mdRenderer, shouldMd, msg, s.renderer.outputFormat)
segment := NewStreamingSegment(sayType, prefix, s.mdRenderer, shouldMd, msg, s.renderer.outputFormat, s.GetTeaProgram())
segment.AppendText(msg.Text)
segment.Freeze()
}
+8 -8
View File
@@ -67,7 +67,7 @@ func (sr *SystemMessageRenderer) RenderError(severity ErrorSeverity, title, body
markdown := strings.Join(parts, "\n")
rendered := sr.renderer.RenderMarkdown(markdown)
fmt.Printf("\n%s\n", rendered)
Printf("\n%s\n", rendered)
return nil
}
@@ -126,7 +126,7 @@ func (sr *SystemMessageRenderer) RenderBalanceError(err *clerror.ClineError) err
markdown := strings.Join(parts, "\n")
rendered := sr.renderer.RenderMarkdown(markdown)
fmt.Printf("\n%s\n", rendered)
Printf("\n%s\n", rendered)
return nil
}
@@ -161,7 +161,7 @@ func (sr *SystemMessageRenderer) RenderAuthError(err *clerror.ClineError) error
markdown := strings.Join(parts, "\n")
rendered := sr.renderer.RenderMarkdown(markdown)
fmt.Printf("\n%s\n", rendered)
Printf("\n%s\n", rendered)
return nil
}
@@ -193,7 +193,7 @@ func (sr *SystemMessageRenderer) RenderRateLimitError(err *clerror.ClineError) e
markdown := strings.Join(parts, "\n")
rendered := sr.renderer.RenderMarkdown(markdown)
fmt.Printf("\n%s\n", rendered)
Printf("\n%s\n", rendered)
return nil
}
@@ -239,7 +239,7 @@ func (sr *SystemMessageRenderer) RenderAPIError(err *clerror.ClineError) error {
markdown := strings.Join(parts, "\n")
rendered := sr.renderer.RenderMarkdown(markdown)
fmt.Printf("\n%s\n", rendered)
Printf("\n%s\n", rendered)
return nil
}
@@ -248,7 +248,7 @@ func (sr *SystemMessageRenderer) RenderAPIError(err *clerror.ClineError) error {
func (sr *SystemMessageRenderer) RenderWarning(title, message string) error {
markdown := fmt.Sprintf("### **[WARNING]** %s\n\n%s", title, message)
rendered := sr.renderer.RenderMarkdown(markdown)
fmt.Printf("\n%s\n", rendered)
Printf("\n%s\n", rendered)
return nil
}
@@ -256,7 +256,7 @@ func (sr *SystemMessageRenderer) RenderWarning(title, message string) error {
func (sr *SystemMessageRenderer) RenderInfo(title, message string) error {
markdown := fmt.Sprintf("### **[INFO]** %s\n\n%s", title, message)
rendered := sr.renderer.RenderMarkdown(markdown)
fmt.Printf("\n%s\n", rendered)
Printf("\n%s\n", rendered)
return nil
}
@@ -264,6 +264,6 @@ func (sr *SystemMessageRenderer) RenderInfo(title, message string) error {
func (sr *SystemMessageRenderer) RenderCheckpoint(timestamp string, id int64) error {
markdown := fmt.Sprintf("## [%s] Checkpoint created `%d`", timestamp, id)
rendered := sr.renderer.RenderMarkdown(markdown)
fmt.Printf(rendered)
Printf("%s", rendered)
return nil
}
+4 -4
View File
@@ -46,7 +46,7 @@ func NewTypewriterPrinter(config *TypewriterConfig) *TypewriterPrinter {
// Print prints text with typewriter effect
func (tp *TypewriterPrinter) Print(text string) {
if !tp.config.Enabled {
fmt.Print(text)
Print(text)
return
}
@@ -72,12 +72,12 @@ func (tp *TypewriterPrinter) PrintfLn(format string, args ...interface{}) {
// PrintInstant prints text immediately without typewriter effect
func (tp *TypewriterPrinter) PrintInstant(text string) {
fmt.Print(text)
Print(text)
}
// PrintfInstant prints formatted text immediately without typewriter effect
func (tp *TypewriterPrinter) PrintfInstant(format string, args ...interface{}) {
fmt.Printf(format, args...)
Printf(format, args...)
}
// typewriterPrint displays text with a typewriter animation effect
@@ -87,7 +87,7 @@ func (tp *TypewriterPrinter) typewriterPrint(text string) {
for i, r := range runes {
// Print the character
fmt.Print(string(r))
Print(string(r))
os.Stdout.Sync() // Force immediate output
// Don't add delay after the last character
+20 -20
View File
@@ -80,12 +80,12 @@ func (h *AskHandler) handleFollowup(msg *types.ClineMessage, dc *DisplayContext)
// Render header
rendered := dc.Renderer.RenderMarkdown(header)
fmt.Print("\n")
fmt.Print(rendered)
fmt.Print("\n")
dc.Printf("\n")
dc.Printf("%s", rendered)
dc.Printf("\n")
// Render body
fmt.Print(body)
dc.Printf("%s", body)
return nil
}
@@ -97,7 +97,7 @@ func (h *AskHandler) handlePlanModeRespond(msg *types.ClineMessage, dc *DisplayC
// Just render the body content
body := dc.ToolRenderer.GeneratePlanModeRespondBody(msg.Text)
if body != "" {
fmt.Print(body)
dc.Printf("%s", body)
}
} else {
// In non-streaming mode, render header + body together
@@ -110,12 +110,12 @@ func (h *AskHandler) handlePlanModeRespond(msg *types.ClineMessage, dc *DisplayC
// Render header
rendered := dc.Renderer.RenderMarkdown(header)
fmt.Print("\n")
fmt.Print(rendered)
fmt.Print("\n")
dc.Printf("\n")
dc.Printf("%s", rendered)
dc.Printf("\n")
// Render body
fmt.Print(body)
dc.Printf("%s", body)
}
return nil
@@ -132,7 +132,7 @@ func (h *AskHandler) handleCommand(msg *types.ClineMessage, dc *DisplayContext)
// Use unified ToolRenderer
output := dc.ToolRenderer.RenderCommandApprovalRequest(msg.Text, autoApprovalConflict)
fmt.Print(output)
dc.Printf("%s", output)
return nil
}
@@ -148,7 +148,7 @@ func (h *AskHandler) handleCommandOutput(msg *types.ClineMessage, dc *DisplayCon
markdown := fmt.Sprintf("```\n%s\n```", commandOutput)
rendered := dc.Renderer.RenderMarkdown(markdown)
fmt.Printf("%s", rendered)
dc.Printf("%s", rendered)
return nil
}
@@ -169,7 +169,7 @@ func (h *AskHandler) handleTool(msg *types.ClineMessage, dc *DisplayContext) err
// Use unified ToolRenderer
output := dc.ToolRenderer.RenderToolApprovalRequest(&tool)
fmt.Print(output)
dc.Printf("%s", output)
return nil
}
@@ -225,7 +225,7 @@ func (h *AskHandler) handleMistakeLimitReached(msg *types.ClineMessage, dc *Disp
"Cline has made too many consecutive mistakes and needs your guidance to proceed.",
details,
)
fmt.Printf("\n**Approval required to continue.**\n")
dc.Printf("\n**Approval required to continue.**\n")
return nil
}
return dc.Renderer.RenderMessage("ERROR", fmt.Sprintf("Mistake Limit Reached: %s. Approval required.", msg.Text), true)
@@ -244,7 +244,7 @@ func (h *AskHandler) handleAutoApprovalMaxReached(msg *types.ClineMessage, dc *D
"The maximum number of auto-approved requests has been reached. Manual approval is now required.",
details,
)
fmt.Printf("\n**Approval required to continue.**\n")
dc.Printf("\n**Approval required to continue.**\n")
return nil
}
return dc.Renderer.RenderMessage("WARNING", fmt.Sprintf("Auto-approval limit reached: %s. Approval required.", msg.Text), true)
@@ -315,12 +315,12 @@ func (h *AskHandler) handleReportBug(msg *types.ClineMessage, dc *DisplayContext
return fmt.Errorf("failed to render handleReportBug: %w", err)
}
fmt.Printf("\n**Title**: %s\n", bugData.Title)
fmt.Printf("**What Happened**: %s\n", bugData.WhatHappened)
fmt.Printf("**Steps to Reproduce**: %s\n", bugData.StepsToReproduce)
fmt.Printf("**API Request Output**: %s\n", bugData.APIRequestOutput)
fmt.Printf("**Additional Context**: %s\n", bugData.AdditionalContext)
fmt.Printf("\nApprove to create a GitHub issue.\n")
dc.Printf("\n**Title**: %s\n", bugData.Title)
dc.Printf("**What Happened**: %s\n", bugData.WhatHappened)
dc.Printf("**Steps to Reproduce**: %s\n", bugData.StepsToReproduce)
dc.Printf("**API Request Output**: %s\n", bugData.APIRequestOutput)
dc.Printf("**Additional Context**: %s\n", bugData.AdditionalContext)
dc.Printf("\nApprove to create a GitHub issue.\n")
return nil
}
+5
View File
@@ -34,6 +34,11 @@ type DisplayContext struct {
IsInteractive bool
}
// Printf outputs text using the global display singleton
func (dc *DisplayContext) Printf(format string, args ...interface{}) {
display.Printf(format, args...)
}
// BaseHandler provides common functionality for message handlers
type BaseHandler struct {
name string
+14 -14
View File
@@ -179,8 +179,8 @@ func (h *SayHandler) handleText(msg *types.ClineMessage, dc *DisplayContext) err
if dc.MessageIndex == 0 {
markdown := formatUserMessage(msg.Text)
rendered := dc.Renderer.RenderMarkdown(markdown)
fmt.Printf("%s", rendered)
fmt.Printf("\n")
dc.Printf("%s", rendered)
dc.Printf("\n")
return nil
}
@@ -189,12 +189,12 @@ func (h *SayHandler) handleText(msg *types.ClineMessage, dc *DisplayContext) err
if dc.IsStreamingMode {
// In streaming mode, header already shown by partial stream
rendered = dc.Renderer.RenderMarkdown(msg.Text)
fmt.Printf("%s\n", rendered)
dc.Printf("%s\n", rendered)
} else {
// In non-streaming mode, render header + body together
markdown := fmt.Sprintf("### Cline responds\n\n%s", msg.Text)
rendered = dc.Renderer.RenderMarkdown(markdown)
fmt.Printf("\n%s\n", rendered)
dc.Printf("\n%s\n", rendered)
}
return nil
}
@@ -209,12 +209,12 @@ func (h *SayHandler) handleReasoning(msg *types.ClineMessage, dc *DisplayContext
if dc.IsStreamingMode {
// In streaming mode, header already shown by partial stream
rendered = dc.Renderer.RenderMarkdown(msg.Text)
fmt.Printf("%s\n", rendered)
dc.Printf("%s\n", rendered)
} else {
// In non-streaming mode, render header + body together
markdown := fmt.Sprintf("### Cline is thinking\n\n%s", msg.Text)
rendered = dc.Renderer.RenderMarkdown(markdown)
fmt.Printf("\n%s\n", rendered)
dc.Printf("\n%s\n", rendered)
}
return nil
}
@@ -230,12 +230,12 @@ func (h *SayHandler) handleCompletionResult(msg *types.ClineMessage, dc *Display
if dc.IsStreamingMode {
// In streaming mode, header already shown by partial stream
rendered = dc.Renderer.RenderMarkdown(text)
fmt.Printf("%s\n", rendered)
dc.Printf("%s\n", rendered)
} else {
// In non-streaming mode, render header + body together
markdown := fmt.Sprintf("### Task completed\n\n%s", text)
rendered = dc.Renderer.RenderMarkdown(markdown)
fmt.Printf("\n%s\n", rendered)
dc.Printf("\n%s\n", rendered)
}
return nil
}
@@ -259,7 +259,7 @@ func (h *SayHandler) handleUserFeedback(msg *types.ClineMessage, dc *DisplayCont
if msg.Text != "" {
markdown := formatUserMessage(msg.Text)
rendered := dc.Renderer.RenderMarkdown(markdown)
fmt.Printf("%s", rendered)
dc.Printf("%s", rendered)
return nil
} else {
return dc.Renderer.RenderMessage("USER", "[Provided feedback without text]", true)
@@ -324,7 +324,7 @@ func (h *SayHandler) handleCommand(msg *types.ClineMessage, dc *DisplayContext)
// Use unified ToolRenderer
output := dc.ToolRenderer.RenderCommandExecution(msg.Text)
fmt.Print(output)
dc.Printf("%s", output)
return nil
}
@@ -337,7 +337,7 @@ func (h *SayHandler) handleCommandOutput(msg *types.ClineMessage, dc *DisplayCon
// Use unified ToolRenderer
output := dc.ToolRenderer.RenderCommandOutput(msg.Text)
fmt.Print(output)
dc.Printf("%s", output)
return nil
}
@@ -350,7 +350,7 @@ func (h *SayHandler) handleTool(msg *types.ClineMessage, dc *DisplayContext) err
// Use unified ToolRenderer
output := dc.ToolRenderer.RenderToolExecution(&tool)
fmt.Print(output)
dc.Printf("%s", output)
return nil
}
@@ -485,7 +485,7 @@ func (h *SayHandler) handleCheckpointCreated(msg *types.ClineMessage, dc *Displa
// Fallback to basic renderer if SystemRenderer not available
markdown := fmt.Sprintf("## [%s] Checkpoint created `%d`", timestamp, msg.Timestamp)
rendered := dc.Renderer.RenderMarkdown(markdown)
fmt.Printf(rendered)
dc.Printf("%s", rendered)
return nil
}
@@ -510,7 +510,7 @@ func (h *SayHandler) handleTaskProgress(msg *types.ClineMessage, dc *DisplayCont
markdown := fmt.Sprintf("### Progress\n\n%s", msg.Text)
rendered := dc.Renderer.RenderMarkdown(markdown)
fmt.Printf("\n%s\n", rendered)
dc.Printf("\n%s\n", rendered)
return nil
}
+406
View File
@@ -0,0 +1,406 @@
package task
import (
"context"
"fmt"
"io"
"strings"
"time"
"github.com/charmbracelet/bubbles/v2/cursor"
"github.com/charmbracelet/bubbles/v2/list"
"github.com/charmbracelet/bubbles/v2/textarea"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/cline/cli/pkg/cli/display"
"github.com/cline/cli/pkg/cli/types"
)
// InputMode represents the current state of the input UI
type InputMode int
const (
InputModeHidden InputMode = iota // No input shown
InputModeMessage // Textarea for messages
InputModeApproval // List for approval selection
InputModeFeedback // Textarea for feedback after approval
)
// InteractiveModel is the BubbleTea model for interactive CLI input
type InteractiveModel struct {
// UI components
textarea *textarea.Model
approvalList list.Model
// References
manager *Manager
cancelFunc context.CancelFunc
ctx context.Context
// State
inputMode InputMode
prevMode InputMode // Track previous mode for transitions
width int
height int
// Mode tracking
currentMode string // "plan" or "act"
// Approval state
approvalMsg *types.ClineMessage
approvalChoice string // "yes_feedback", "no_feedback", etc.
// Styles
styles approvalStyles
}
type approvalStyles struct {
title lipgloss.Style
item lipgloss.Style
selectedItem lipgloss.Style
}
// Approval option item
type approvalItem string
func (i approvalItem) FilterValue() string { return "" }
func (i approvalItem) Title() string { return string(i) }
func (i approvalItem) Description() string { return "" }
// Approval list delegate
type approvalDelegate struct {
styles *approvalStyles
}
func (d approvalDelegate) Height() int { return 1 }
func (d approvalDelegate) Spacing() int { return 0 }
func (d approvalDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { return nil }
func (d approvalDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) {
i, ok := listItem.(approvalItem)
if !ok {
return
}
str := string(i)
fn := d.styles.item.Render
if index == m.Index() {
fn = func(s ...string) string {
return d.styles.selectedItem.Render("> " + strings.Join(s, " "))
}
}
fmt.Fprint(w, fn(str))
}
// pollTickMsg triggers a check of whether input should be shown
type pollTickMsg struct{}
func pollForInputState() tea.Cmd {
return tea.Tick(500*time.Millisecond, func(t time.Time) tea.Msg {
return pollTickMsg{}
})
}
// NewInteractiveModel creates a new interactive model
func NewInteractiveModel(manager *Manager, cancelFunc context.CancelFunc, ctx context.Context) InteractiveModel {
// Setup textarea
ta := textarea.New()
ta.Placeholder = "Type your message... (shift+enter for new line, enter to submit, /plan or /act to switch mode)"
ta.SetVirtualCursor(true)
ta.Focus()
ta.SetHeight(5)
ta.ShowLineNumbers = false
ta.Styles()
taStyles := ta.Styles()
taStyles.Cursor.Blink = true
ta.SetStyles(taStyles)
// Setup approval list
items := []list.Item{
approvalItem("Yes"),
approvalItem("Yes, with feedback"),
approvalItem("No"),
approvalItem("No, with feedback"),
}
styles := approvalStyles{
title: lipgloss.NewStyle().MarginLeft(2).Bold(true),
item: lipgloss.NewStyle().PaddingLeft(4),
selectedItem: lipgloss.NewStyle().PaddingLeft(2).Foreground(lipgloss.Color("170")),
}
delegate := approvalDelegate{styles: &styles}
approvalList := list.New(items, delegate, 40, 8) // Height: 1 title + 4 items + 3 padding
approvalList.Title = "Let Cline use this tool?"
approvalList.SetShowStatusBar(false)
approvalList.SetFilteringEnabled(false)
approvalList.SetShowPagination(false)
approvalList.SetShowHelp(true)
approvalList.Styles.Title = styles.title
return InteractiveModel{
textarea: ta,
approvalList: approvalList,
manager: manager,
cancelFunc: cancelFunc,
ctx: ctx,
inputMode: InputModeHidden,
prevMode: InputModeHidden,
currentMode: "act", // Default to act mode
styles: styles,
}
}
func (m InteractiveModel) Init() tea.Cmd {
return tea.Batch(
textarea.Blink,
pollForInputState(),
)
}
func (m InteractiveModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case display.PrintMsg:
// Handle print messages from display singleton
return m, tea.Printf("%s", string(msg))
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.textarea.SetWidth(msg.Width)
m.approvalList.SetWidth(msg.Width)
return m, nil
case pollTickMsg:
// Check if approval is needed
needsApproval, approvalMsg, err := m.manager.CheckNeedsApproval(m.ctx)
if err == nil && needsApproval {
if m.inputMode != InputModeApproval {
m.prevMode = m.inputMode
m.inputMode = InputModeApproval
m.approvalMsg = approvalMsg
}
return m, pollForInputState()
}
// Check if we can send a message
err = m.manager.CheckSendEnabled(m.ctx)
if err == nil {
// Can send message
if m.inputMode != InputModeMessage && m.inputMode != InputModeFeedback {
m.prevMode = m.inputMode
m.inputMode = InputModeMessage
// Get current mode from manager
m.currentMode = m.manager.GetCurrentMode()
}
} else {
// Hide input
if m.inputMode != InputModeHidden {
m.prevMode = m.inputMode
m.inputMode = InputModeHidden
}
}
return m, pollForInputState()
case tea.KeyPressMsg:
switch m.inputMode {
case InputModeApproval:
switch msg.String() {
case "ctrl+c", "esc":
m.cancelFunc()
return m, tea.Quit
case "enter":
// Get selected item
selected := m.approvalList.SelectedItem()
if selected == nil {
return m, nil
}
choice := string(selected.(approvalItem))
switch choice {
case "Yes":
m.sendApproval(true, "")
m.inputMode = InputModeHidden
case "Yes, with feedback":
m.approvalChoice = "yes"
m.inputMode = InputModeFeedback
m.textarea.Reset()
m.textarea.Focus()
case "No":
m.sendApproval(false, "")
m.inputMode = InputModeHidden
case "No, with feedback":
m.approvalChoice = "no"
m.inputMode = InputModeFeedback
m.textarea.Reset()
m.textarea.Focus()
}
return m, nil
default:
// Pass to list for navigation
var cmd tea.Cmd
m.approvalList, cmd = m.approvalList.Update(msg)
return m, cmd
}
case InputModeFeedback:
switch msg.String() {
case "ctrl+c", "esc":
m.cancelFunc()
return m, tea.Quit
case "enter":
// Send approval with feedback
feedback := strings.TrimSpace(m.textarea.Value())
approved := m.approvalChoice == "yes"
m.sendApproval(approved, feedback)
m.textarea.Reset()
m.inputMode = InputModeHidden
return m, nil
default:
// Pass to textarea
var cmd tea.Cmd
m.textarea, cmd = m.textarea.Update(msg)
return m, cmd
}
case InputModeMessage:
switch msg.String() {
case "ctrl+c", "esc":
m.cancelFunc()
return m, tea.Quit
case "enter":
message := strings.TrimSpace(m.textarea.Value())
if message == "" {
return m, nil
}
// Handle mode switching
if newMode, remainingMessage, isModeSwitch := m.parseModeSwitch(message); isModeSwitch {
if err := m.manager.SetMode(m.ctx, newMode, nil, nil, nil); err == nil {
m.currentMode = newMode
if remainingMessage != "" {
message = remainingMessage
} else {
m.textarea.Reset()
return m, nil
}
}
}
// Handle special commands
if m.handleSpecialCommand(message) {
m.textarea.Reset()
return m, nil
}
// Send the message
if err := m.manager.SendMessage(m.ctx, message, nil, nil, ""); err != nil {
// Error sending, but don't crash - just log it
fmt.Printf("\nError sending message: %v\n", err)
}
m.textarea.Reset()
m.inputMode = InputModeHidden
return m, nil
default:
// Pass to textarea
var cmd tea.Cmd
m.textarea, cmd = m.textarea.Update(msg)
return m, cmd
}
}
case cursor.BlinkMsg:
// Textarea needs cursor blinks
var cmd tea.Cmd
m.textarea, cmd = m.textarea.Update(msg)
return m, cmd
}
return m, tea.Batch(cmds...)
}
func (m InteractiveModel) View() string {
switch m.inputMode {
case InputModeHidden:
return "" // Nothing shown - pure stdout mode
case InputModeMessage:
// Show mode indicator and textarea
modeColor := "\033[34m" // Blue for act
if m.currentMode == "plan" {
modeColor = "\033[33m" // Yellow for plan
}
reset := "\033[0m"
title := fmt.Sprintf("\n%s[%s mode]%s Cline is ready for your message", modeColor, m.currentMode, reset)
return title + "\n" + m.textarea.View()
case InputModeApproval:
return "\n" + m.approvalList.View()
case InputModeFeedback:
return "\nYour feedback:\n" + m.textarea.View()
}
return ""
}
// sendApproval sends approval response to the manager
func (m *InteractiveModel) sendApproval(approved bool, feedback string) {
approveStr := "false"
if approved {
approveStr = "true"
}
if err := m.manager.SendMessage(m.ctx, feedback, nil, nil, approveStr); err != nil {
fmt.Printf("\nError sending approval: %v\n", err)
}
}
// parseModeSwitch checks if message starts with /act or /plan
func (m *InteractiveModel) parseModeSwitch(message string) (string, string, bool) {
trimmed := strings.TrimSpace(message)
lower := strings.ToLower(trimmed)
if strings.HasPrefix(lower, "/plan") {
remaining := strings.TrimSpace(trimmed[5:])
return "plan", remaining, true
}
if strings.HasPrefix(lower, "/act") {
remaining := strings.TrimSpace(trimmed[4:])
return "act", remaining, true
}
return "", message, false
}
// handleSpecialCommand processes special commands like /cancel, /exit
func (m *InteractiveModel) handleSpecialCommand(message string) bool {
switch strings.ToLower(strings.TrimSpace(message)) {
case "/cancel":
if err := m.manager.CancelTask(m.ctx); err != nil {
fmt.Printf("Error cancelling task: %v\n", err)
} else {
fmt.Println("Task cancelled successfully")
}
return true
case "/exit", "/quit":
fmt.Println("\nExiting follow mode...")
m.cancelFunc()
return true
default:
return false
}
}
-368
View File
@@ -1,368 +0,0 @@
package task
import (
"context"
"errors"
"fmt"
"strings"
"sync"
"time"
"github.com/charmbracelet/huh"
"github.com/cline/cli/pkg/cli/global"
"github.com/cline/cli/pkg/cli/types"
)
// InputHandler manages interactive user input during follow mode
type InputHandler struct {
manager *Manager
coordinator *StreamCoordinator
cancelFunc context.CancelFunc
mu sync.RWMutex
isRunning bool
pollTicker *time.Ticker
}
// NewInputHandler creates a new input handler
func NewInputHandler(manager *Manager, coordinator *StreamCoordinator, cancelFunc context.CancelFunc) *InputHandler {
return &InputHandler{
manager: manager,
coordinator: coordinator,
cancelFunc: cancelFunc,
isRunning: false,
pollTicker: time.NewTicker(500 * time.Millisecond),
}
}
// Start begins monitoring for input opportunities
func (ih *InputHandler) Start(ctx context.Context, errChan chan error) {
ih.mu.Lock()
ih.isRunning = true
ih.mu.Unlock()
defer func() {
ih.mu.Lock()
ih.isRunning = false
ih.mu.Unlock()
ih.pollTicker.Stop()
}()
for {
select {
case <-ctx.Done():
return
case <-ih.pollTicker.C:
// First check if approval is needed
needsApproval, approvalMsg, err := ih.manager.CheckNeedsApproval(ctx)
if err != nil {
if global.Config.Verbose {
fmt.Printf("\nDebug: CheckNeedsApproval error: %v\n", err)
}
continue
}
if needsApproval {
ih.coordinator.SetInputAllowed(true)
// Lock output to prevent race with streaming display
ih.coordinator.LockOutput()
// Show approval prompt
approved, feedback, err := ih.promptForApproval(ctx, approvalMsg)
// Unlock output after form dismissed
ih.coordinator.UnlockOutput()
if err != nil {
// Check if the error is due to interrupt (Ctrl+C) or context cancellation
if err == huh.ErrUserAborted || ctx.Err() != nil {
// User pressed Ctrl+C - cancel context to exit FollowConversation
ih.cancelFunc()
return
}
if global.Config.Verbose {
fmt.Printf("\nDebug: Approval prompt error: %v\n", err)
}
continue
}
ih.coordinator.SetInputAllowed(false)
// Send approval response
approveStr := "false"
if approved {
approveStr = "true"
}
if err := ih.manager.SendMessage(ctx, feedback, nil, nil, approveStr); err != nil {
fmt.Printf("\nError sending approval: %v\n", err)
continue
}
if global.Config.Verbose {
fmt.Printf("\nDebug: Approval sent (approved=%s, feedback=%q)\n", approveStr, feedback)
}
// Give the system a moment to process before re-polling
time.Sleep(1 * time.Second)
continue
}
// Check if we can send a regular message
err = ih.manager.CheckSendEnabled(ctx)
if err != nil {
// Handle specific error cases
if errors.Is(err, ErrNoActiveTask) {
// No active task - don't show input prompt
ih.coordinator.SetInputAllowed(false)
continue
}
if errors.Is(err, ErrTaskBusy) {
// Task is busy - don't show input prompt
ih.coordinator.SetInputAllowed(false)
continue
}
// Unexpected error
if global.Config.Verbose {
fmt.Printf("\nDebug: CheckSendEnabled error: %v\n", err)
}
continue
}
// If we reach here, we can send a message
ih.coordinator.SetInputAllowed(true)
// Lock output to prevent race with streaming display
ih.coordinator.LockOutput()
// Show prompt and get input
message, shouldSend, err := ih.promptForInput(ctx)
// Unlock output after form dismissed
ih.coordinator.UnlockOutput()
if err != nil {
// Check if the error is due to interrupt (Ctrl+C) or context cancellation
if err == huh.ErrUserAborted || ctx.Err() != nil {
// User pressed Ctrl+C - cancel context to exit FollowConversation
ih.cancelFunc()
return
}
if global.Config.Verbose {
fmt.Printf("\nDebug: Input prompt error: %v\n", err)
}
continue
}
ih.coordinator.SetInputAllowed(false)
if shouldSend {
// Check for mode switch commands first
newMode, remainingMessage, isModeSwitch := ih.parseModeSwitch(message)
if isModeSwitch {
// Switch mode
if err := ih.manager.SetMode(ctx, newMode, nil, nil, nil); err != nil {
fmt.Printf("\nError switching to %s mode: %v\n", newMode, err)
continue
}
fmt.Printf("\nSwitched to %s mode\n", newMode)
// If there's remaining message, use it as the new message to send
if remainingMessage != "" {
message = remainingMessage
} else {
// No message to send, just mode switch
time.Sleep(1 * time.Second)
continue
}
}
// Handle special commands
if handled := ih.handleSpecialCommand(ctx, message); handled {
continue
}
// Send the message
if err := ih.manager.SendMessage(ctx, message, nil, nil, ""); err != nil {
fmt.Printf("\nError sending message: %v\n", err)
continue
}
if global.Config.Verbose {
fmt.Printf("\nDebug: Message sent successfully\n")
}
// Give the system a moment to process before re-polling
time.Sleep(1 * time.Second)
}
}
}
}
// promptForInput displays an interactive prompt and waits for user input
func (ih *InputHandler) promptForInput(ctx context.Context) (string, bool, error) {
// Add visual separation before the form
fmt.Println()
var message string
// Get current mode and format title with color
currentMode := ih.manager.GetCurrentMode()
// ANSI color codes
yellow := "\033[33m" // Yellow for plan mode
blue := "\033[34m" // Blue for act mode
indigo := "\033[38;5;99m" // Indigo (huh default title color) - approximation of #7571F9
bold := "\033[1m" // Bold
reset := "\033[0m" // Reset
var coloredMode string
if currentMode == "plan" {
coloredMode = fmt.Sprintf("%s[plan mode]%s", yellow, reset)
} else {
coloredMode = fmt.Sprintf("%s[act mode]%s", blue, reset)
}
title := fmt.Sprintf("%s %s%sCline is ready for your message%s", coloredMode, bold, indigo, reset)
// Create multiline text area form using huh
form := huh.NewForm(
huh.NewGroup(
huh.NewText().
Title(title).
Placeholder("Type your message... (shift+enter for new line, enter to submit, /plan or /act to switch mode)").
Lines(5).
Value(&message),
),
)
// Run the form
err := form.Run()
if err != nil {
return "", false, err
}
// Trim whitespace
message = strings.TrimSpace(message)
// If empty, user just wants to keep watching
if message == "" {
return "", false, nil
}
return message, true, nil
}
// promptForApproval displays an approval prompt for tool/command requests
// Returns (approved, message, error)
// Note: The approval details are already shown by segment streamer / state stream
func (ih *InputHandler) promptForApproval(ctx context.Context, msg *types.ClineMessage) (bool, string, error) {
// Add visual separation before the form
fmt.Println()
// Show selection menu (approval details already displayed by other handlers)
var choice string
form := huh.NewForm(
huh.NewGroup(
huh.NewSelect[string]().
Title("Let Cline use this tool?").
Options(
huh.NewOption("Yes", "yes"),
huh.NewOption("Yes, with feedback", "yes_feedback"),
huh.NewOption("No", "no"),
huh.NewOption("No, with feedback", "no_feedback"),
).
Value(&choice),
),
)
err := form.Run()
if err != nil {
return false, "", err
}
// Check if feedback is needed
needsFeedback := choice == "yes_feedback" || choice == "no_feedback"
approved := choice == "yes" || choice == "yes_feedback"
var feedback string
if needsFeedback {
// Show multiline text area for feedback
feedbackForm := huh.NewForm(
huh.NewGroup(
huh.NewText().
Title("Your feedback").
Placeholder("Type your message... (shift+enter for new line, enter to submit, /plan or /act to switch mode)").
Lines(5).
Value(&feedback),
),
)
err := feedbackForm.Run()
if err != nil {
return false, "", err
}
feedback = strings.TrimSpace(feedback)
}
return approved, feedback, nil
}
// parseModeSwitch checks if message starts with /act or /plan and extracts the mode and remaining message
// Returns: (newMode, remainingMessage, isModeSwitch)
func (ih *InputHandler) parseModeSwitch(message string) (string, string, bool) {
trimmed := strings.TrimSpace(message)
lower := strings.ToLower(trimmed)
if strings.HasPrefix(lower, "/plan") {
// Extract remaining message after /plan
remaining := strings.TrimSpace(trimmed[5:]) // Remove "/plan"
return "plan", remaining, true
}
if strings.HasPrefix(lower, "/act") {
// Extract remaining message after /act
remaining := strings.TrimSpace(trimmed[4:]) // Remove "/act"
return "act", remaining, true
}
return "", message, false
}
// handleSpecialCommand processes special commands like /cancel, /exit
func (ih *InputHandler) handleSpecialCommand(ctx context.Context, message string) bool {
switch strings.ToLower(strings.TrimSpace(message)) {
case "/cancel":
ih.manager.GetRenderer().RenderTaskCancelled()
if err := ih.manager.CancelTask(ctx); err != nil {
fmt.Printf("Error cancelling task: %v\n", err)
} else {
fmt.Println("Task cancelled successfully")
}
return true
case "/exit", "/quit":
fmt.Println("\nExiting follow mode...")
// This will be handled by context cancellation
return true
default:
return false
}
}
// Stop stops the input handler
func (ih *InputHandler) Stop() {
ih.mu.Lock()
defer ih.mu.Unlock()
if ih.pollTicker != nil {
ih.pollTicker.Stop()
}
ih.isRunning = false
}
// IsRunning returns whether the input handler is currently running
func (ih *InputHandler) IsRunning() bool {
ih.mu.RLock()
defer ih.mu.RUnlock()
return ih.isRunning
}
+52 -59
View File
@@ -10,6 +10,7 @@ import (
"syscall"
"time"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/cline/cli/pkg/cli/display"
"github.com/cline/cli/pkg/cli/global"
"github.com/cline/cli/pkg/cli/handlers"
@@ -699,32 +700,43 @@ func (m *Manager) FollowConversation(ctx context.Context, instanceAddress string
// Start both streams concurrently
errChan := make(chan error, 3)
// Setup BubbleTea interactive mode if enabled
var teaProgram *tea.Program
if interactive && global.Config.OutputFormat != "json" {
model := NewInteractiveModel(m, cancel, ctx)
teaProgram = tea.NewProgram(model)
coordinator.SetTeaProgram(teaProgram)
m.streamingDisplay.SetTeaProgram(teaProgram)
// Set the global output writer to use BubbleTea
display.SetOutputWriter(display.NewBubbleTeaWriter(teaProgram))
// Run BubbleTea in goroutine
go func() {
if _, err := teaProgram.Run(); err != nil {
errChan <- fmt.Errorf("bubbletea error: %w", err)
}
}()
}
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() {
select {
case <-ctx.Done():
return
case <-sigChan:
// 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 {
if !interactive || global.Config.OutputFormat == "json" {
// Only handle Ctrl+C manually in non-interactive mode
// In interactive mode, BubbleTea handles it
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
go func() {
select {
case <-ctx.Done():
return
case <-sigChan:
// Streaming mode - cancel the task and stay in follow mode
m.renderer.RenderTaskCancelled()
if err := m.CancelTask(context.Background()); err != nil {
@@ -732,8 +744,8 @@ func (m *Manager) FollowConversation(ctx context.Context, instanceAddress string
}
// Don't cancel main context - stay in follow mode
}
}
}()
}()
}
// Wait for either stream to error or context cancellation
select {
@@ -882,9 +894,7 @@ func (m *Manager) processStateUpdateJsonMode(stateUpdate *cline.State, coordinat
// 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
coordinator.WithOutputLock(func() {
m.displayMessage(msg, false, false, i)
})
} else {
break
}
@@ -930,59 +940,47 @@ func (m *Manager) processStateUpdate(stateUpdate *cline.State, coordinator *Stre
case msg.Say == string(types.SayTypeUserFeedback):
msgKey := fmt.Sprintf("%d", msg.Timestamp)
if !coordinator.IsProcessedInCurrentTurn(msgKey) {
coordinator.WithOutputLock(func() {
fmt.Println()
m.displayMessage(msg, false, false, i)
})
m.printf("\n")
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) {
coordinator.WithOutputLock(func() {
fmt.Println()
m.displayMessage(msg, false, false, i)
})
m.printf("\n")
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) {
coordinator.WithOutputLock(func() {
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) {
coordinator.WithOutputLock(func() {
fmt.Println()
m.displayMessage(msg, false, false, i)
})
m.printf("\n")
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) {
coordinator.WithOutputLock(func() {
fmt.Println()
m.displayMessage(msg, false, false, i)
})
m.printf("\n")
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) {
coordinator.WithOutputLock(func() {
fmt.Println()
m.displayMessage(msg, false, false, i)
})
m.printf("\n")
m.displayMessage(msg, false, false, i)
coordinator.MarkProcessedInCurrentTurn(msgKey)
}
@@ -991,10 +989,8 @@ func (m *Manager) processStateUpdate(stateUpdate *cline.State, coordinator *Stre
apiInfo := types.APIRequestInfo{Cost: -1}
if err := json.Unmarshal([]byte(msg.Text), &apiInfo); err == nil && apiInfo.Cost >= 0 {
if !coordinator.IsProcessedInCurrentTurn(msgKey) {
coordinator.WithOutputLock(func() {
fmt.Println() // adds a separator between cline message and usage message
m.displayMessage(msg, false, false, i)
})
m.printf("\n") // adds a separator between cline message and usage message
m.displayMessage(msg, false, false, i)
coordinator.MarkProcessedInCurrentTurn(msgKey)
coordinator.CompleteTurn(len(messages))
displayedUsage = true
@@ -1004,9 +1000,7 @@ func (m *Manager) processStateUpdate(stateUpdate *cline.State, coordinator *Stre
case msg.Ask == string(types.AskTypeCommandOutput):
msgKey := fmt.Sprintf("%d", msg.Timestamp)
if !coordinator.IsProcessedInCurrentTurn(msgKey) {
coordinator.WithOutputLock(func() {
m.displayMessage(msg, false, false, i)
})
coordinator.MarkProcessedInCurrentTurn(msgKey)
}
@@ -1019,9 +1013,7 @@ func (m *Manager) processStateUpdate(stateUpdate *cline.State, coordinator *Stre
} else {
// Non-streaming mode: render normally when message is complete
if !msg.Partial && !coordinator.IsProcessedInCurrentTurn(msgKey) {
coordinator.WithOutputLock(func() {
m.displayMessage(msg, false, false, i)
})
m.displayMessage(msg, false, false, i)
coordinator.MarkProcessedInCurrentTurn(msgKey)
}
}
@@ -1030,10 +1022,8 @@ func (m *Manager) processStateUpdate(stateUpdate *cline.State, coordinator *Stre
msgKey := fmt.Sprintf("%d", msg.Timestamp)
// Only render if not already handled by partial stream
if !coordinator.IsProcessedInCurrentTurn(msgKey) {
coordinator.WithOutputLock(func() {
fmt.Println()
m.displayMessage(msg, false, false, i)
})
m.printf("\n")
m.displayMessage(msg, false, false, i)
coordinator.MarkProcessedInCurrentTurn(msgKey)
}
}
@@ -1093,14 +1083,12 @@ func (m *Manager) handleStreamingMessage(msg *types.ClineMessage, coordinator *S
msg.Timestamp, msg.Partial, msg.Type, m.truncateText(msg.Text, 50))
// Lock output to prevent race with input forms
coordinator.WithOutputLock(func() {
// 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
}
@@ -1258,6 +1246,11 @@ func (m *Manager) updateMode(stateJson string) {
m.mu.Unlock()
}
// printf outputs text using the global display singleton
func (m *Manager) printf(format string, args ...interface{}) {
display.Printf(format, args...)
}
// Cleanup cleans up resources
func (m *Manager) Cleanup() {
// Clean up streaming display resources if needed
+19 -16
View File
@@ -1,6 +1,10 @@
package task
import "sync"
import (
"sync"
tea "github.com/charmbracelet/bubbletea/v2"
)
// StreamCoordinator manages coordination between SubscribeToState and SubscribeToPartialMessage streams
type StreamCoordinator struct {
@@ -8,7 +12,7 @@ type StreamCoordinator struct {
processedInCurrentTurn map[string]bool // What we've handled in THIS turn
inputAllowed bool // Whether user input is currently allowed
mu sync.RWMutex // Protects inputAllowed
outputMu sync.Mutex // Protects terminal output (prevents interleaving with input forms)
teaProgram *tea.Program // BubbleTea program for Printf output
}
// NewStreamCoordinator creates a new stream coordinator
@@ -60,22 +64,21 @@ func (sc *StreamCoordinator) IsInputAllowed() bool {
return sc.inputAllowed
}
// LockOutput locks the output mutex to prevent interleaved terminal output
// Should be called before displaying input forms
func (sc *StreamCoordinator) LockOutput() {
sc.outputMu.Lock()
// SetTeaProgram sets the BubbleTea program reference for Printf output
func (sc *StreamCoordinator) SetTeaProgram(program *tea.Program) {
sc.teaProgram = program
}
// UnlockOutput unlocks the output mutex
// Should be called after input forms are dismissed
func (sc *StreamCoordinator) UnlockOutput() {
sc.outputMu.Unlock()
// GetTeaProgram returns the BubbleTea program reference
func (sc *StreamCoordinator) GetTeaProgram() *tea.Program {
return sc.teaProgram
}
// WithOutputLock executes a function while holding the output lock
// This is a convenience method for wrapping output operations
func (sc *StreamCoordinator) WithOutputLock(fn func()) {
sc.outputMu.Lock()
defer sc.outputMu.Unlock()
fn()
// Printf outputs text above the BubbleTea UI using tea.Printf
// Falls back to regular fmt.Printf if tea program isn't set (non-interactive mode)
func (sc *StreamCoordinator) Printf(format string, args ...interface{}) tea.Cmd {
if sc.teaProgram != nil {
return tea.Printf(format, args...)
}
return nil
}
+31
View File
@@ -1,8 +1,29 @@
cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw=
cloud.google.com/go/compute/metadata v0.7.0/go.mod h1:j5MvL9PprKL39t166CoB1uVHfQMs4tFQZZcKwksXUjo=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.29.0/go.mod h1:Cz6ft6Dkn3Et6l2v2a9/RpN7epQ1GtDlO6lj8bEcOvw=
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
github.com/bits-and-blooms/bitset v1.24.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/charmbracelet/bubbles/v2 v2.0.0-20251001202932-f03bfcc799df h1:4OyF87Tta/Ovk6Aru2uXJZOvr5jVhzpkycr3hpSCRCM=
github.com/charmbracelet/bubbles/v2 v2.0.0-20251001202932-f03bfcc799df/go.mod h1:hliJhApEi6ITgGkXMqa3ttFadsyKXB6kNud2NN6X0zk=
github.com/charmbracelet/bubbletea/v2 v2.0.0-20251011205917-3b687ffc1619 h1:+WW3FTOevGpK+0sUEVch1j/CwyJEFE8M5Mkz/PVVsj8=
github.com/charmbracelet/bubbletea/v2 v2.0.0-20251011205917-3b687ffc1619/go.mod h1:5IzIGXU1n0foRc8bRAherC8ZuQCQURPlwx3ANLq1138=
github.com/charmbracelet/colorprofile v0.3.2 h1:9J27WdztfJQVAQKX2WOlSSRB+5gaKqqITmrvb1uTIiI=
github.com/charmbracelet/colorprofile v0.3.2/go.mod h1:mTD5XzNeWHj8oqHb+S1bssQb7vIHbepiebQ2kPKVKbI=
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
github.com/charmbracelet/lipgloss/v2 v2.0.0-20250917201909-41ff0bf215ea h1:/EC8Fcb0ZFyxChhOR7PIdP/XUIsR8v+0VNStOxh4HIw=
github.com/charmbracelet/lipgloss/v2 v2.0.0-20250917201909-41ff0bf215ea/go.mod h1:ngHerf1JLJXBrDXdphn5gFrBPriCL437uwukd5c93pM=
github.com/charmbracelet/ultraviolet v0.0.0-20250910155420-aa0094762299/go.mod h1:V21rZtvULxJyG8tUsRC8caTBvKNHOuRJVxH+G6ghH0Y=
github.com/charmbracelet/ultraviolet v0.0.0-20250915111650-81d4262876ef h1:VrWaUi2LXYLjfjCHowdSOEc6dQ9Ro14KY7Bw4IWd19M=
github.com/charmbracelet/ultraviolet v0.0.0-20250915111650-81d4262876ef/go.mod h1:AThRsQH1t+dfyOKIwXRoJBniYFQUkUpQq4paheHMc2o=
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
github.com/charmbracelet/x/ansi v0.10.2 h1:ith2ArZS0CJG30cIUfID1LXN7ZFXRCww6RUvAPA+Pzw=
github.com/charmbracelet/x/ansi v0.10.2/go.mod h1:HbLdJjQH4UH4AqA2HpRWuWNluRE6zxJH/yteYEYCFa8=
github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
github.com/charmbracelet/x/exp/golden v0.0.0-20241212170349-ad4b7ae0f25f/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8=
github.com/envoyproxy/go-control-plane v0.13.4/go.mod h1:kDfuBlDVsSj2MjrLEtRWtHlsWIFcGyB2RMO44Dc5GZA=
github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw=
@@ -11,7 +32,12 @@ github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2T
github.com/go-jose/go-jose/v4 v4.1.1/go.mod h1:BdsZGqgdO3b6tTc6LSE56wcDbMMLuPsw5d4ZD5f94kA=
github.com/golang/glog v1.2.5/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-runewidth v0.0.17 h1:78v8ZlW0bP43XfmAfPsdXcoNCelfMHsDmd/pkENfrjQ=
github.com/mattn/go-runewidth v0.0.17/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
github.com/spiffe/go-spiffe/v2 v2.5.0/go.mod h1:P+NxobPc6wXhVtINNtFjNWGBTreew1GBUCwT2wPmb7g=
github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4=
@@ -19,6 +45,11 @@ go.opentelemetry.io/contrib/detectors/gcp v1.36.0/go.mod h1:IbBN8uAIIx734PTonTPx
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/genproto/googleapis/api v0.0.0-20250707201910-8d1bb00bc6a7/go.mod h1:kXqgZtrWaf6qS3jZOCnCH7WYfrvFjkC51bM8fz3RsCA=