mirror of
https://github.com/mark3labs/kit.git
synced 2026-06-14 03:30:26 +00:00
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:
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user