@@ -19,16 +19,16 @@ import (
1919// overall conversation flow between the user and AI assistants.
2020type CLI struct {
2121 messageRenderer * MessageRenderer
22- compactRenderer * CompactRenderer // Add compact renderer
22+ compactRenderer * CompactRenderer
2323 messageContainer * MessageContainer
2424 usageTracker * UsageTracker
2525 width int
2626 height int
27- compactMode bool // Add compact mode flag
28- debug bool // Add debug mode flag
29- modelName string // Store current model name
30- lastStreamHeight int // track how far back we need to move the cursor to overwrite streaming messages
31- usageDisplayed bool // track if usage info was displayed after last assistant message
27+ compactMode bool
28+ debug bool
29+ modelName string
30+ streamProgram * tea. Program // active Bubble Tea program for streaming display
31+ streamDone chan struct {} // closed when the streaming program exits
3232}
3333
3434// NewCLI creates and initializes a new CLI instance with the specified display modes.
@@ -81,8 +81,8 @@ func (c *CLI) GetPrompt() (string, error) {
8181 // Usage info is now displayed immediately after responses via DisplayUsageAfterResponse()
8282 // No need to display it here to avoid duplication
8383
84+ c .finishStreaming () // ensure any active streaming display is stopped
8485 c .messageContainer .messages = nil // clear previous messages (they should have been printed already)
85- c .lastStreamHeight = 0 // Reset last stream height for new prompt
8686
8787 // No divider needed - removed for cleaner appearance
8888
@@ -155,6 +155,7 @@ func (c *CLI) DisplayAssistantMessage(message string) error {
155155// with the specified model name shown in the message header. The message is
156156// formatted according to the current display mode and includes timestamp information.
157157func (c * CLI ) DisplayAssistantMessageWithModel (message , modelName string ) error {
158+ c .finishStreaming () // ensure streaming display is stopped before printing
158159 var msg UIMessage
159160 if c .compactMode {
160161 msg = c .compactRenderer .RenderAssistantMessage (message , time .Now (), modelName )
@@ -170,9 +171,8 @@ func (c *CLI) DisplayAssistantMessageWithModel(message, modelName string) error
170171// is being executed. Shows the tool name and its arguments formatted appropriately
171172// for the current display mode. This is typically shown while a tool is running.
172173func (c * CLI ) DisplayToolCallMessage (toolName , toolArgs string ) {
173-
174+ c . finishStreaming () // ensure any active streaming display is stopped
174175 c .messageContainer .messages = nil // clear previous messages (they should have been printed already)
175- c .lastStreamHeight = 0 // Reset last stream height for new prompt
176176
177177 var msg UIMessage
178178 if c .compactMode {
@@ -203,35 +203,51 @@ func (c *CLI) DisplayToolMessage(toolName, toolArgs, toolResult string, isError
203203}
204204
205205// StartStreamingMessage initializes a new streaming message display for real-time
206- // AI responses. The message will be progressively updated as content arrives.
206+ // AI responses. A Bubble Tea program is started to handle flicker-free in-place
207+ // updates using synchronized output and proper cursor management.
207208// The modelName parameter indicates which AI model is generating the response.
208209func (c * CLI ) StartStreamingMessage (modelName string ) {
209- // Add an empty assistant message that we'll update during streaming
210- var msg UIMessage
211- if c .compactMode {
212- msg = c .compactRenderer .RenderAssistantMessage ("" , time .Now (), modelName )
213- } else {
214- msg = c .messageRenderer .RenderAssistantMessage ("" , time .Now (), modelName )
215- }
216- msg .Streaming = true
217- c .lastStreamHeight = 0 // Reset last stream height for new message
218- c .messageContainer .AddMessage (msg )
219- c .displayContainer ()
210+ c .finishStreaming () // stop any previous streaming program
211+
212+ model := newStreamingDisplay (c .compactMode , c .width , modelName )
213+ c .streamDone = make (chan struct {})
214+ c .streamProgram = tea .NewProgram (model , tea .WithInput (nil ))
215+
216+ done := c .streamDone
217+ p := c .streamProgram
218+ go func () {
219+ _ , _ = p .Run ()
220+ close (done )
221+ }()
220222}
221223
222224// UpdateStreamingMessage updates the currently streaming message with new content.
223225// This method should be called after StartStreamingMessage to progressively display
224226// AI responses as they are generated in real-time.
225227func (c * CLI ) UpdateStreamingMessage (content string ) {
226- // Update the last message (which should be the streaming assistant message)
227- c .messageContainer .UpdateLastMessage (content )
228- c .displayContainer ()
228+ if c .streamProgram != nil {
229+ c .streamProgram .Send (streamContentMsg (content ))
230+ }
231+ }
232+
233+ // finishStreaming stops the active streaming Bubble Tea program, if any.
234+ // It sends a quit message and waits for the program to exit cleanly.
235+ // This is idempotent and safe to call when no streaming is active.
236+ func (c * CLI ) finishStreaming () {
237+ if c .streamProgram == nil {
238+ return
239+ }
240+ c .streamProgram .Send (streamDoneMsg {})
241+ <- c .streamDone // wait for the program goroutine to exit
242+ c .streamProgram = nil
243+ c .streamDone = nil
229244}
230245
231246// DisplayError renders and displays an error message with distinctive formatting
232247// to ensure visibility. The error is timestamped and styled according to the
233248// current display mode's error theme.
234249func (c * CLI ) DisplayError (err error ) {
250+ c .finishStreaming () // ensure streaming display is stopped before printing
235251 var msg UIMessage
236252 if c .compactMode {
237253 msg = c .compactRenderer .RenderErrorMessage (err .Error (), time .Now ())
@@ -259,6 +275,7 @@ func (c *CLI) DisplayInfo(message string) {
259275// DisplayCancellation displays a system message indicating that the current
260276// AI generation has been cancelled by the user (typically via ESC key).
261277func (c * CLI ) DisplayCancellation () {
278+ c .finishStreaming () // ensure streaming display is stopped before printing
262279 var msg UIMessage
263280 if c .compactMode {
264281 msg = c .compactRenderer .RenderSystemMessage ("Generation cancelled by user (ESC pressed)" , time .Now ())
@@ -440,15 +457,14 @@ func (c *CLI) ClearMessages() {
440457 c .displayContainer ()
441458}
442459
443- // displayContainer renders and displays the message container
460+ // displayContainer renders and displays the message container for one-shot
461+ // (non-streaming) messages. Streaming messages are handled separately by a
462+ // dedicated Bubble Tea program for flicker-free updates.
444463func (c * CLI ) displayContainer () {
445-
446- // Add left padding to the entire container
447464 content := c .messageContainer .Render ()
448465
449- // Check if we're displaying a user message
450- // User messages should not have additional left padding since they're right-aligned
451- // This only applies in non-compact mode
466+ // User messages should not have additional left padding since they're right-aligned.
467+ // This only applies in non-compact mode.
452468 paddingLeft := 2
453469 if ! c .compactMode && len (c .messageContainer .messages ) > 0 {
454470 lastMessage := c .messageContainer .messages [len (c .messageContainer .messages )- 1 ]
@@ -459,32 +475,13 @@ func (c *CLI) displayContainer() {
459475
460476 paddedContent := lipgloss .NewStyle ().
461477 PaddingLeft (paddingLeft ).
462- Width (c .width ). // overwrite (no content) while agent is streaming
478+ Width (c .width ).
463479 Render (content )
464480
465- if c .lastStreamHeight > 0 {
466- // Move cursor up by the height of the last streamed message
467- fmt .Printf ("\033 [%dF" , c .lastStreamHeight )
468- } else if c .usageDisplayed {
469- // If we're not overwriting a streaming message but usage was displayed,
470- // move up to account for the usage info (2 lines: content + padding)
471- fmt .Printf ("\033 [2F" )
472- c .usageDisplayed = false
473- }
474-
475481 fmt .Println (paddedContent )
476482
477- // clear message history except the "in-progress" message
478- if len (c .messageContainer .messages ) > 0 {
479- // keep the last message, clear the rest (in case of streaming)
480- last := c .messageContainer .messages [len (c .messageContainer .messages )- 1 ]
481- c .messageContainer .messages = []UIMessage {}
482- if last .Streaming {
483- // If the last message is still streaming, we keep it
484- c .messageContainer .messages = append (c .messageContainer .messages , last )
485- c .lastStreamHeight = lipgloss .Height (paddedContent )
486- }
487- }
483+ // Clear messages after display; one-shot messages don't need to persist.
484+ c .messageContainer .messages = nil
488485}
489486
490487// UpdateUsage estimates and records token usage based on input and output text.
@@ -570,6 +567,8 @@ func (c *CLI) ResetUsageStats() {
570567// following an AI response. This provides real-time feedback about the cost and
571568// token consumption of each interaction.
572569func (c * CLI ) DisplayUsageAfterResponse () {
570+ c .finishStreaming () // ensure streaming display is stopped before printing usage
571+
573572 if c .usageTracker == nil {
574573 return
575574 }
@@ -580,8 +579,7 @@ func (c *CLI) DisplayUsageAfterResponse() {
580579 PaddingLeft (2 ).
581580 PaddingTop (1 ).
582581 Render (usageInfo )
583- fmt .Print (paddedUsage )
584- c .usageDisplayed = true
582+ fmt .Println (paddedUsage )
585583 }
586584}
587585
0 commit comments