feat: add live streaming text to ScrollList viewport

- Create StreamingMessageItem that accumulates chunks and re-renders
- Update StreamChunkEvent/ReasoningChunkEvent to append to StreamingMessageItem
- Enable live streaming display within ScrollList (iteratr-style)
- Mark streaming items as complete on ResponseCompleteEvent
- Reasoning and assistant text now stream in real-time in the viewport
This commit is contained in:
Ed Zynda
2026-03-31 16:35:43 +03:00
parent 290c5a4774
commit e631fc1b17
2 changed files with 139 additions and 9 deletions
+84
View File
@@ -4,6 +4,8 @@ import (
"fmt"
"strings"
"time"
"charm.land/lipgloss/v2"
)
// --------------------------------------------------------------------------
@@ -100,6 +102,88 @@ func (m *TextMessageItem) renderContent(width int) string {
return strings.Join(parts, "\n")
}
// --------------------------------------------------------------------------
// StreamingMessageItem - Live streaming assistant/reasoning text
// --------------------------------------------------------------------------
// StreamingMessageItem represents actively streaming assistant or reasoning text.
// It accumulates content chunks and re-renders on each update for live display.
type StreamingMessageItem struct {
id string
role string // "assistant" or "reasoning"
content string // Accumulated streaming content
timestamp time.Time
modelName string
streaming bool // true while actively streaming
cachedRender string
cachedWidth int
}
// NewStreamingMessageItem creates a new streaming message item.
func NewStreamingMessageItem(id, role string, modelName string) *StreamingMessageItem {
return &StreamingMessageItem{
id: id,
role: role,
timestamp: time.Now(),
modelName: modelName,
streaming: true,
}
}
// ID returns the unique identifier.
func (s *StreamingMessageItem) ID() string {
return s.id
}
// Render renders the streaming message with live content.
func (s *StreamingMessageItem) Render(width int) string {
// Return cached render if width matches and cache is valid
if s.cachedWidth == width && s.cachedRender != "" {
return s.cachedRender
}
// Get renderer from context
renderer := newMessageRenderer(width, false)
var rendered string
if s.role == "reasoning" {
// Render as reasoning/thinking block
theme := GetTheme()
mutedStyle := lipgloss.NewStyle().Foreground(theme.Muted)
ty := createTypography(theme)
content := strings.TrimLeft(s.content, " \t\n")
rendered = styleMarginBottom1.Render(mutedStyle.Render(ty.Italic(content)))
} else {
// Render as assistant message
msg := renderer.RenderAssistantMessage(s.content, s.timestamp, s.modelName)
rendered = msg.Content
}
// Cache and return
s.cachedRender = rendered
s.cachedWidth = width
return rendered
}
// Height returns the number of lines.
func (s *StreamingMessageItem) Height() int {
if s.cachedRender == "" {
return 0
}
return strings.Count(s.cachedRender, "\n") + 1
}
// AppendChunk adds a content chunk and invalidates the render cache.
func (s *StreamingMessageItem) AppendChunk(chunk string) {
s.content += chunk
s.cachedWidth = 0 // Invalidate cache
}
// MarkComplete marks the streaming message as complete.
func (s *StreamingMessageItem) MarkComplete() {
s.streaming = false
}
// --------------------------------------------------------------------------
// SystemMessageItem - System messages (commands, info, errors)
// --------------------------------------------------------------------------
+55 -9
View File
@@ -1445,19 +1445,27 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
case app.ReasoningChunkEvent:
// Forward to stream component for display rendering
if m.stream != nil {
updated, cmd := m.stream.Update(msg)
m.stream, _ = updated.(streamComponentIface)
cmds = append(cmds, cmd)
}
// Also update/create StreamingMessageItem in ScrollList for live display
m.appendStreamingChunk("reasoning", msg.Delta)
case app.StreamChunkEvent:
// Forward to stream component for display rendering
if m.stream != nil {
updated, cmd := m.stream.Update(msg)
m.stream, _ = updated.(streamComponentIface)
cmds = append(cmds, cmd)
}
// Also update/create StreamingMessageItem in ScrollList for live display
m.appendStreamingChunk("assistant", msg.Content)
case app.ToolCallStartedEvent:
// Flush any accumulated streaming text to scrollback first (streaming
// always completes before tool calls fire). The tool call itself is
@@ -1520,18 +1528,27 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case app.ResponseCompleteEvent:
// This event fires for both streaming and non-streaming paths.
// In streaming mode, flush the accumulated stream content to scrollback.
// In streaming mode, mark the StreamingMessageItem as complete.
// In non-streaming mode (no stream content accumulated), print the text.
hasStreamContent := m.stream != nil && m.stream.GetRenderedContent() != ""
if hasStreamContent {
// Flush stream content to scrollback immediately
m.flushStreamContent()
} else if strings.TrimSpace(msg.Content) != "" {
m.printAssistantMessage(msg.Content)
if m.stream != nil {
m.stream.Reset()
// Check if we have an active StreamingMessageItem
hasStreamingItem := false
if len(m.messages) > 0 {
if streamMsg, ok := m.messages[len(m.messages)-1].(*StreamingMessageItem); ok {
streamMsg.MarkComplete()
hasStreamingItem = true
}
}
// Reset stream component
if m.stream != nil {
m.stream.Reset()
}
// If no streaming item exists and we have content, print it as a regular message
if !hasStreamingItem && strings.TrimSpace(msg.Content) != "" {
m.printAssistantMessage(msg.Content)
}
case app.MessageCreatedEvent:
// Informational — no action needed by parent.
@@ -2914,6 +2931,35 @@ func (m *AppModel) flushStreamAndPendingUserMessages() {
m.refreshContent()
}
// appendStreamingChunk updates or creates a StreamingMessageItem in the ScrollList.
// This enables live streaming text display within the ScrollList viewport (iteratr-style).
func (m *AppModel) appendStreamingChunk(role, content string) {
// Find the last message
var lastMsg MessageItem
if len(m.messages) > 0 {
lastMsg = m.messages[len(m.messages)-1]
}
// If last message is a StreamingMessageItem with matching role, append to it
if streamMsg, ok := lastMsg.(*StreamingMessageItem); ok && streamMsg.role == role {
streamMsg.AppendChunk(content)
// Auto-scroll to bottom if enabled
if m.scrollList != nil && m.scrollList.autoScroll {
m.scrollList.GotoBottom()
}
return
}
// Otherwise, create a new StreamingMessageItem
id := fmt.Sprintf("streaming-%s-%d", role, len(m.messages))
newMsg := NewStreamingMessageItem(id, role, m.modelName)
newMsg.AppendChunk(content)
m.messages = append(m.messages, newMsg)
// Refresh ScrollList and scroll to bottom
m.refreshContent()
}
// appendScrollback adds rendered content to the scrollback buffer. The content
// will be emitted via tea.Println when drainScrollback is called at the end of
// the current Update cycle.