Skip to content

Commit 676a6b0

Browse files
committed
fix: replace manual ANSI escape sequences with Bubble Tea program for streaming display
Streaming text was flickering because displayContainer() used raw escape sequences (\033[%dF) to move the cursor and fmt.Println to repaint on every token. This bypassed Bubble Tea's synchronized output, cursor hiding, and atomic flush. Replace the manual approach with a dedicated tea.Program that runs for the lifetime of each streaming response. BT's renderer now handles all in-place updates flicker-free. One-shot messages (user, tool, system, error) continue to use simple fmt.Println since they are never redrawn.
1 parent 4434100 commit 676a6b0

File tree

2 files changed

+134
-54
lines changed

2 files changed

+134
-54
lines changed

internal/ui/cli.go

Lines changed: 52 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -19,16 +19,16 @@ import (
1919
// overall conversation flow between the user and AI assistants.
2020
type 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.
157157
func (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.
172173
func (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.
208209
func (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.
225227
func (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.
234249
func (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).
261277
func (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.
444463
func (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.
572569
func (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

internal/ui/streaming_display.go

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
package ui
2+
3+
import (
4+
"time"
5+
6+
tea "charm.land/bubbletea/v2"
7+
"charm.land/lipgloss/v2"
8+
)
9+
10+
// streamContentMsg carries updated content to the streaming display.
11+
type streamContentMsg string
12+
13+
// streamDoneMsg signals the streaming display to quit cleanly.
14+
type streamDoneMsg struct{}
15+
16+
// StreamingDisplay is a Bubble Tea model that renders streamed AI responses
17+
// using BT's synchronized output and cursor management for flicker-free
18+
// in-place updates. It replaces the manual ANSI escape sequence approach
19+
// previously used in displayContainer().
20+
type StreamingDisplay struct {
21+
content string
22+
messageRenderer *MessageRenderer
23+
compactRenderer *CompactRenderer
24+
compactMode bool
25+
width int
26+
modelName string
27+
timestamp time.Time
28+
}
29+
30+
// newStreamingDisplay creates a StreamingDisplay configured for the given
31+
// display mode and terminal width.
32+
func newStreamingDisplay(compactMode bool, width int, modelName string) *StreamingDisplay {
33+
return &StreamingDisplay{
34+
messageRenderer: NewMessageRenderer(width, false),
35+
compactRenderer: NewCompactRenderer(width, false),
36+
compactMode: compactMode,
37+
width: width,
38+
modelName: modelName,
39+
timestamp: time.Now(),
40+
}
41+
}
42+
43+
// Init implements tea.Model. No initial command is needed.
44+
func (m *StreamingDisplay) Init() tea.Cmd {
45+
return nil
46+
}
47+
48+
// Update implements tea.Model. It handles content updates and quit signals.
49+
func (m *StreamingDisplay) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
50+
switch msg := msg.(type) {
51+
case streamContentMsg:
52+
m.content = string(msg)
53+
return m, nil
54+
case streamDoneMsg:
55+
return m, tea.Quit
56+
case tea.WindowSizeMsg:
57+
m.width = msg.Width - 4 // Match CLI padding
58+
m.messageRenderer.SetWidth(m.width)
59+
m.compactRenderer.SetWidth(m.width)
60+
return m, nil
61+
}
62+
return m, nil
63+
}
64+
65+
// View implements tea.Model. It renders the current streaming content as a
66+
// fully styled assistant message. Bubble Tea handles the cursor management
67+
// and synchronized output to prevent flicker.
68+
func (m *StreamingDisplay) View() tea.View {
69+
var msg UIMessage
70+
if m.compactMode {
71+
msg = m.compactRenderer.RenderAssistantMessage(m.content, m.timestamp, m.modelName)
72+
} else {
73+
msg = m.messageRenderer.RenderAssistantMessage(m.content, m.timestamp, m.modelName)
74+
}
75+
76+
paddedContent := lipgloss.NewStyle().
77+
PaddingLeft(2).
78+
Width(m.width).
79+
Render(msg.Content)
80+
81+
return tea.NewView(paddedContent)
82+
}

0 commit comments

Comments
 (0)