Files

318 lines
9.0 KiB
Go
Raw Permalink Normal View History

package ui
import (
"fmt"
"strings"
"time"
"charm.land/lipgloss/v2"
"github.com/mark3labs/kit/internal/ui/render"
"github.com/mark3labs/kit/internal/ui/style"
)
// --------------------------------------------------------------------------
// MessageItem implementations for ScrollList
// --------------------------------------------------------------------------
// TextMessageItem represents a completed text message (user or assistant)
// in the scrollback. It uses pre-rendered styled content from MessageRenderer.
type TextMessageItem struct {
id string
role string // "user" or "assistant"
content string // Raw content (for re-rendering if needed)
preRendered string // Pre-rendered styled content from MessageRenderer
timestamp time.Time
}
// NewStyledMessageItem creates a message item with pre-rendered styled content.
// This is the preferred way to create messages when you have styled content from MessageRenderer.
func NewStyledMessageItem(id string, role string, rawContent string, preRendered string) *TextMessageItem {
return &TextMessageItem{
id: id,
role: role,
content: rawContent,
preRendered: preRendered,
timestamp: time.Now(),
}
}
func (m *TextMessageItem) ID() string {
return m.id
}
func (m *TextMessageItem) Render(width int) string {
// If we have pre-rendered styled content, return it
if m.preRendered != "" {
return m.preRendered
}
// Fallback to simple formatting if no pre-rendered content
return m.renderContent(width)
}
func (m *TextMessageItem) Height() int {
rendered := m.Render(0) // Width doesn't matter since we use pre-rendered
if rendered == "" {
return 0
}
return strings.Count(rendered, "\n") + 1
}
func (m *TextMessageItem) renderContent(width int) string {
var parts []string
// Role indicator
if m.role == "user" {
parts = append(parts, "│ ▸ You")
} else {
parts = append(parts, "") // Assistant messages start without role
}
// Content with simple wrapping
2026-03-31 17:49:25 +03:00
contentWidth := max(width-4, 20)
2026-03-31 17:49:25 +03:00
for line := range strings.SplitSeq(m.content, "\n") {
if len(line) <= contentWidth {
parts = append(parts, "│ "+line)
} else {
// Basic wrap
for len(line) > contentWidth {
parts = append(parts, "│ "+line[:contentWidth])
line = line[contentWidth:]
}
if len(line) > 0 {
parts = append(parts, "│ "+line)
}
}
}
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 strings.Builder // Accumulated streaming content
timestamp time.Time
startTime time.Time // When streaming started (for live duration counter)
modelName string
streaming bool // true while actively streaming
finalDuration time.Duration // Frozen duration when complete
cachedRender string
cachedWidth int
}
// NewStreamingMessageItem creates a new streaming message item.
func NewStreamingMessageItem(id, role string, modelName string) *StreamingMessageItem {
now := time.Now()
return &StreamingMessageItem{
id: id,
role: role,
timestamp: now,
startTime: 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 {
// For reasoning, never cache - we need live duration updates
// For assistant, cache is OK
if s.role != "reasoning" && s.cachedWidth == width && s.cachedRender != "" {
return s.cachedRender
}
var rendered string
if s.role == "reasoning" {
// Calculate duration in milliseconds for render.ReasoningBlock
var durationMs int64
if s.finalDuration > 0 {
durationMs = s.finalDuration.Milliseconds()
} else if !s.startTime.IsZero() {
durationMs = time.Since(s.startTime).Milliseconds()
}
ty := createTypography(style.GetTheme())
rendered = render.ReasoningBlock(s.content.String(), durationMs, width, ty, style.GetTheme())
} else {
// Render as assistant message
rendered = render.AssistantBlock(s.content.String(), width, style.GetTheme())
}
// Cache and return (but reasoning is never cached due to live duration)
if s.role != "reasoning" {
s.cachedRender = rendered
s.cachedWidth = width
}
return rendered
}
// Height returns the number of lines.
func (s *StreamingMessageItem) Height() int {
// For reasoning blocks, cachedRender is never populated (rendering is
// width-independent and includes a live timer). Fall back to Render(0)
// so callers always get the correct height.
rendered := s.cachedRender
if rendered == "" {
rendered = s.Render(0)
}
if rendered == "" {
return 0
}
return strings.Count(rendered, "\n") + 1
}
// AppendChunk adds a content chunk and invalidates the render cache.
func (s *StreamingMessageItem) AppendChunk(chunk string) {
s.content.WriteString(chunk)
s.cachedWidth = 0 // Invalidate cache
}
// MarkComplete marks the streaming message as complete and freezes the duration.
func (s *StreamingMessageItem) MarkComplete() {
s.streaming = false
// Freeze the duration for reasoning blocks
if s.role == "reasoning" && !s.startTime.IsZero() {
s.finalDuration = time.Since(s.startTime)
}
}
// --------------------------------------------------------------------------
// StreamingBashOutputItem - Live bash command output
// --------------------------------------------------------------------------
// StreamingBashOutputItem represents live bash command output.
type StreamingBashOutputItem struct {
id string
command string
stdoutLines []string
stderrLines []string
maxLines int
complete bool
cachedRender string
cachedWidth int
}
// NewStreamingBashOutputItem creates a new streaming bash output item.
func NewStreamingBashOutputItem(id string, command string) *StreamingBashOutputItem {
return &StreamingBashOutputItem{
id: id,
command: command,
stdoutLines: make([]string, 0),
stderrLines: make([]string, 0),
maxLines: 100, // Cap lines to prevent memory issues
complete: false,
}
}
func (m *StreamingBashOutputItem) ID() string {
return m.id
}
func (m *StreamingBashOutputItem) Render(width int) string {
// Return cached if width matches and complete
if m.complete && m.cachedWidth == width && m.cachedRender != "" {
return m.cachedRender
}
theme := style.GetTheme()
var parts []string
// Header with command
if m.command != "" {
headerStyle := style.GetCachedStyles().BashHeader
parts = append(parts, headerStyle.Render(fmt.Sprintf("▸ %s", m.command)))
}
const lineIndent = " "
lineWidth := width - len(lineIndent)
// Stdout lines
if len(m.stdoutLines) > 0 {
outputStyle := lipgloss.NewStyle().
Foreground(theme.Text).
Background(theme.CodeBg).
PaddingLeft(1).
Width(lineWidth)
for _, line := range m.stdoutLines {
parts = append(parts, lineIndent+outputStyle.Render(line))
}
}
// Stderr lines
if len(m.stderrLines) > 0 {
stderrStyle := lipgloss.NewStyle().
Foreground(theme.Error).
Background(theme.CodeBg).
PaddingLeft(1).
Width(lineWidth)
for _, line := range m.stderrLines {
parts = append(parts, lineIndent+stderrStyle.Render(line))
}
}
result := strings.Join(parts, "\n")
if m.complete {
m.cachedRender = result
m.cachedWidth = width
}
return result
}
func (m *StreamingBashOutputItem) Height() int {
if m.cachedRender != "" {
return strings.Count(m.cachedRender, "\n") + 1
}
// Estimate: command header + stdout + stderr
return 1 + len(m.stdoutLines) + len(m.stderrLines)
}
// AppendStdout adds a stdout line to the output.
func (m *StreamingBashOutputItem) AppendStdout(line string) {
m.stdoutLines = append(m.stdoutLines, line)
// Cap lines
if len(m.stdoutLines) > m.maxLines {
m.stdoutLines = m.stdoutLines[len(m.stdoutLines)-m.maxLines:]
}
m.cachedWidth = 0 // Invalidate cache
}
// AppendStderr adds a stderr line to the output.
func (m *StreamingBashOutputItem) AppendStderr(line string) {
m.stderrLines = append(m.stderrLines, line)
// Cap lines
if len(m.stderrLines) > m.maxLines {
m.stderrLines = m.stderrLines[len(m.stderrLines)-m.maxLines:]
}
m.cachedWidth = 0 // Invalidate cache
}
// MarkComplete marks the bash output as complete.
func (m *StreamingBashOutputItem) MarkComplete() {
m.complete = true
}
// --------------------------------------------------------------------------
// --------------------------------------------------------------------------
// Helper: generateMessageID
// --------------------------------------------------------------------------
var messageCounter = 0
func generateMessageID() string {
messageCounter++
return fmt.Sprintf("msg-%d-%d", time.Now().UnixNano(), messageCounter)
}