mirror of
https://github.com/cline/cline.git
synced 2026-09-04 11:44:01 +08:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 020b13c87d |
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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=
|
||||
|
||||
Reference in New Issue
Block a user