diff --git a/internal/ui/message_items.go b/internal/ui/message_items.go index 14640976..896447db 100644 --- a/internal/ui/message_items.go +++ b/internal/ui/message_items.go @@ -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) // -------------------------------------------------------------------------- diff --git a/internal/ui/model.go b/internal/ui/model.go index 54af6585..c3647545 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -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.