mirror of
https://github.com/mark3labs/kit.git
synced 2026-06-14 03:30:26 +00:00
518 lines
17 KiB
Go
518 lines
17 KiB
Go
package ui
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/charmbracelet/lipgloss"
|
|
)
|
|
|
|
// CompactRenderer handles rendering messages in a space-efficient compact format,
|
|
// optimized for terminals with limited vertical space. It displays messages with
|
|
// minimal decorations while maintaining readability and essential information.
|
|
type CompactRenderer struct {
|
|
width int
|
|
debug bool
|
|
}
|
|
|
|
// NewCompactRenderer creates and initializes a new CompactRenderer with the specified
|
|
// terminal width and debug mode setting. The width parameter determines line wrapping,
|
|
// while debug enables additional diagnostic output in rendered messages.
|
|
func NewCompactRenderer(width int, debug bool) *CompactRenderer {
|
|
return &CompactRenderer{
|
|
width: width,
|
|
debug: debug,
|
|
}
|
|
}
|
|
|
|
// SetWidth updates the terminal width for the renderer, affecting how content
|
|
// is wrapped and formatted in subsequent render operations.
|
|
func (r *CompactRenderer) SetWidth(width int) {
|
|
r.width = width
|
|
}
|
|
|
|
// RenderUserMessage renders a user's input message in compact format with a
|
|
// distinctive symbol (>) and label. The content is formatted to preserve structure
|
|
// while minimizing vertical space usage. Returns a UIMessage with formatted content
|
|
// and metadata.
|
|
func (r *CompactRenderer) RenderUserMessage(content string, timestamp time.Time) UIMessage {
|
|
theme := getTheme()
|
|
symbol := lipgloss.NewStyle().Foreground(theme.Secondary).Render(">")
|
|
label := lipgloss.NewStyle().Foreground(theme.Secondary).Bold(true).Render("User")
|
|
|
|
// Format content for user messages (preserve formatting, no truncation)
|
|
compactContent := r.formatUserAssistantContent(content)
|
|
|
|
// Handle multi-line content
|
|
lines := strings.Split(compactContent, "\n")
|
|
var formattedLines []string
|
|
|
|
for i, line := range lines {
|
|
if i == 0 {
|
|
// First line includes symbol and label
|
|
formattedLines = append(formattedLines, fmt.Sprintf("%s %s %s", symbol, label, line))
|
|
} else {
|
|
// Subsequent lines without indentation for compact mode
|
|
formattedLines = append(formattedLines, line)
|
|
}
|
|
}
|
|
|
|
return UIMessage{
|
|
Type: UserMessage,
|
|
Content: strings.Join(formattedLines, "\n"),
|
|
Height: len(formattedLines),
|
|
Timestamp: timestamp,
|
|
}
|
|
}
|
|
|
|
// RenderAssistantMessage renders an AI assistant's response in compact format with
|
|
// a distinctive symbol (<) and the model name as label. Empty content is displayed
|
|
// as "(no output)". Returns a UIMessage with formatted content and metadata.
|
|
func (r *CompactRenderer) RenderAssistantMessage(content string, timestamp time.Time, modelName string) UIMessage {
|
|
theme := getTheme()
|
|
symbol := lipgloss.NewStyle().Foreground(theme.Primary).Render("<")
|
|
|
|
// Use the full model name, fallback to "Assistant" if empty
|
|
if modelName == "" {
|
|
modelName = "Assistant"
|
|
}
|
|
label := lipgloss.NewStyle().Foreground(theme.Primary).Bold(true).Render(modelName)
|
|
|
|
// Format content for assistant messages (preserve formatting, no truncation)
|
|
compactContent := r.formatUserAssistantContent(content)
|
|
if compactContent == "" {
|
|
compactContent = lipgloss.NewStyle().Foreground(theme.Muted).Italic(true).Render("(no output)")
|
|
}
|
|
|
|
// Handle multi-line content
|
|
lines := strings.Split(compactContent, "\n")
|
|
var formattedLines []string
|
|
|
|
for i, line := range lines {
|
|
if i == 0 {
|
|
// First line includes symbol and label
|
|
formattedLines = append(formattedLines, fmt.Sprintf("%s %s %s", symbol, label, line))
|
|
} else {
|
|
// Subsequent lines without indentation for compact mode
|
|
formattedLines = append(formattedLines, line)
|
|
}
|
|
}
|
|
|
|
return UIMessage{
|
|
Type: AssistantMessage,
|
|
Content: strings.Join(formattedLines, "\n"),
|
|
Height: len(formattedLines),
|
|
Timestamp: timestamp,
|
|
}
|
|
}
|
|
|
|
// RenderToolCallMessage renders a tool call notification in compact format, showing
|
|
// the tool being executed with its arguments in a single line. The tool name is
|
|
// highlighted and arguments are displayed in a muted color for visual distinction.
|
|
func (r *CompactRenderer) RenderToolCallMessage(toolName, toolArgs string, timestamp time.Time) UIMessage {
|
|
theme := getTheme()
|
|
symbol := lipgloss.NewStyle().Foreground(theme.Tool).Render("[")
|
|
label := lipgloss.NewStyle().Foreground(theme.Tool).Bold(true).Render(toolName)
|
|
|
|
// Format args for compact display
|
|
argsDisplay := r.formatToolArgs(toolArgs)
|
|
if argsDisplay != "" {
|
|
argsDisplay = lipgloss.NewStyle().Foreground(theme.Muted).Render(argsDisplay)
|
|
}
|
|
|
|
line := fmt.Sprintf("%s %s %s", symbol, label, argsDisplay)
|
|
|
|
return UIMessage{
|
|
Type: ToolCallMessage,
|
|
Content: line,
|
|
Height: 1,
|
|
Timestamp: timestamp,
|
|
}
|
|
}
|
|
|
|
// RenderToolMessage renders the result of a tool execution in compact format,
|
|
// displaying the outcome with appropriate styling based on success or error status.
|
|
// Results are limited to 5 lines to maintain compact display while preserving key information.
|
|
func (r *CompactRenderer) RenderToolMessage(toolName, toolArgs, toolResult string, isError bool) UIMessage {
|
|
theme := getTheme()
|
|
symbol := lipgloss.NewStyle().Foreground(theme.Muted).Render("]")
|
|
|
|
// Determine result type and styling
|
|
var label string
|
|
var content string
|
|
var labelText string
|
|
|
|
if isError {
|
|
labelText = "Error"
|
|
label = lipgloss.NewStyle().Foreground(theme.Muted).Bold(true).Render(labelText)
|
|
content = lipgloss.NewStyle().Foreground(theme.Muted).Render(r.formatToolResult(toolResult))
|
|
} else {
|
|
// Determine result type based on tool and content
|
|
labelText = r.determineResultType(toolName, toolResult)
|
|
label = lipgloss.NewStyle().Foreground(theme.Muted).Bold(true).Render(labelText)
|
|
content = lipgloss.NewStyle().Foreground(theme.Muted).Render(r.formatToolResult(toolResult))
|
|
|
|
if r.formatToolResult(toolResult) == "" {
|
|
content = lipgloss.NewStyle().Foreground(theme.Muted).Italic(true).Render("(no output)")
|
|
}
|
|
}
|
|
|
|
// Handle multi-line tool results
|
|
contentLines := strings.Split(content, "\n")
|
|
var formattedLines []string
|
|
|
|
for i, line := range contentLines {
|
|
if i == 0 {
|
|
// First line includes symbol and label
|
|
formattedLines = append(formattedLines, fmt.Sprintf("%s %s %s", symbol, label, line))
|
|
} else {
|
|
// Subsequent lines without indentation for compact mode
|
|
formattedLines = append(formattedLines, line)
|
|
}
|
|
}
|
|
|
|
return UIMessage{
|
|
Type: ToolMessage,
|
|
Content: strings.Join(formattedLines, "\n"),
|
|
Height: len(formattedLines),
|
|
}
|
|
}
|
|
|
|
// RenderSystemMessage renders a system notification or informational message in
|
|
// compact format with a distinctive symbol (*) and "System" label. Content is
|
|
// formatted to fit on a single line for minimal space usage.
|
|
func (r *CompactRenderer) RenderSystemMessage(content string, timestamp time.Time) UIMessage {
|
|
theme := getTheme()
|
|
symbol := lipgloss.NewStyle().Foreground(theme.System).Render("*")
|
|
label := lipgloss.NewStyle().Foreground(theme.System).Bold(true).Render("System")
|
|
|
|
compactContent := r.formatCompactContent(content)
|
|
|
|
line := fmt.Sprintf("%s %-8s %s", symbol, label, compactContent)
|
|
|
|
return UIMessage{
|
|
Type: SystemMessage,
|
|
Content: line,
|
|
Height: 1,
|
|
Timestamp: timestamp,
|
|
}
|
|
}
|
|
|
|
// RenderErrorMessage renders an error notification in compact format with a
|
|
// distinctive error symbol (!) and styling to ensure visibility. The error
|
|
// content is displayed in a single line with appropriate color highlighting.
|
|
func (r *CompactRenderer) RenderErrorMessage(errorMsg string, timestamp time.Time) UIMessage {
|
|
theme := getTheme()
|
|
symbol := lipgloss.NewStyle().Foreground(theme.Error).Render("!")
|
|
label := lipgloss.NewStyle().Foreground(theme.Error).Bold(true).Render("Error")
|
|
|
|
compactContent := lipgloss.NewStyle().Foreground(theme.Error).Render(r.formatCompactContent(errorMsg))
|
|
|
|
line := fmt.Sprintf("%s %-8s %s", symbol, label, compactContent)
|
|
|
|
return UIMessage{
|
|
Type: ErrorMessage,
|
|
Content: line,
|
|
Height: 1,
|
|
Timestamp: timestamp,
|
|
}
|
|
}
|
|
|
|
// RenderDebugMessage renders diagnostic information in compact format when debug
|
|
// mode is enabled. Messages are truncated if they exceed the available width to
|
|
// maintain single-line display.
|
|
func (r *CompactRenderer) RenderDebugMessage(message string, timestamp time.Time) UIMessage {
|
|
theme := getTheme()
|
|
symbol := lipgloss.NewStyle().Foreground(theme.Tool).Render("*")
|
|
label := lipgloss.NewStyle().Foreground(theme.Tool).Bold(true).Render("Debug")
|
|
|
|
// Truncate message if too long
|
|
content := message
|
|
if len(content) > r.width-20 {
|
|
content = content[:r.width-23] + "..."
|
|
}
|
|
|
|
line := fmt.Sprintf("%s %-8s %s", symbol, label, content)
|
|
|
|
return UIMessage{
|
|
Type: SystemMessage,
|
|
Content: line,
|
|
Height: 1,
|
|
Timestamp: timestamp,
|
|
}
|
|
}
|
|
|
|
// RenderDebugConfigMessage renders configuration settings in compact format for
|
|
// debugging purposes. Config entries are displayed as key=value pairs separated
|
|
// by commas, truncated if necessary to fit on a single line.
|
|
func (r *CompactRenderer) RenderDebugConfigMessage(config map[string]any, timestamp time.Time) UIMessage {
|
|
theme := getTheme()
|
|
symbol := lipgloss.NewStyle().Foreground(theme.Tool).Render("*")
|
|
label := lipgloss.NewStyle().Foreground(theme.Tool).Bold(true).Render("Debug")
|
|
|
|
// Format config as compact key=value pairs
|
|
var configPairs []string
|
|
for key, value := range config {
|
|
if value != nil {
|
|
configPairs = append(configPairs, fmt.Sprintf("%s=%v", key, value))
|
|
}
|
|
}
|
|
|
|
content := strings.Join(configPairs, ", ")
|
|
if len(content) > r.width-20 {
|
|
content = content[:r.width-23] + "..."
|
|
}
|
|
|
|
line := fmt.Sprintf("%s %-8s %s", symbol, label, content)
|
|
|
|
return UIMessage{
|
|
Type: SystemMessage,
|
|
Content: line,
|
|
Height: 1,
|
|
Timestamp: timestamp,
|
|
}
|
|
}
|
|
|
|
// formatCompactContent formats content for compact single-line display
|
|
func (r *CompactRenderer) formatCompactContent(content string) string {
|
|
if content == "" {
|
|
return ""
|
|
}
|
|
|
|
// Remove markdown formatting for compact display
|
|
content = strings.ReplaceAll(content, "\n", " ")
|
|
content = strings.ReplaceAll(content, "\t", " ")
|
|
|
|
// Collapse multiple spaces
|
|
for strings.Contains(content, " ") {
|
|
content = strings.ReplaceAll(content, " ", " ")
|
|
}
|
|
|
|
content = strings.TrimSpace(content)
|
|
|
|
// Truncate if too long (unless in debug mode)
|
|
maxLen := r.width - 28 // Reserve space for symbol and label more conservatively
|
|
if maxLen < 40 {
|
|
maxLen = 40 // Minimum width for readability
|
|
}
|
|
if !r.debug && len(content) > maxLen {
|
|
content = content[:maxLen-3] + "..."
|
|
}
|
|
|
|
return content
|
|
}
|
|
|
|
// formatUserAssistantContent formats user and assistant content using glamour markdown rendering
|
|
func (r *CompactRenderer) formatUserAssistantContent(content string) string {
|
|
if content == "" {
|
|
return ""
|
|
}
|
|
|
|
// Calculate available width more conservatively
|
|
// Account for: symbol (1) + spaces (2) + label (up to 20 chars) + space (1) + margin (4)
|
|
availableWidth := r.width - 28
|
|
if availableWidth < 40 {
|
|
availableWidth = 40 // Minimum width for readability
|
|
}
|
|
|
|
// Use glamour to render markdown content with proper width
|
|
rendered := toMarkdown(content, availableWidth)
|
|
return strings.TrimSuffix(rendered, "\n")
|
|
}
|
|
|
|
// wrapText wraps text to the specified width, preserving existing line breaks
|
|
func (r *CompactRenderer) wrapText(text string, width int) string {
|
|
if width <= 0 {
|
|
return text
|
|
}
|
|
|
|
lines := strings.Split(text, "\n")
|
|
var wrappedLines []string
|
|
|
|
for _, line := range lines {
|
|
if len(line) <= width {
|
|
wrappedLines = append(wrappedLines, line)
|
|
continue
|
|
}
|
|
|
|
// Wrap long lines
|
|
words := strings.Fields(line)
|
|
if len(words) == 0 {
|
|
wrappedLines = append(wrappedLines, line)
|
|
continue
|
|
}
|
|
|
|
currentLine := ""
|
|
for _, word := range words {
|
|
// If adding this word would exceed the width, start a new line
|
|
if len(currentLine)+len(word)+1 > width && currentLine != "" {
|
|
wrappedLines = append(wrappedLines, currentLine)
|
|
currentLine = word
|
|
} else {
|
|
if currentLine == "" {
|
|
currentLine = word
|
|
} else {
|
|
currentLine += " " + word
|
|
}
|
|
}
|
|
}
|
|
if currentLine != "" {
|
|
wrappedLines = append(wrappedLines, currentLine)
|
|
}
|
|
}
|
|
|
|
return strings.Join(wrappedLines, "\n")
|
|
}
|
|
|
|
// formatToolArgs formats tool arguments for compact display
|
|
func (r *CompactRenderer) formatToolArgs(args string) string {
|
|
if args == "" || args == "{}" {
|
|
return ""
|
|
}
|
|
|
|
// Remove JSON braces and format compactly
|
|
args = strings.TrimSpace(args)
|
|
if strings.HasPrefix(args, "{") && strings.HasSuffix(args, "}") {
|
|
args = strings.TrimPrefix(args, "{")
|
|
args = strings.TrimSuffix(args, "}")
|
|
args = strings.TrimSpace(args)
|
|
}
|
|
|
|
// Remove quotes around simple values
|
|
args = strings.ReplaceAll(args, `"`, "")
|
|
|
|
// Remove parameter names (e.g., "command: ls" -> "ls", "path: /home" -> "/home")
|
|
// Look for pattern "key: value" and extract just the value
|
|
if colonIndex := strings.Index(args, ":"); colonIndex != -1 {
|
|
args = strings.TrimSpace(args[colonIndex+1:])
|
|
}
|
|
|
|
return r.formatCompactContent(args)
|
|
}
|
|
|
|
// formatToolResult formats tool results preserving formatting but limiting to 5 lines
|
|
func (r *CompactRenderer) formatToolResult(result string) string {
|
|
if result == "" {
|
|
return ""
|
|
}
|
|
|
|
// Check if this is bash output with stdout/stderr tags
|
|
if strings.Contains(result, "<stdout>") || strings.Contains(result, "<stderr>") {
|
|
result = r.formatBashOutput(result)
|
|
}
|
|
|
|
// Calculate available width more conservatively
|
|
availableWidth := r.width - 28
|
|
if availableWidth < 40 {
|
|
availableWidth = 40 // Minimum width for readability
|
|
}
|
|
|
|
// First wrap the text to prevent long lines (tool results are usually plain text, not markdown)
|
|
wrappedResult := r.wrapText(result, availableWidth)
|
|
|
|
// Then limit to 5 lines
|
|
lines := strings.Split(wrappedResult, "\n")
|
|
if len(lines) > 5 {
|
|
lines = lines[:5]
|
|
// Add truncation indicator
|
|
if len(lines) == 5 && lines[4] != "" {
|
|
lines[4] = lines[4] + "..."
|
|
} else {
|
|
lines = append(lines, "...")
|
|
}
|
|
}
|
|
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
// formatBashOutput formats bash command output by removing stdout/stderr tags and styling appropriately
|
|
func (r *CompactRenderer) formatBashOutput(result string) string {
|
|
theme := getTheme()
|
|
|
|
// Replace tag pairs with styled content
|
|
var formattedResult strings.Builder
|
|
remaining := result
|
|
|
|
for {
|
|
// Find stderr tags
|
|
stderrStart := strings.Index(remaining, "<stderr>")
|
|
stderrEnd := strings.Index(remaining, "</stderr>")
|
|
|
|
// Find stdout tags
|
|
stdoutStart := strings.Index(remaining, "<stdout>")
|
|
stdoutEnd := strings.Index(remaining, "</stdout>")
|
|
|
|
// Process whichever comes first
|
|
if stderrStart != -1 && stderrEnd != -1 && stderrEnd > stderrStart &&
|
|
(stdoutStart == -1 || stderrStart < stdoutStart) {
|
|
// Process stderr
|
|
// Add content before the tag
|
|
if stderrStart > 0 {
|
|
formattedResult.WriteString(remaining[:stderrStart])
|
|
}
|
|
|
|
// Extract and style stderr content
|
|
stderrContent := remaining[stderrStart+8 : stderrEnd]
|
|
// Trim leading/trailing newlines but preserve internal ones
|
|
stderrContent = strings.Trim(stderrContent, "\n")
|
|
if len(stderrContent) > 0 {
|
|
// Style stderr content with error color, same as non-compact mode
|
|
styledContent := lipgloss.NewStyle().Foreground(theme.Error).Render(stderrContent)
|
|
formattedResult.WriteString(styledContent)
|
|
}
|
|
|
|
// Continue with remaining content
|
|
remaining = remaining[stderrEnd+9:] // Skip past </stderr>
|
|
|
|
} else if stdoutStart != -1 && stdoutEnd != -1 && stdoutEnd > stdoutStart {
|
|
// Process stdout
|
|
// Add content before the tag
|
|
if stdoutStart > 0 {
|
|
formattedResult.WriteString(remaining[:stdoutStart])
|
|
}
|
|
|
|
// Extract stdout content (no special styling needed)
|
|
stdoutContent := remaining[stdoutStart+8 : stdoutEnd]
|
|
// Trim leading/trailing newlines but preserve internal ones
|
|
stdoutContent = strings.Trim(stdoutContent, "\n")
|
|
if len(stdoutContent) > 0 {
|
|
formattedResult.WriteString(stdoutContent)
|
|
}
|
|
|
|
// Continue with remaining content
|
|
remaining = remaining[stdoutEnd+9:] // Skip past </stdout>
|
|
|
|
} else {
|
|
// No more tags, add remaining content
|
|
formattedResult.WriteString(remaining)
|
|
break
|
|
}
|
|
}
|
|
|
|
// Trim any leading/trailing whitespace from the final result
|
|
return strings.TrimSpace(formattedResult.String())
|
|
}
|
|
|
|
// determineResultType determines the display type for tool results
|
|
func (r *CompactRenderer) determineResultType(toolName, result string) string {
|
|
toolName = strings.ToLower(toolName)
|
|
|
|
switch {
|
|
case strings.Contains(toolName, "read"):
|
|
return "Text"
|
|
case strings.Contains(toolName, "write"):
|
|
return "Write"
|
|
case strings.Contains(toolName, "bash") || strings.Contains(toolName, "command") || strings.Contains(toolName, "shell") || toolName == "run_shell_cmd":
|
|
return "Bash"
|
|
case strings.Contains(toolName, "list") || strings.Contains(toolName, "ls"):
|
|
return "List"
|
|
case strings.Contains(toolName, "search") || strings.Contains(toolName, "grep"):
|
|
return "Search"
|
|
case strings.Contains(toolName, "fetch") || strings.Contains(toolName, "http"):
|
|
return "Fetch"
|
|
default:
|
|
return "Result"
|
|
}
|
|
}
|