diff --git a/cmd/root.go b/cmd/root.go index 75ab334..05e0651 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -315,7 +315,7 @@ func runNormalMode(ctx context.Context) error { isOAuth = true } } - + usageTracker := ui.NewUsageTracker(modelInfo, provider, 80, isOAuth) // Will be updated with actual width cli.SetUsageTracker(usageTracker) } diff --git a/internal/models/providers.go b/internal/models/providers.go index 8cf2e42..859e400 100644 --- a/internal/models/providers.go +++ b/internal/models/providers.go @@ -29,22 +29,22 @@ const ( // resolveModelAlias resolves model aliases to their full names using the registry func resolveModelAlias(provider, modelName string) string { registry := GetGlobalRegistry() - + // Common alias patterns for Anthropic models - using Claude 4 as the latest/default aliasMap := map[string]string{ // Claude 4 models (latest and most capable) - "claude-opus-latest": "claude-opus-4-20250514", - "claude-sonnet-latest": "claude-sonnet-4-20250514", - "claude-4-opus-latest": "claude-opus-4-20250514", - "claude-4-sonnet-latest": "claude-sonnet-4-20250514", - + "claude-opus-latest": "claude-opus-4-20250514", + "claude-sonnet-latest": "claude-sonnet-4-20250514", + "claude-4-opus-latest": "claude-opus-4-20250514", + "claude-4-sonnet-latest": "claude-sonnet-4-20250514", + // Claude 3.x models for backward compatibility "claude-3-5-haiku-latest": "claude-3-5-haiku-20241022", - "claude-3-5-sonnet-latest": "claude-3-5-sonnet-20241022", + "claude-3-5-sonnet-latest": "claude-3-5-sonnet-20241022", "claude-3-7-sonnet-latest": "claude-3-7-sonnet-20250219", "claude-3-opus-latest": "claude-3-opus-20240229", } - + // Check if it's a known alias if resolved, exists := aliasMap[modelName]; exists { // Verify the resolved model exists in the registry @@ -52,7 +52,7 @@ func resolveModelAlias(provider, modelName string) string { return resolved } } - + // Return original if no alias found or resolved model doesn't exist return modelName } diff --git a/internal/ui/block_renderer.go b/internal/ui/block_renderer.go new file mode 100644 index 0000000..50a39d0 --- /dev/null +++ b/internal/ui/block_renderer.go @@ -0,0 +1,178 @@ +package ui + +import ( + "github.com/charmbracelet/lipgloss" +) + +// blockRenderer handles rendering of content blocks with configurable options +type blockRenderer struct { + align *lipgloss.Position + borderColor *lipgloss.AdaptiveColor + fullWidth bool + paddingTop int + paddingBottom int + paddingLeft int + paddingRight int + marginTop int + marginBottom int + width int +} + +// renderingOption configures block rendering +type renderingOption func(*blockRenderer) + +// WithFullWidth makes the block take full available width +func WithFullWidth() renderingOption { + return func(c *blockRenderer) { + c.fullWidth = true + } +} + +// WithAlign sets the horizontal alignment of the block +func WithAlign(align lipgloss.Position) renderingOption { + return func(c *blockRenderer) { + c.align = &align + } +} + +// WithBorderColor sets the border color +func WithBorderColor(color lipgloss.AdaptiveColor) renderingOption { + return func(c *blockRenderer) { + c.borderColor = &color + } +} + +// WithMarginTop sets the top margin +func WithMarginTop(margin int) renderingOption { + return func(c *blockRenderer) { + c.marginTop = margin + } +} + +// WithMarginBottom sets the bottom margin +func WithMarginBottom(margin int) renderingOption { + return func(c *blockRenderer) { + c.marginBottom = margin + } +} + +// WithPaddingLeft sets the left padding +func WithPaddingLeft(padding int) renderingOption { + return func(c *blockRenderer) { + c.paddingLeft = padding + } +} + +// WithPaddingRight sets the right padding +func WithPaddingRight(padding int) renderingOption { + return func(c *blockRenderer) { + c.paddingRight = padding + } +} + +// WithPaddingTop sets the top padding +func WithPaddingTop(padding int) renderingOption { + return func(c *blockRenderer) { + c.paddingTop = padding + } +} + +// WithPaddingBottom sets the bottom padding +func WithPaddingBottom(padding int) renderingOption { + return func(c *blockRenderer) { + c.paddingBottom = padding + } +} + +// WithWidth sets a specific width for the block +func WithWidth(width int) renderingOption { + return func(c *blockRenderer) { + c.width = width + } +} + +// renderContentBlock renders content with configurable styling options +func renderContentBlock(content string, containerWidth int, options ...renderingOption) string { + renderer := &blockRenderer{ + fullWidth: false, + paddingTop: 1, + paddingBottom: 1, + paddingLeft: 2, + paddingRight: 2, + width: containerWidth, + } + + for _, option := range options { + option(renderer) + } + + theme := GetTheme() + style := lipgloss.NewStyle(). + PaddingTop(renderer.paddingTop). + PaddingBottom(renderer.paddingBottom). + PaddingLeft(renderer.paddingLeft). + PaddingRight(renderer.paddingRight). + Foreground(theme.Text). + BorderStyle(lipgloss.ThickBorder()) + + align := lipgloss.Left + if renderer.align != nil { + align = *renderer.align + } + + // Default to transparent/no border color + borderColor := lipgloss.AdaptiveColor{Light: "", Dark: ""} + if renderer.borderColor != nil { + borderColor = *renderer.borderColor + } + + // Very muted color for the opposite border + mutedOppositeBorder := lipgloss.AdaptiveColor{ + Light: "#F3F4F6", // Very light gray, barely visible + Dark: "#1F2937", // Very dark gray, barely visible + } + + switch align { + case lipgloss.Left: + style = style. + BorderLeft(true). + BorderRight(true). + AlignHorizontal(align). + BorderLeftForeground(borderColor). + BorderRightForeground(mutedOppositeBorder) + case lipgloss.Right: + style = style. + BorderRight(true). + BorderLeft(true). + AlignHorizontal(align). + BorderRightForeground(borderColor). + BorderLeftForeground(mutedOppositeBorder) + } + + if renderer.fullWidth { + style = style.Width(renderer.width) + } + + content = style.Render(content) + + // Place the content horizontally with proper background + content = lipgloss.PlaceHorizontal( + renderer.width, + align, + content, + ) + + // Add margins + if renderer.marginTop > 0 { + for range renderer.marginTop { + content = "\n" + content + } + } + if renderer.marginBottom > 0 { + for range renderer.marginBottom { + content = content + "\n" + } + } + + return content +} diff --git a/internal/ui/cli.go b/internal/ui/cli.go index 614970e..6cfee2b 100644 --- a/internal/ui/cli.go +++ b/internal/ui/cli.go @@ -51,20 +51,27 @@ func (c *CLI) GetPrompt() (string, error) { if c.usageTracker != nil { usageInfo := c.usageTracker.RenderUsageInfo() if usageInfo != "" { - fmt.Print(usageInfo) + paddedUsage := lipgloss.NewStyle(). + PaddingLeft(2). + Render(usageInfo) + fmt.Print(paddedUsage) } } - // Create a divider before the input + // Create an enhanced divider with gradient effect + theme := GetTheme() dividerStyle := lipgloss.NewStyle(). Width(c.width). BorderTop(true). - BorderStyle(lipgloss.NormalBorder()). - BorderForeground(mutedColor). + BorderStyle(lipgloss.Border{ + Top: "━", + }). + BorderForeground(theme.Border). MarginTop(1). - MarginBottom(1) + MarginBottom(1). + PaddingLeft(2) - // Render the divider + // Render the enhanced input section fmt.Print(dividerStyle.Render("")) var prompt string @@ -312,7 +319,14 @@ func (c *CLI) ClearMessages() { func (c *CLI) displayContainer() { // Clear screen and display messages fmt.Print("\033[2J\033[H") // Clear screen and move cursor to top - fmt.Print(c.messageContainer.Render()) + + // Add left padding to the entire container + content := c.messageContainer.Render() + paddedContent := lipgloss.NewStyle(). + PaddingLeft(2). + Render(content) + + fmt.Print(paddedContent) } // UpdateUsage updates the usage tracker with token counts and costs @@ -393,7 +407,9 @@ func (c *CLI) updateSize() { return } - c.width = width + // Add left and right padding (4 characters total: 2 on each side) + paddingTotal := 4 + c.width = width - paddingTotal c.height = height // Update renderers if they exist diff --git a/internal/ui/enhanced_styles.go b/internal/ui/enhanced_styles.go new file mode 100644 index 0000000..bd74c8a --- /dev/null +++ b/internal/ui/enhanced_styles.go @@ -0,0 +1,212 @@ +package ui + +import ( + "github.com/charmbracelet/lipgloss" +) + +// Enhanced styling utilities and theme definitions + +// Global theme instance +var currentTheme = DefaultTheme() + +// GetTheme returns the current theme +func GetTheme() Theme { + return currentTheme +} + +// SetTheme sets the current theme +func SetTheme(theme Theme) { + currentTheme = theme +} + +// Theme represents a complete UI theme +type Theme struct { + Primary lipgloss.AdaptiveColor + Secondary lipgloss.AdaptiveColor + Success lipgloss.AdaptiveColor + Warning lipgloss.AdaptiveColor + Error lipgloss.AdaptiveColor + Info lipgloss.AdaptiveColor + Text lipgloss.AdaptiveColor + Muted lipgloss.AdaptiveColor + VeryMuted lipgloss.AdaptiveColor + Background lipgloss.AdaptiveColor + Border lipgloss.AdaptiveColor + MutedBorder lipgloss.AdaptiveColor + System lipgloss.AdaptiveColor + Tool lipgloss.AdaptiveColor + Accent lipgloss.AdaptiveColor + Highlight lipgloss.AdaptiveColor +} + +// DefaultTheme returns the default MCPHost theme (Catppuccin Mocha) +func DefaultTheme() Theme { + return Theme{ + Primary: lipgloss.AdaptiveColor{ + Light: "#8839ef", // Latte Mauve + Dark: "#cba6f7", // Mocha Mauve + }, + Secondary: lipgloss.AdaptiveColor{ + Light: "#04a5e5", // Latte Sky + Dark: "#89dceb", // Mocha Sky + }, + Success: lipgloss.AdaptiveColor{ + Light: "#40a02b", // Latte Green + Dark: "#a6e3a1", // Mocha Green + }, + Warning: lipgloss.AdaptiveColor{ + Light: "#df8e1d", // Latte Yellow + Dark: "#f9e2af", // Mocha Yellow + }, + Error: lipgloss.AdaptiveColor{ + Light: "#d20f39", // Latte Red + Dark: "#f38ba8", // Mocha Red + }, + Info: lipgloss.AdaptiveColor{ + Light: "#1e66f5", // Latte Blue + Dark: "#89b4fa", // Mocha Blue + }, + Text: lipgloss.AdaptiveColor{ + Light: "#4c4f69", // Latte Text + Dark: "#cdd6f4", // Mocha Text + }, + Muted: lipgloss.AdaptiveColor{ + Light: "#6c6f85", // Latte Subtext 0 + Dark: "#a6adc8", // Mocha Subtext 0 + }, + VeryMuted: lipgloss.AdaptiveColor{ + Light: "#9ca0b0", // Latte Overlay 0 + Dark: "#6c7086", // Mocha Overlay 0 + }, + Background: lipgloss.AdaptiveColor{ + Light: "#eff1f5", // Latte Base + Dark: "#1e1e2e", // Mocha Base + }, + Border: lipgloss.AdaptiveColor{ + Light: "#acb0be", // Latte Surface 2 + Dark: "#585b70", // Mocha Surface 2 + }, + MutedBorder: lipgloss.AdaptiveColor{ + Light: "#ccd0da", // Latte Surface 0 + Dark: "#313244", // Mocha Surface 0 + }, + System: lipgloss.AdaptiveColor{ + Light: "#179299", // Latte Teal + Dark: "#94e2d5", // Mocha Teal + }, + Tool: lipgloss.AdaptiveColor{ + Light: "#fe640b", // Latte Peach + Dark: "#fab387", // Mocha Peach + }, + Accent: lipgloss.AdaptiveColor{ + Light: "#ea76cb", // Latte Pink + Dark: "#f5c2e7", // Mocha Pink + }, + Highlight: lipgloss.AdaptiveColor{ + Light: "#df8e1d", // Latte Yellow (for highlights) + Dark: "#45475a", // Mocha Surface 1 (subtle highlight) + }, + } +} + +// StyleCard creates a styled card container +func StyleCard(width int, theme Theme) lipgloss.Style { + return lipgloss.NewStyle(). + Width(width). + Border(lipgloss.RoundedBorder()). + BorderForeground(theme.Border). + Padding(1, 2). + MarginBottom(1) +} + +// StyleHeader creates a styled header +func StyleHeader(theme Theme) lipgloss.Style { + return lipgloss.NewStyle(). + Foreground(theme.Primary). + Bold(true) +} + +// StyleSubheader creates a styled subheader +func StyleSubheader(theme Theme) lipgloss.Style { + return lipgloss.NewStyle(). + Foreground(theme.Secondary). + Bold(true) +} + +// StyleMuted creates muted text styling +func StyleMuted(theme Theme) lipgloss.Style { + return lipgloss.NewStyle(). + Foreground(theme.Muted). + Italic(true) +} + +// StyleSuccess creates success text styling +func StyleSuccess(theme Theme) lipgloss.Style { + return lipgloss.NewStyle(). + Foreground(theme.Success). + Bold(true) +} + +// StyleError creates error text styling +func StyleError(theme Theme) lipgloss.Style { + return lipgloss.NewStyle(). + Foreground(theme.Error). + Bold(true) +} + +// StyleWarning creates warning text styling +func StyleWarning(theme Theme) lipgloss.Style { + return lipgloss.NewStyle(). + Foreground(theme.Warning). + Bold(true) +} + +// StyleInfo creates info text styling +func StyleInfo(theme Theme) lipgloss.Style { + return lipgloss.NewStyle(). + Foreground(theme.Info). + Bold(true) +} + +// CreateSeparator creates a styled separator line +func CreateSeparator(width int, char string, color lipgloss.AdaptiveColor) string { + return lipgloss.NewStyle(). + Foreground(color). + Width(width). + Render(lipgloss.PlaceHorizontal(width, lipgloss.Center, char)) +} + +// CreateProgressBar creates a simple progress bar +func CreateProgressBar(width int, percentage float64, theme Theme) string { + filled := int(float64(width) * percentage / 100) + empty := width - filled + + filledBar := lipgloss.NewStyle(). + Foreground(theme.Success). + Render(lipgloss.PlaceHorizontal(filled, lipgloss.Left, "█")) + + emptyBar := lipgloss.NewStyle(). + Foreground(theme.Muted). + Render(lipgloss.PlaceHorizontal(empty, lipgloss.Left, "░")) + + return filledBar + emptyBar +} + +// CreateBadge creates a styled badge +func CreateBadge(text string, color lipgloss.AdaptiveColor) string { + return lipgloss.NewStyle(). + Foreground(lipgloss.AdaptiveColor{Light: "#FFFFFF", Dark: "#000000"}). + Background(color). + Padding(0, 1). + Bold(true). + Render(text) +} + +// CreateGradientText creates text with gradient-like effect using different shades +func CreateGradientText(text string, startColor, endColor lipgloss.AdaptiveColor) string { + // For now, just use the start color - true gradients would require more complex implementation + return lipgloss.NewStyle(). + Foreground(startColor). + Bold(true). + Render(text) +} diff --git a/internal/ui/messages.go b/internal/ui/messages.go index db5770e..ec1ba42 100644 --- a/internal/ui/messages.go +++ b/internal/ui/messages.go @@ -2,6 +2,8 @@ package ui import ( "fmt" + "os" + "os/user" "strings" "time" @@ -30,16 +32,10 @@ type UIMessage struct { Timestamp time.Time } -// Color constants -var ( - primaryColor = lipgloss.Color("#7C3AED") // Purple - secondaryColor = lipgloss.Color("#06B6D4") // Cyan - systemColor = lipgloss.Color("#10B981") // Green for MCPHost system messages - textColor = lipgloss.Color("#FFFFFF") // White - mutedColor = lipgloss.Color("#6B7280") // Gray - errorColor = lipgloss.Color("#EF4444") // Red - toolColor = lipgloss.Color("#F59E0B") // Orange/Amber for tool calls -) +// Helper functions to get theme colors +func getTheme() Theme { + return GetTheme() +} // MessageRenderer handles rendering of messages with proper styling type MessageRenderer struct { @@ -47,6 +43,21 @@ type MessageRenderer struct { debug bool } +// getSystemUsername returns the current system username, fallback to "User" +func getSystemUsername() string { + if currentUser, err := user.Current(); err == nil && currentUser.Username != "" { + return currentUser.Username + } + // Fallback to environment variable + if username := os.Getenv("USER"); username != "" { + return username + } + if username := os.Getenv("USERNAME"); username != "" { + return username + } + return "User" +} + // NewMessageRenderer creates a new message renderer func NewMessageRenderer(width int, debug bool) *MessageRenderer { return &MessageRenderer{ @@ -60,40 +71,30 @@ func (r *MessageRenderer) SetWidth(width int) { r.width = width } -// RenderUserMessage renders a user message with proper styling +// RenderUserMessage renders a user message with right border and background header func (r *MessageRenderer) RenderUserMessage(content string, timestamp time.Time) UIMessage { - baseStyle := lipgloss.NewStyle() - - // Create the main message style with border - style := baseStyle. - Width(r.width - 1). - BorderLeft(true). - Foreground(mutedColor). - BorderForeground(secondaryColor). - BorderStyle(lipgloss.ThickBorder()). - PaddingLeft(1) + // Format timestamp and username + timeStr := timestamp.Local().Format("15:04") + username := getSystemUsername() - // Format timestamp - timeStr := timestamp.Local().Format("02 Jan 2006 03:04 PM") - username := "You" + // Render the message content + messageContent := r.renderMarkdown(content, r.width-8) // Account for padding and borders // Create info line - info := baseStyle. - Width(r.width - 1). - Foreground(mutedColor). - Render(fmt.Sprintf(" %s (%s)", username, timeStr)) - - // Render the message content - messageContent := r.renderMarkdown(content, r.width-2) + info := fmt.Sprintf(" %s (%s)", username, timeStr) // Combine content and info - parts := []string{ - strings.TrimSuffix(messageContent, "\n"), - info, - } - - rendered := style.Render( - lipgloss.JoinVertical(lipgloss.Left, parts...), + theme := getTheme() + fullContent := strings.TrimSuffix(messageContent, "\n") + "\n" + + lipgloss.NewStyle().Foreground(theme.VeryMuted).Render(info) + + // Use the new block renderer + rendered := renderContentBlock( + fullContent, + r.width, + WithAlign(lipgloss.Right), + WithBorderColor(theme.Secondary), + WithMarginBottom(1), ) return UIMessage{ @@ -104,50 +105,41 @@ func (r *MessageRenderer) RenderUserMessage(content string, timestamp time.Time) } } -// RenderAssistantMessage renders an assistant message with proper styling +// RenderAssistantMessage renders an assistant message with left border and background header func (r *MessageRenderer) RenderAssistantMessage(content string, timestamp time.Time, modelName string) UIMessage { - baseStyle := lipgloss.NewStyle() - - // Create the main message style with border - style := baseStyle. - Width(r.width - 1). - BorderLeft(true). - Foreground(mutedColor). - BorderForeground(primaryColor). - BorderStyle(lipgloss.ThickBorder()). - PaddingLeft(1) - - // Format timestamp and model info - timeStr := timestamp.Local().Format("02 Jan 2006 03:04 PM") + // Format timestamp and model info with better defaults + timeStr := timestamp.Local().Format("15:04") if modelName == "" { modelName = "Assistant" } - // Create info line - info := baseStyle. - Width(r.width - 1). - Foreground(mutedColor). - Render(fmt.Sprintf(" %s (%s)", modelName, timeStr)) - - // Render the message content - messageContent := r.renderMarkdown(content, r.width-2) - - // Handle empty content + // Handle empty content with better styling + theme := getTheme() + var messageContent string if strings.TrimSpace(content) == "" { - messageContent = baseStyle. + messageContent = lipgloss.NewStyle(). Italic(true). - Foreground(mutedColor). - Render("*Finished without output*") + Foreground(theme.Muted). + Align(lipgloss.Center). + Render("Finished without output") + } else { + messageContent = r.renderMarkdown(content, r.width-8) // Account for padding and borders } - // Combine content and info - parts := []string{ - strings.TrimSuffix(messageContent, "\n"), - info, - } + // Create info line + info := fmt.Sprintf(" %s (%s)", modelName, timeStr) - rendered := style.Render( - lipgloss.JoinVertical(lipgloss.Left, parts...), + // Combine content and info + fullContent := strings.TrimSuffix(messageContent, "\n") + "\n" + + lipgloss.NewStyle().Foreground(theme.VeryMuted).Render(info) + + // Use the new block renderer + rendered := renderContentBlock( + fullContent, + r.width, + WithAlign(lipgloss.Left), + WithBorderColor(theme.Primary), + WithMarginBottom(1), ) return UIMessage{ @@ -158,47 +150,38 @@ func (r *MessageRenderer) RenderAssistantMessage(content string, timestamp time. } } -// RenderSystemMessage renders a system message (help, tools, etc.) with proper styling +// RenderSystemMessage renders a system message with left border and background header func (r *MessageRenderer) RenderSystemMessage(content string, timestamp time.Time) UIMessage { - baseStyle := lipgloss.NewStyle() - - // Create the main message style with border - style := baseStyle. - Width(r.width - 1). - BorderLeft(true). - Foreground(mutedColor). - BorderForeground(systemColor). - BorderStyle(lipgloss.ThickBorder()). - PaddingLeft(1) - // Format timestamp - timeStr := timestamp.Local().Format("02 Jan 2006 03:04 PM") - - // Create info line with MCPHost label - info := baseStyle. - Width(r.width - 1). - Foreground(mutedColor). - Render(fmt.Sprintf(" MCPHost (%s)", timeStr)) - - // Render the message content with markdown - messageContent := r.renderMarkdown(content, r.width-2) + timeStr := timestamp.Local().Format("15:04") - // Handle empty content + // Handle empty content with better styling + theme := getTheme() + var messageContent string if strings.TrimSpace(content) == "" { - messageContent = baseStyle. + messageContent = lipgloss.NewStyle(). Italic(true). - Foreground(mutedColor). - Render("*No content*") + Foreground(theme.Muted). + Align(lipgloss.Center). + Render("No content available") + } else { + messageContent = r.renderMarkdown(content, r.width-8) // Account for padding and borders } - // Combine content and info - parts := []string{ - strings.TrimSuffix(messageContent, "\n"), - info, - } + // Create info line + info := fmt.Sprintf(" MCPHost System (%s)", timeStr) - rendered := style.Render( - lipgloss.JoinVertical(lipgloss.Left, parts...), + // Combine content and info + fullContent := strings.TrimSuffix(messageContent, "\n") + "\n" + + lipgloss.NewStyle().Foreground(theme.VeryMuted).Render(info) + + // Use the new block renderer + rendered := renderContentBlock( + fullContent, + r.width, + WithAlign(lipgloss.Left), + WithBorderColor(theme.System), + WithMarginBottom(1), ) return UIMessage{ @@ -214,11 +197,12 @@ func (r *MessageRenderer) RenderDebugConfigMessage(config map[string]any, timest baseStyle := lipgloss.NewStyle() // Create the main message style with border using tool color + theme := getTheme() style := baseStyle. Width(r.width - 1). BorderLeft(true). - Foreground(mutedColor). - BorderForeground(toolColor). + Foreground(theme.Muted). + BorderForeground(theme.Tool). BorderStyle(lipgloss.ThickBorder()). PaddingLeft(1) @@ -227,7 +211,7 @@ func (r *MessageRenderer) RenderDebugConfigMessage(config map[string]any, timest // Create header with debug icon header := baseStyle. - Foreground(toolColor). + Foreground(theme.Tool). Bold(true). Render("🔧 Debug Configuration") @@ -240,13 +224,13 @@ func (r *MessageRenderer) RenderDebugConfigMessage(config map[string]any, timest } configContent := baseStyle. - Foreground(mutedColor). + Foreground(theme.Muted). Render(strings.Join(configLines, "\n")) // Create info line info := baseStyle. Width(r.width - 1). - Foreground(mutedColor). + Foreground(theme.Muted). Render(fmt.Sprintf(" MCPHost (%s)", timeStr)) // Combine parts @@ -268,42 +252,32 @@ func (r *MessageRenderer) RenderDebugConfigMessage(config map[string]any, timest } } -// RenderErrorMessage renders an error message with proper styling +// RenderErrorMessage renders an error message with left border and background header func (r *MessageRenderer) RenderErrorMessage(errorMsg string, timestamp time.Time) UIMessage { - baseStyle := lipgloss.NewStyle() - - // Create the main message style with border - style := baseStyle. - Width(r.width - 1). - BorderLeft(true). - Foreground(mutedColor). - BorderForeground(errorColor). - BorderStyle(lipgloss.ThickBorder()). - PaddingLeft(1) - // Format timestamp - timeStr := timestamp.Local().Format("02 Jan 2006 03:04 PM") + timeStr := timestamp.Local().Format("15:04") - // Create info line with Error label - info := baseStyle. - Width(r.width - 1). - Foreground(mutedColor). - Render(fmt.Sprintf(" Error (%s)", timeStr)) - - // Format error content with error styling - errorContent := baseStyle. - Foreground(errorColor). + // Format error content + theme := getTheme() + errorContent := lipgloss.NewStyle(). + Foreground(theme.Error). Bold(true). - Render(fmt.Sprintf("❌ %s", errorMsg)) + Render(errorMsg) - // Combine content and info - parts := []string{ - errorContent, - info, - } + // Create info line + info := fmt.Sprintf(" Error (%s)", timeStr) - rendered := style.Render( - lipgloss.JoinVertical(lipgloss.Left, parts...), + // Combine content and info + fullContent := errorContent + "\n" + + lipgloss.NewStyle().Foreground(theme.VeryMuted).Render(info) + + // Use the new block renderer + rendered := renderContentBlock( + fullContent, + r.width, + WithAlign(lipgloss.Left), + WithBorderColor(theme.Error), + WithMarginBottom(1), ) return UIMessage{ @@ -314,53 +288,40 @@ func (r *MessageRenderer) RenderErrorMessage(errorMsg string, timestamp time.Tim } } -// RenderToolCallMessage renders a tool call in progress with proper styling +// RenderToolCallMessage renders a tool call in progress with left border and background header func (r *MessageRenderer) RenderToolCallMessage(toolName, toolArgs string, timestamp time.Time) UIMessage { - baseStyle := lipgloss.NewStyle() - - // Create the main message style with border - style := baseStyle. - Width(r.width - 1). - BorderLeft(true). - Foreground(mutedColor). - BorderForeground(toolColor). - BorderStyle(lipgloss.ThickBorder()). - PaddingLeft(1) - // Format timestamp - timeStr := timestamp.Local().Format("02 Jan 2006 03:04 PM") + timeStr := timestamp.Local().Format("15:04") - // Create header with tool icon and name - toolIcon := "🔧" - header := baseStyle. - Foreground(toolColor). - Bold(true). - Render(fmt.Sprintf("%s Calling %s", toolIcon, toolName)) - - // Format arguments in a more readable way + // Format arguments with better presentation + theme := getTheme() var argsContent string if toolArgs != "" && toolArgs != "{}" { - // Try to format JSON args nicely - argsContent = baseStyle. - Foreground(mutedColor). + argsContent = lipgloss.NewStyle(). + Foreground(theme.Muted). + Italic(true). Render(fmt.Sprintf("Arguments: %s", r.formatToolArgs(toolArgs))) } // Create info line - info := baseStyle. - Width(r.width - 1). - Foreground(mutedColor). - Render(fmt.Sprintf(" Tool Call (%s)", timeStr)) + info := fmt.Sprintf(" Executing %s (%s)", toolName, timeStr) // Combine parts - parts := []string{header} + var fullContent string if argsContent != "" { - parts = append(parts, argsContent) + fullContent = argsContent + "\n" + + lipgloss.NewStyle().Foreground(theme.VeryMuted).Render(info) + } else { + fullContent = lipgloss.NewStyle().Foreground(theme.VeryMuted).Render(info) } - parts = append(parts, info) - rendered := style.Render( - lipgloss.JoinVertical(lipgloss.Left, parts...), + // Use the new block renderer + rendered := renderContentBlock( + fullContent, + r.width, + WithAlign(lipgloss.Left), + WithBorderColor(theme.Tool), + WithMarginBottom(1), ) return UIMessage{ @@ -373,49 +334,44 @@ func (r *MessageRenderer) RenderToolCallMessage(toolName, toolArgs string, times // RenderToolMessage renders a tool call message with proper styling func (r *MessageRenderer) RenderToolMessage(toolName, toolArgs, toolResult string, isError bool) UIMessage { - baseStyle := lipgloss.NewStyle() - - // Create the main message style with border - style := baseStyle. - Width(r.width - 1). - BorderLeft(true). - BorderStyle(lipgloss.ThickBorder()). - PaddingLeft(1). - BorderForeground(mutedColor) - - // Tool name styling - toolNameText := baseStyle. - Foreground(mutedColor). + // Tool name and arguments header + theme := getTheme() + toolNameText := lipgloss.NewStyle(). + Foreground(theme.Muted). Render(fmt.Sprintf("%s: ", toolName)) - // Tool arguments styling - argsText := baseStyle. - Width(r.width - 2 - lipgloss.Width(toolNameText)). - Foreground(mutedColor). - Render(r.truncateText(toolArgs, r.width-2-lipgloss.Width(toolNameText))) + argsText := lipgloss.NewStyle(). + Foreground(theme.Muted). + Render(r.truncateText(toolArgs, r.width-8-lipgloss.Width(toolNameText))) + + headerLine := lipgloss.JoinHorizontal(lipgloss.Left, toolNameText, argsText) // Tool result styling var resultContent string if isError { - resultContent = baseStyle. - Width(r.width - 2). - Foreground(errorColor). + resultContent = lipgloss.NewStyle(). + Foreground(theme.Error). Render(fmt.Sprintf("Error: %s", toolResult)) } else { // Format result based on tool type - resultContent = r.formatToolResult(toolName, toolResult, r.width-2) + resultContent = r.formatToolResult(toolName, toolResult, r.width-8) } // Combine parts - headerLine := lipgloss.JoinHorizontal(lipgloss.Left, toolNameText, argsText) - parts := []string{headerLine} - + var fullContent string if resultContent != "" { - parts = append(parts, strings.TrimSuffix(resultContent, "\n")) + fullContent = headerLine + "\n" + strings.TrimSuffix(resultContent, "\n") + } else { + fullContent = headerLine } - rendered := style.Render( - lipgloss.JoinVertical(lipgloss.Left, parts...), + // Use the new block renderer + rendered := renderContentBlock( + fullContent, + r.width, + WithAlign(lipgloss.Left), + WithBorderColor(theme.Muted), + WithMarginBottom(1), ) return UIMessage{ @@ -471,9 +427,10 @@ func (r *MessageRenderer) formatToolResult(toolName, result string, width int) s } // For other tools, render as muted text + theme := getTheme() return baseStyle. Width(width). - Foreground(mutedColor). + Foreground(theme.Muted). Render(result) } @@ -546,16 +503,24 @@ func (c *MessageContainer) Render() string { return c.renderEmptyState() } - baseStyle := lipgloss.NewStyle() var parts []string - for _, msg := range c.messages { - parts = append(parts, msg.Content) - // Add spacing between messages - parts = append(parts, baseStyle.Width(c.width).Render("")) + for i, msg := range c.messages { + // Center each message horizontally + centeredMsg := lipgloss.PlaceHorizontal( + c.width, + lipgloss.Center, + msg.Content, + ) + parts = append(parts, centeredMsg) + + // Add spacing between messages (except after the last one) + if i < len(c.messages)-1 { + parts = append(parts, "") + } } - return baseStyle. + return lipgloss.NewStyle(). Width(c.width). PaddingBottom(1). Render( @@ -563,35 +528,73 @@ func (c *MessageContainer) Render() string { ) } -// renderEmptyState renders the initial empty state +// renderEmptyState renders an enhanced initial empty state func (c *MessageContainer) renderEmptyState() string { baseStyle := lipgloss.NewStyle() - header := baseStyle. - Width(c.width). - Align(lipgloss.Center). - Foreground(systemColor). + // Create a welcome box with border + theme := getTheme() + welcomeBox := baseStyle. + Width(c.width-4). + Border(lipgloss.RoundedBorder()). + BorderForeground(theme.System). + Padding(2, 4). + Align(lipgloss.Center) + + // Main title + title := baseStyle. + Foreground(theme.System). Bold(true). - Render("MCPHost - AI Assistant with MCP Tools") + Render("MCPHost") + // Subtitle with better typography subtitle := baseStyle. - Width(c.width). - Align(lipgloss.Center). - Foreground(mutedColor). - Render("Start a conversation by typing your message below") + Foreground(theme.Primary). + Bold(true). + MarginTop(1). + Render("AI Assistant with MCP Tools") + + // Feature highlights + features := []string{ + "Natural language conversations", + "Powerful tool integrations", + "Multi-provider LLM support", + "Usage tracking & analytics", + } + + var featureList []string + for _, feature := range features { + featureList = append(featureList, baseStyle. + Foreground(theme.Muted). + MarginLeft(2). + Render("• "+feature)) + } + + // Getting started prompt + prompt := baseStyle. + Foreground(theme.Accent). + Italic(true). + MarginTop(2). + Render("Start by typing your message below or use /help for commands") + + // Combine all elements + content := lipgloss.JoinVertical( + lipgloss.Center, + title, + subtitle, + "", + lipgloss.JoinVertical(lipgloss.Left, featureList...), + "", + prompt, + ) + + welcomeContent := welcomeBox.Render(content) + // Center the welcome box vertically return baseStyle. Width(c.width). Height(c.height). - PaddingBottom(1). - Render( - lipgloss.JoinVertical( - lipgloss.Center, - "", - header, - "", - subtitle, - "", - ), - ) + Align(lipgloss.Center). + AlignVertical(lipgloss.Center). + Render(welcomeContent) } diff --git a/internal/ui/spinner.go b/internal/ui/spinner.go index e66dd0f..71cc511 100644 --- a/internal/ui/spinner.go +++ b/internal/ui/spinner.go @@ -51,17 +51,33 @@ func (m spinnerModel) View() string { if m.quitting { return "" } - return fmt.Sprintf("%s %s", m.spinner.View(), m.message) + + // Enhanced spinner display with better styling + baseStyle := lipgloss.NewStyle() + theme := GetTheme() + + spinnerStyle := baseStyle. + Foreground(theme.Primary). + Bold(true) + + messageStyle := baseStyle. + Foreground(theme.Text). + Italic(true) + + return fmt.Sprintf("%s %s", + spinnerStyle.Render(m.spinner.View()), + messageStyle.Render(m.message)) } // quitMsg is sent when we want to quit the spinner type quitMsg struct{} -// NewSpinner creates a new spinner with the given message +// NewSpinner creates a new spinner with enhanced styling func NewSpinner(message string) *Spinner { s := spinner.New() - s.Spinner = spinner.Dot - s.Style = s.Style.Foreground(lipgloss.Color("205")) // Purple color + s.Spinner = spinner.Points // More modern spinner style + theme := GetTheme() + s.Style = s.Style.Foreground(theme.Primary) ctx, cancel := context.WithCancel(context.Background()) diff --git a/internal/ui/styles.go b/internal/ui/styles.go index 4cd4ebe..0131329 100644 --- a/internal/ui/styles.go +++ b/internal/ui/styles.go @@ -29,19 +29,44 @@ func GetMarkdownRenderer(width int) *glamour.TermRenderer { // generateMarkdownStyleConfig creates an ansi.StyleConfig for markdown rendering func generateMarkdownStyleConfig() ansi.StyleConfig { - // Define colors - using simple colors since we're not implementing theming - textColor := "#ffffff" - mutedColor := "#888888" - headingColor := "#00d7ff" - emphColor := "#ffff87" - strongColor := "#ffffff" - linkColor := "#5fd7ff" - codeColor := "#d7d7af" - errorColor := "#ff5f5f" - keywordColor := "#ff87d7" - stringColor := "#87ff87" - numberColor := "#ffaf87" - commentColor := "#5f5f87" + // Define adaptive colors based on terminal background + var textColor, mutedColor string + if lipgloss.HasDarkBackground() { + textColor = "#F9FAFB" // Light text for dark backgrounds + mutedColor = "#9CA3AF" // Light muted for dark backgrounds + } else { + textColor = "#1F2937" // Dark text for light backgrounds + mutedColor = "#6B7280" // Dark muted for light backgrounds + } + var headingColor, emphColor, strongColor, linkColor, codeColor, errorColor, keywordColor, stringColor, numberColor, commentColor string + if lipgloss.HasDarkBackground() { + // Dark background colors + headingColor = "#22D3EE" // Cyan + emphColor = "#FDE047" // Yellow + strongColor = "#F9FAFB" // Light gray + linkColor = "#60A5FA" // Blue + codeColor = "#D1D5DB" // Light gray + errorColor = "#F87171" // Red + keywordColor = "#C084FC" // Purple + stringColor = "#34D399" // Green + numberColor = "#FBBF24" // Orange + commentColor = "#9CA3AF" // Muted gray + } else { + // Light background colors + headingColor = "#0891B2" // Dark cyan + emphColor = "#D97706" // Orange + strongColor = "#1F2937" // Dark gray + linkColor = "#2563EB" // Blue + codeColor = "#374151" // Dark gray + errorColor = "#DC2626" // Red + keywordColor = "#7C3AED" // Purple + stringColor = "#059669" // Green + numberColor = "#D97706" // Orange + commentColor = "#6B7280" // Muted gray + } + + // Don't apply background in markdown - let the block renderer handle it + bgColor := "" return ansi.StyleConfig{ Document: ansi.StyleBlock{ @@ -50,7 +75,7 @@ func generateMarkdownStyleConfig() ansi.StyleConfig { BlockSuffix: "", Color: stringPtr(textColor), }, - Margin: uintPtr(defaultMargin), + Margin: uintPtr(0), // Remove margin to prevent spacing }, BlockQuote: ansi.StyleBlock{ StylePrimitive: ansi.StylePrimitive{ @@ -59,12 +84,12 @@ func generateMarkdownStyleConfig() ansi.StyleConfig { Prefix: "┃ ", }, Indent: uintPtr(1), - IndentToken: stringPtr(BaseStyle().Render(" ")), + IndentToken: stringPtr(lipgloss.NewStyle().Background(lipgloss.AdaptiveColor{Light: bgColor, Dark: bgColor}).Render(" ")), }, List: ansi.StyleList{ - LevelIndent: defaultMargin, + LevelIndent: 0, // Remove list indentation StyleBlock: ansi.StyleBlock{ - IndentToken: stringPtr(BaseStyle().Render(" ")), + IndentToken: stringPtr(lipgloss.NewStyle().Background(lipgloss.AdaptiveColor{Light: bgColor, Dark: bgColor}).Render(" ")), StylePrimitive: ansi.StylePrimitive{ Color: stringPtr(textColor), }, @@ -124,7 +149,8 @@ func generateMarkdownStyleConfig() ansi.StyleConfig { Color: stringPtr(mutedColor), }, Emph: ansi.StylePrimitive{ - Color: stringPtr(emphColor), + Color: stringPtr(emphColor), + Italic: boolPtr(true), }, Strong: ansi.StylePrimitive{ @@ -149,25 +175,30 @@ func generateMarkdownStyleConfig() ansi.StyleConfig { Unticked: "[ ] ", }, Link: ansi.StylePrimitive{ - Color: stringPtr(linkColor), + Color: stringPtr(linkColor), + Underline: boolPtr(true), }, LinkText: ansi.StylePrimitive{ Color: stringPtr(linkColor), - Bold: boolPtr(true), + + Bold: boolPtr(true), }, Image: ansi.StylePrimitive{ - Color: stringPtr(linkColor), + Color: stringPtr(linkColor), + Underline: boolPtr(true), Format: "🖼 {{.text}}", }, ImageText: ansi.StylePrimitive{ - Color: stringPtr(linkColor), + Color: stringPtr(linkColor), + Format: "{{.text}}", }, Code: ansi.StyleBlock{ StylePrimitive: ansi.StylePrimitive{ - Color: stringPtr(codeColor), + Color: stringPtr(codeColor), + Prefix: "", Suffix: "", }, @@ -175,10 +206,10 @@ func generateMarkdownStyleConfig() ansi.StyleConfig { CodeBlock: ansi.StyleCodeBlock{ StyleBlock: ansi.StyleBlock{ StylePrimitive: ansi.StylePrimitive{ - Prefix: " ", + Prefix: "", Color: stringPtr(codeColor), }, - Margin: uintPtr(defaultMargin), + Margin: uintPtr(0), // Remove margin }, Chroma: &ansi.Chroma{ Text: ansi.StylePrimitive{ @@ -248,7 +279,8 @@ func generateMarkdownStyleConfig() ansi.StyleConfig { Color: stringPtr(errorColor), }, GenericEmph: ansi.StylePrimitive{ - Color: stringPtr(emphColor), + Color: stringPtr(emphColor), + Italic: boolPtr(true), }, GenericInserted: ansi.StylePrimitive{ @@ -256,7 +288,8 @@ func generateMarkdownStyleConfig() ansi.StyleConfig { }, GenericStrong: ansi.StylePrimitive{ Color: stringPtr(strongColor), - Bold: boolPtr(true), + + Bold: boolPtr(true), }, GenericSubheading: ansi.StylePrimitive{ Color: stringPtr(headingColor), diff --git a/internal/ui/usage_tracker.go b/internal/ui/usage_tracker.go index 9fddb43..e5a0350 100644 --- a/internal/ui/usage_tracker.go +++ b/internal/ui/usage_tracker.go @@ -4,6 +4,7 @@ import ( "fmt" "sync" + "github.com/charmbracelet/lipgloss" "github.com/mark3labs/mcphost/internal/models" "github.com/mark3labs/mcphost/internal/tokens" ) @@ -68,7 +69,7 @@ func (ut *UsageTracker) UpdateUsage(inputTokens, outputTokens, cacheReadTokens, // Calculate costs based on model pricing // For OAuth credentials, costs are $0 for usage tracking purposes var inputCost, outputCost, cacheReadCost, cacheWriteCost, totalCost float64 - + if !ut.isOAuth { inputCost = float64(inputTokens) * ut.modelInfo.Cost.Input / 1000000 // Cost is per million tokens outputCost = float64(outputTokens) * ut.modelInfo.Cost.Output / 1000000 @@ -120,7 +121,7 @@ func (ut *UsageTracker) EstimateAndUpdateUsageFromText(inputText, outputText str ut.UpdateUsage(inputTokens, outputTokens, 0, 0) } -// RenderUsageInfo renders the current usage information in a single line format +// RenderUsageInfo renders enhanced usage information with better styling func (ut *UsageTracker) RenderUsageInfo() string { ut.mu.RLock() defer ut.mu.RUnlock() @@ -129,29 +130,73 @@ func (ut *UsageTracker) RenderUsageInfo() string { return "" } + // Import lipgloss for styling + baseStyle := lipgloss.NewStyle() + // Calculate total tokens totalTokens := ut.sessionStats.TotalInputTokens + ut.sessionStats.TotalOutputTokens - // Format tokens with K suffix if >= 1000 + // Format tokens with K/M suffix for better readability var tokenStr string - if totalTokens >= 1000 { + if totalTokens >= 1000000 { + tokenStr = fmt.Sprintf("%.1fM", float64(totalTokens)/1000000) + } else if totalTokens >= 1000 { tokenStr = fmt.Sprintf("%.1fK", float64(totalTokens)/1000) } else { tokenStr = fmt.Sprintf("%d", totalTokens) } - // Calculate percentage based on context limit (if available) + // Calculate percentage based on context limit with color coding var percentageStr string + var percentageColor lipgloss.AdaptiveColor if ut.modelInfo.Limit.Context > 0 { percentage := float64(totalTokens) / float64(ut.modelInfo.Limit.Context) * 100 - percentageStr = fmt.Sprintf(" (%.0f%%)", percentage) + + // Color code based on usage percentage + theme := GetTheme() + if percentage >= 80 { + percentageColor = theme.Error // Red + } else if percentage >= 60 { + percentageColor = theme.Warning // Orange + } else { + percentageColor = theme.Success // Green + } + + percentageStr = baseStyle. + Foreground(percentageColor). + Render(fmt.Sprintf(" (%.0f%%)", percentage)) } - // Format cost - costStr := fmt.Sprintf("$%.2f", ut.sessionStats.TotalCost) + // Format cost with appropriate styling + theme := GetTheme() + var costStr string + if ut.isOAuth { + costStr = baseStyle. + Foreground(theme.Primary). + Render("$0.00") + } else { + costStr = baseStyle. + Foreground(theme.Primary). + Render(fmt.Sprintf("$%.4f", ut.sessionStats.TotalCost)) + } + + // Create styled components + tokensLabel := baseStyle. + Foreground(theme.Muted). + Render("Tokens: ") + + tokensValue := baseStyle. + Foreground(theme.Text). + Bold(true). + Render(tokenStr) + + costLabel := baseStyle. + Foreground(theme.Muted). + Render(" | Cost: ") - // Build the single line display - return fmt.Sprintf("Tokens: %s%s, Cost: %s", tokenStr, percentageStr, costStr) + // Build the enhanced display + return fmt.Sprintf("%s%s%s%s%s\n", + tokensLabel, tokensValue, percentageStr, costLabel, costStr) } // GetSessionStats returns a copy of the current session statistics diff --git a/internal/ui/usage_tracker_render_test.go b/internal/ui/usage_tracker_render_test.go index 0e983f5..4fc6be7 100644 --- a/internal/ui/usage_tracker_render_test.go +++ b/internal/ui/usage_tracker_render_test.go @@ -27,8 +27,8 @@ func TestUsageTracker_RenderUsageInfo_OAuth(t *testing.T) { oauthTracker.UpdateUsage(1500, 500, 0, 0) // 2000 total tokens rendered := oauthTracker.RenderUsageInfo() - - // Should show tokens and percentage, but cost should be $0.00 + + // Should show tokens and percentage, but cost should show "$0.00" if !strings.Contains(rendered, "Tokens: 2.0K") { t.Errorf("Expected rendered output to contain 'Tokens: 2.0K', got: %s", rendered) } @@ -44,16 +44,16 @@ func TestUsageTracker_RenderUsageInfo_OAuth(t *testing.T) { regularTracker.UpdateUsage(1500, 500, 0, 0) // Same token usage regularRendered := regularTracker.RenderUsageInfo() - + // Should show tokens and actual cost if !strings.Contains(regularRendered, "Tokens: 2.0K") { t.Errorf("Expected regular rendered output to contain 'Tokens: 2.0K', got: %s", regularRendered) } if strings.Contains(regularRendered, "Cost: $0.00") { - t.Errorf("Expected regular rendered output to NOT show $0.00 cost, got: %s", regularRendered) + t.Errorf("Expected regular rendered output to NOT show $0.00, got: %s", regularRendered) } // Should show actual calculated cost (1500*3 + 500*15)/1000000 = 0.0120 - if !strings.Contains(regularRendered, "Cost: $0.01") { // Rounded to 2 decimal places + if !strings.Contains(regularRendered, "Cost: $0.0120") { // Now showing 4 decimal places t.Errorf("Expected regular rendered output to show actual cost, got: %s", regularRendered) } -} \ No newline at end of file +} diff --git a/internal/ui/usage_tracker_test.go b/internal/ui/usage_tracker_test.go index 6f62cfa..04eb2e2 100644 --- a/internal/ui/usage_tracker_test.go +++ b/internal/ui/usage_tracker_test.go @@ -27,8 +27,8 @@ func TestUsageTracker_OAuthCosts(t *testing.T) { } // Check that costs are calculated for regular API key - expectedInputCost := float64(1000) * 3.0 / 1000000 // $0.003 - expectedOutputCost := float64(500) * 15.0 / 1000000 // $0.0075 + expectedInputCost := float64(1000) * 3.0 / 1000000 // $0.003 + expectedOutputCost := float64(500) * 15.0 / 1000000 // $0.0075 expectedTotalCost := expectedInputCost + expectedOutputCost // $0.0105 if stats.InputCost != expectedInputCost { @@ -83,7 +83,7 @@ func TestUsageTracker_OAuthSessionStats(t *testing.T) { // Test OAuth session stats accumulation oauthTracker := NewUsageTracker(modelInfo, "anthropic", 80, true) - + // Make multiple requests oauthTracker.UpdateUsage(1000, 500, 0, 0) oauthTracker.UpdateUsage(2000, 1000, 0, 0) @@ -107,4 +107,4 @@ func TestUsageTracker_OAuthSessionStats(t *testing.T) { if sessionStats.RequestCount != 2 { t.Errorf("Expected request count to be 2, got %d", sessionStats.RequestCount) } -} \ No newline at end of file +}