2026-03-31 16:12:30 +03:00
|
|
|
package ui
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"fmt"
|
|
|
|
|
"strings"
|
|
|
|
|
"time"
|
2026-03-31 16:35:43 +03:00
|
|
|
|
|
|
|
|
"charm.land/lipgloss/v2"
|
2026-04-01 13:54:10 +03:00
|
|
|
|
2026-04-01 14:59:27 +03:00
|
|
|
"github.com/mark3labs/kit/internal/ui/render"
|
2026-04-01 13:54:10 +03:00
|
|
|
"github.com/mark3labs/kit/internal/ui/style"
|
2026-03-31 16:12:30 +03:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// --------------------------------------------------------------------------
|
|
|
|
|
// 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 16:12:30 +03:00
|
|
|
|
2026-03-31 17:49:25 +03:00
|
|
|
for line := range strings.SplitSeq(m.content, "\n") {
|
2026-03-31 16:12:30 +03:00
|
|
|
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")
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 16:35:43 +03:00
|
|
|
// --------------------------------------------------------------------------
|
|
|
|
|
// 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 {
|
2026-03-31 17:33:51 +03:00
|
|
|
id string
|
2026-04-22 16:48:17 +03:00
|
|
|
role string // "assistant" or "reasoning"
|
|
|
|
|
content strings.Builder // Accumulated streaming content
|
2026-03-31 17:33:51 +03:00
|
|
|
timestamp time.Time
|
|
|
|
|
startTime time.Time // When streaming started (for live duration counter)
|
|
|
|
|
modelName string
|
|
|
|
|
streaming bool // true while actively streaming
|
2026-03-31 16:40:41 +03:00
|
|
|
finalDuration time.Duration // Frozen duration when complete
|
2026-03-31 17:33:51 +03:00
|
|
|
cachedRender string
|
|
|
|
|
cachedWidth int
|
2026-03-31 16:35:43 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// NewStreamingMessageItem creates a new streaming message item.
|
|
|
|
|
func NewStreamingMessageItem(id, role string, modelName string) *StreamingMessageItem {
|
2026-03-31 16:40:41 +03:00
|
|
|
now := time.Now()
|
2026-03-31 16:35:43 +03:00
|
|
|
return &StreamingMessageItem{
|
|
|
|
|
id: id,
|
|
|
|
|
role: role,
|
2026-03-31 16:40:41 +03:00
|
|
|
timestamp: now,
|
|
|
|
|
startTime: now,
|
2026-03-31 16:35:43 +03:00
|
|
|
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 {
|
2026-03-31 16:40:41 +03:00
|
|
|
// For reasoning, never cache - we need live duration updates
|
|
|
|
|
// For assistant, cache is OK
|
|
|
|
|
if s.role != "reasoning" && s.cachedWidth == width && s.cachedRender != "" {
|
2026-03-31 16:35:43 +03:00
|
|
|
return s.cachedRender
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var rendered string
|
|
|
|
|
if s.role == "reasoning" {
|
2026-04-01 14:59:27 +03:00
|
|
|
// Calculate duration in milliseconds for render.ReasoningBlock
|
|
|
|
|
var durationMs int64
|
2026-03-31 16:40:41 +03:00
|
|
|
if s.finalDuration > 0 {
|
2026-04-01 14:59:27 +03:00
|
|
|
durationMs = s.finalDuration.Milliseconds()
|
2026-03-31 16:40:41 +03:00
|
|
|
} else if !s.startTime.IsZero() {
|
2026-04-01 14:59:27 +03:00
|
|
|
durationMs = time.Since(s.startTime).Milliseconds()
|
2026-03-31 16:40:41 +03:00
|
|
|
}
|
2026-04-01 14:59:27 +03:00
|
|
|
ty := createTypography(style.GetTheme())
|
2026-04-22 16:48:17 +03:00
|
|
|
rendered = render.ReasoningBlock(s.content.String(), durationMs, width, ty, style.GetTheme())
|
2026-03-31 16:35:43 +03:00
|
|
|
} else {
|
|
|
|
|
// Render as assistant message
|
2026-04-22 16:48:17 +03:00
|
|
|
rendered = render.AssistantBlock(s.content.String(), width, style.GetTheme())
|
2026-03-31 16:35:43 +03:00
|
|
|
}
|
|
|
|
|
|
2026-03-31 16:40:41 +03:00
|
|
|
// Cache and return (but reasoning is never cached due to live duration)
|
|
|
|
|
if s.role != "reasoning" {
|
|
|
|
|
s.cachedRender = rendered
|
|
|
|
|
s.cachedWidth = width
|
|
|
|
|
}
|
2026-03-31 16:35:43 +03:00
|
|
|
return rendered
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Height returns the number of lines.
|
|
|
|
|
func (s *StreamingMessageItem) Height() int {
|
2026-04-01 18:15:04 +03:00
|
|
|
// 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 == "" {
|
2026-03-31 16:35:43 +03:00
|
|
|
return 0
|
|
|
|
|
}
|
2026-04-01 18:15:04 +03:00
|
|
|
return strings.Count(rendered, "\n") + 1
|
2026-03-31 16:35:43 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// AppendChunk adds a content chunk and invalidates the render cache.
|
|
|
|
|
func (s *StreamingMessageItem) AppendChunk(chunk string) {
|
2026-04-22 16:48:17 +03:00
|
|
|
s.content.WriteString(chunk)
|
2026-03-31 16:35:43 +03:00
|
|
|
s.cachedWidth = 0 // Invalidate cache
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 16:40:41 +03:00
|
|
|
// MarkComplete marks the streaming message as complete and freezes the duration.
|
2026-03-31 16:35:43 +03:00
|
|
|
func (s *StreamingMessageItem) MarkComplete() {
|
|
|
|
|
s.streaming = false
|
2026-03-31 16:40:41 +03:00
|
|
|
// Freeze the duration for reasoning blocks
|
|
|
|
|
if s.role == "reasoning" && !s.startTime.IsZero() {
|
|
|
|
|
s.finalDuration = time.Since(s.startTime)
|
|
|
|
|
}
|
2026-03-31 16:35:43 +03:00
|
|
|
}
|
|
|
|
|
|
2026-03-31 17:38:03 +03:00
|
|
|
// --------------------------------------------------------------------------
|
|
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 13:54:10 +03:00
|
|
|
theme := style.GetTheme()
|
2026-03-31 17:38:03 +03:00
|
|
|
var parts []string
|
|
|
|
|
|
|
|
|
|
// Header with command
|
|
|
|
|
if m.command != "" {
|
2026-04-22 16:48:17 +03:00
|
|
|
headerStyle := style.GetCachedStyles().BashHeader
|
2026-03-31 17:38:03 +03:00
|
|
|
parts = append(parts, headerStyle.Render(fmt.Sprintf("▸ %s", m.command)))
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 17:42:32 +03:00
|
|
|
const lineIndent = " "
|
|
|
|
|
lineWidth := width - len(lineIndent)
|
|
|
|
|
|
2026-03-31 17:38:03 +03:00
|
|
|
// Stdout lines
|
|
|
|
|
if len(m.stdoutLines) > 0 {
|
|
|
|
|
outputStyle := lipgloss.NewStyle().
|
|
|
|
|
Foreground(theme.Text).
|
|
|
|
|
Background(theme.CodeBg).
|
2026-03-31 17:42:32 +03:00
|
|
|
PaddingLeft(1).
|
|
|
|
|
Width(lineWidth)
|
2026-03-31 17:38:03 +03:00
|
|
|
for _, line := range m.stdoutLines {
|
2026-03-31 17:42:32 +03:00
|
|
|
parts = append(parts, lineIndent+outputStyle.Render(line))
|
2026-03-31 17:38:03 +03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Stderr lines
|
|
|
|
|
if len(m.stderrLines) > 0 {
|
|
|
|
|
stderrStyle := lipgloss.NewStyle().
|
|
|
|
|
Foreground(theme.Error).
|
|
|
|
|
Background(theme.CodeBg).
|
2026-03-31 17:42:32 +03:00
|
|
|
PaddingLeft(1).
|
|
|
|
|
Width(lineWidth)
|
2026-03-31 17:38:03 +03:00
|
|
|
for _, line := range m.stderrLines {
|
2026-03-31 17:42:32 +03:00
|
|
|
parts = append(parts, lineIndent+stderrStyle.Render(line))
|
2026-03-31 17:38:03 +03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 16:12:30 +03:00
|
|
|
// --------------------------------------------------------------------------
|
|
|
|
|
// --------------------------------------------------------------------------
|
|
|
|
|
// Helper: generateMessageID
|
|
|
|
|
// --------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
var messageCounter = 0
|
|
|
|
|
|
|
|
|
|
func generateMessageID() string {
|
|
|
|
|
messageCounter++
|
|
|
|
|
return fmt.Sprintf("msg-%d-%d", time.Now().UnixNano(), messageCounter)
|
|
|
|
|
}
|