diff --git a/packages/tui/internal/components/chat_view.go b/packages/tui/internal/components/chat_view.go new file mode 100644 index 00000000..9e419ecc --- /dev/null +++ b/packages/tui/internal/components/chat_view.go @@ -0,0 +1,398 @@ +package components + +import ( + "strings" + + tea "github.com/charmbracelet/bubbletea/v2" + "github.com/charmbracelet/lipgloss/v2" + "github.com/rogue/tui/internal/styles" + "github.com/rogue/tui/internal/theme" +) + +// ChatView is a reusable chat component with message history and input +type ChatView struct { + // Layout + width int + height int + + // Components + messageHistory *MessageHistoryView + input *TextArea + + // State + errorMessage string + + // Config + inputLabel string + inputPlaceholder string + showHeader bool + headerText string + baseID int // Base ID for sub-components +} + +// NewChatView creates a new chat view component +func NewChatView(id int, width, height int, t theme.Theme) *ChatView { + // Create message history view + messageHistory := NewMessageHistoryView(id, width, height-24, t) + + // Create input + input := NewTextArea(id+100, width-6, 4, t) + input.Placeholder = "Type your message..." + input.ShowLineNumbers = false // Disable line numbers for chat input + input.Focus() + + return &ChatView{ + width: width, + height: height, + messageHistory: messageHistory, + input: &input, + errorMessage: "", + inputLabel: "Your Response:", + inputPlaceholder: "Type your message...", + showHeader: false, + headerText: "", + baseID: id, + } +} + +// SetSize updates the component dimensions +func (c *ChatView) SetSize(width, height int) { + c.width = width + c.height = height + if c.messageHistory != nil { + c.messageHistory.SetSize(width, height-24) + } + if c.input != nil { + c.input.Width = width - 4 + } +} + +// SetPrefixes customizes the message prefixes +func (c *ChatView) SetPrefixes(userPrefix, assistantPrefix string) { + if c.messageHistory != nil { + c.messageHistory.SetPrefixes(userPrefix, assistantPrefix) + } +} + +// SetInputLabel customizes the input label +func (c *ChatView) SetInputLabel(label string) { + c.inputLabel = label +} + +// SetInputPlaceholder customizes the input placeholder +func (c *ChatView) SetInputPlaceholder(placeholder string) { + c.inputPlaceholder = placeholder + if c.input != nil { + c.input.Placeholder = placeholder + } +} + +// SetHeader sets a custom header text for the chat view +func (c *ChatView) SetHeader(text string) { + c.showHeader = text != "" + c.headerText = text +} + +// HideHeader hides the header +func (c *ChatView) HideHeader() { + c.showHeader = false + c.headerText = "" +} + +// AddMessage adds a message to the chat history +func (c *ChatView) AddMessage(role, content string) { + if c.messageHistory != nil { + c.messageHistory.AddMessage(role, content) + } +} + +// ClearMessages removes all messages +func (c *ChatView) ClearMessages() { + if c.messageHistory != nil { + c.messageHistory.ClearMessages() + } +} + +// GetMessages returns all messages +func (c *ChatView) GetMessages() []Message { + if c.messageHistory != nil { + return c.messageHistory.GetMessages() + } + return []Message{} +} + +// GetError returns the current error message +func (c *ChatView) GetError() string { + return c.errorMessage +} + +// SetLoading sets the loading state +func (c *ChatView) SetLoading(loading bool) { + if c.messageHistory != nil { + c.messageHistory.SetSpinner(loading) + } +} + +// StartSpinner starts the spinner animation +func (c *ChatView) StartSpinner() tea.Cmd { + if c.messageHistory != nil { + return c.messageHistory.StartSpinner() + } + return nil +} + +// IsLoading returns the loading state +func (c *ChatView) IsLoading() bool { + if c.messageHistory != nil { + return c.messageHistory.showSpinner + } + return false +} + +// SetError sets an error message +func (c *ChatView) SetError(err string) { + c.errorMessage = err +} + +// ClearError clears the error message +func (c *ChatView) ClearError() { + c.errorMessage = "" +} + +// GetInputValue returns the current input value +func (c *ChatView) GetInputValue() string { + if c.input != nil { + return c.input.GetValue() + } + return "" +} + +// ClearInput clears the input field +func (c *ChatView) ClearInput() { + if c.input != nil { + c.input.SetValue("") + } +} + +// IsViewportFocused returns true if viewport is focused +func (c *ChatView) IsViewportFocused() bool { + if c.messageHistory != nil { + return c.messageHistory.IsFocused() + } + return false +} + +// IsViewportFocused returns true if viewport is focused +func (c *ChatView) IsInputFocused() bool { + if c.input != nil { + return c.input.IsFocused() + } + return false +} + +// FocusViewport focuses the viewport for scrolling +func (c *ChatView) FocusViewport() { + if c.messageHistory != nil { + c.messageHistory.Focus() + } + if c.input != nil { + c.input.Blur() + } +} + +// FocusInput focuses the input field +func (c *ChatView) FocusInput() { + if c.messageHistory != nil { + c.messageHistory.Blur() + } + if c.input != nil { + c.input.Focus() + } +} + +// Update handles keyboard input for the chat view +func (c *ChatView) Update(msg tea.Msg) tea.Cmd { + switch msg := msg.(type) { + case tea.KeyMsg: + return c.handleKeyPress(msg) + case tea.PasteMsg: + // Forward paste to input if not loading and not scrolling + if c.input != nil && !c.IsLoading() && c.IsInputFocused() { + updatedTextArea, cmd := c.input.Update(msg) + *c.input = *updatedTextArea + return cmd + } + return nil + case SpinnerTickMsg: + if c.messageHistory != nil { + return c.messageHistory.Update(msg) + } + } + return nil +} + +// handleKeyPress processes keyboard input +func (c *ChatView) handleKeyPress(msg tea.KeyMsg) tea.Cmd { + switch msg.String() { + case "tab", "shift+tab": + // Toggle focus between viewport and input + if !c.IsLoading() { + if c.IsViewportFocused() { + c.FocusInput() + } else { + c.FocusViewport() + } + } + return nil + + case "up": + // When viewport is focused: scroll up + // When input is focused: move focus to viewport + if !c.IsLoading() { + if c.IsViewportFocused() { + // Scroll viewport up + if c.messageHistory != nil { + c.messageHistory.ScrollUp(1) + } + } else { + // Move focus to viewport + c.FocusViewport() + } + } + return nil + + case "down": + // When viewport is focused: scroll down or move to input if at bottom + if !c.IsLoading() { + if c.IsViewportFocused() { + // Check if viewport is at bottom + if c.messageHistory != nil && !c.messageHistory.AtBottom() { + // Not at bottom - scroll down + c.messageHistory.ScrollDown(1) + } else { + // At bottom or messageHistory is nil - shift focus to input + c.FocusInput() + } + } + } + return nil + + case "shift+enter": + // Insert newline in the input + if c.input != nil && !c.IsLoading() && c.IsInputFocused() { + c.input.InsertNewline() + return nil + } + return nil + + default: + // Forward to TextArea for text input (only when input is focused) + if c.input != nil && !c.IsLoading() && c.IsInputFocused() { + updatedTextArea, cmd := c.input.Update(msg) + *c.input = *updatedTextArea + return cmd + } + return nil + } +} + +// ViewWithoutHelp renders the chat view without the help/error section at the bottom +// Useful when you want to add custom UI elements below the chat +func (c *ChatView) ViewWithoutHelp(t theme.Theme) string { + return c.renderChatContent(t, false) +} + +// View renders the chat view +func (c *ChatView) View(t theme.Theme) string { + return c.renderChatContent(t, true) +} + +// renderChatContent renders the chat view with optional help section +func (c *ChatView) renderChatContent(t theme.Theme, includeHelp bool) string { + // Header (optional) + var header string + if c.showHeader { + header = lipgloss.NewStyle(). + Background(t.Background()). + Foreground(t.Primary()). + Bold(true). + Render("\n" + c.headerText) + } + + // Render message history using MessageHistoryView + var borderedHistory string + if c.messageHistory != nil { + borderedHistory = c.messageHistory.View(t) + } + + // Input section + inputLabelStyled := lipgloss.NewStyle(). + Background(t.Background()). + Foreground(t.Accent()). + Padding(1, 0). + Render(c.inputLabel) + + // Input area with focus-dependent border + var inputArea string + if c.input != nil { + borderColor := t.TextMuted() + if c.IsInputFocused() { + borderColor = t.Primary() + } + + inputArea = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(borderColor). + Render(c.input.View()) + } + + // Build the view + contentParts := make([]string, 0, 5) + if c.showHeader && header != "" { + contentParts = append(contentParts, header) + } + contentParts = append(contentParts, borderedHistory, inputLabelStyled, inputArea) + + // Only add help/error section if requested + if includeHelp { + // Help text - context-aware based on focus + var helpText string + if c.IsViewportFocused() { + helpText = "โ†‘โ†“ scroll Tab switch focus" + } else { + helpText = "โ†‘ to scroll history Tab switch focus Enter send Shift+Enter new line" + } + helpStyle := lipgloss.NewStyle(). + Background(t.Background()). + Foreground(t.TextMuted()). + Padding(1, 0). + Render(helpText) + + help := lipgloss.Place( + c.width-4, + 3, + lipgloss.Right, + lipgloss.Top, + helpStyle, + styles.WhitespaceStyle(t.Background()), + ) + + // Error display + errorLine := "" + if c.errorMessage != "" { + errorLine = lipgloss.NewStyle(). + Background(t.Background()). + Foreground(t.Error()). + Padding(1, 0). + Render("โš  " + c.errorMessage) + } + + if errorLine != "" { + contentParts = append(contentParts, errorLine) + } else { + contentParts = append(contentParts, help) + } + } + + return strings.Join(contentParts, "\n") +} diff --git a/packages/tui/internal/components/message_history_view.go b/packages/tui/internal/components/message_history_view.go new file mode 100644 index 00000000..d88d90f2 --- /dev/null +++ b/packages/tui/internal/components/message_history_view.go @@ -0,0 +1,364 @@ +package components + +import ( + "strings" + + tea "github.com/charmbracelet/bubbletea/v2" + "github.com/charmbracelet/lipgloss/v2" + "github.com/charmbracelet/lipgloss/v2/compat" + "github.com/rogue/tui/internal/theme" +) + +// Message represents a single message in the history +type Message struct { + Role string // "user", "assistant", or custom role + Content string +} + +// MessageHistoryView is a reusable scrollable message display component +type MessageHistoryView struct { + // Messages + messages []Message + + // Layout + width int + height int + + // Components + viewport *Viewport + focused bool // true when focused for scrolling + + // Config + userPrefix string + assistantPrefix string + showSpinner bool + spinner *Spinner + + // Rendering + userColor compat.AdaptiveColor + assistantColor compat.AdaptiveColor +} + +// NewMessageHistoryView creates a new message history viewport +func NewMessageHistoryView(id int, width, height int, t theme.Theme) *MessageHistoryView { + viewport := NewViewport(id, width-4, height) + viewport.WrapContent = true + + spinner := NewSpinner(id + 100) + + return &MessageHistoryView{ + messages: make([]Message, 0), + width: width, + height: height, + viewport: &viewport, + focused: false, + userPrefix: "๐Ÿ‘ค You: ", + assistantPrefix: "๐Ÿค– AI: ", + showSpinner: false, + spinner: &spinner, + userColor: t.Text(), // Will use theme Primary + assistantColor: t.Text(), // Will use theme Accent + } +} + +// SetSize updates the component dimensions +func (m *MessageHistoryView) SetSize(width, height int) { + m.width = width + m.height = height + if m.viewport != nil { + m.viewport.Width = width - 4 + // Account for border (2) + padding (2) = 4 lines total + m.viewport.Height = height - 4 + } +} + +// SetPrefixes customizes the message prefixes +func (m *MessageHistoryView) SetPrefixes(userPrefix, assistantPrefix string) { + m.userPrefix = userPrefix + m.assistantPrefix = assistantPrefix +} + +// SetColors customizes the message colors +func (m *MessageHistoryView) SetColors(userColor, assistantColor compat.AdaptiveColor) { + m.userColor = userColor + m.assistantColor = assistantColor +} + +// AddMessage adds a message to the history +func (m *MessageHistoryView) AddMessage(role, content string) { + m.messages = append(m.messages, Message{ + Role: role, + Content: content, + }) +} + +// ClearMessages removes all messages +func (m *MessageHistoryView) ClearMessages() { + m.messages = make([]Message, 0) +} + +// GetMessages returns all messages +func (m *MessageHistoryView) GetMessages() []Message { + return m.messages +} + +// SetSpinner enables/disables the loading spinner +func (m *MessageHistoryView) SetSpinner(enabled bool) { + m.showSpinner = enabled + if m.spinner != nil { + m.spinner.SetActive(enabled) + } +} + +// StartSpinner starts the spinner animation +func (m *MessageHistoryView) StartSpinner() tea.Cmd { + m.showSpinner = true + if m.spinner != nil { + return m.spinner.Start() + } + return nil +} + +// StopSpinner stops the spinner +func (m *MessageHistoryView) StopSpinner() { + m.SetSpinner(false) +} + +// IsFocused returns true if the viewport is focused for scrolling +func (m *MessageHistoryView) IsFocused() bool { + return m.focused +} + +// Focus focuses the viewport for scrolling +func (m *MessageHistoryView) Focus() { + m.focused = true +} + +// Blur removes focus from the viewport +func (m *MessageHistoryView) Blur() { + m.focused = false +} + +// ScrollUp scrolls the viewport up by n lines +func (m *MessageHistoryView) ScrollUp(lines int) { + if m.viewport != nil { + m.viewport.ScrollUp(lines) + } +} + +// ScrollDown scrolls the viewport down by n lines +func (m *MessageHistoryView) ScrollDown(lines int) { + if m.viewport != nil { + m.viewport.ScrollDown(lines) + } +} + +// AtBottom returns true if scrolled to bottom +func (m *MessageHistoryView) AtBottom() bool { + if m.viewport != nil { + return m.viewport.AtBottom() + } + return true +} + +// AtTop returns true if scrolled to top +func (m *MessageHistoryView) AtTop() bool { + if m.viewport != nil { + return m.viewport.AtTop() + } + return true +} + +// GotoBottom scrolls to the bottom +func (m *MessageHistoryView) GotoBottom() { + if m.viewport != nil { + m.viewport.GotoBottom() + } +} + +// GotoTop scrolls to the top +func (m *MessageHistoryView) GotoTop() { + if m.viewport != nil { + m.viewport.GotoTop() + } +} + +// Update handles messages for the history view +func (m *MessageHistoryView) Update(msg tea.Msg) tea.Cmd { + switch msg := msg.(type) { + case SpinnerTickMsg: + if m.spinner != nil { + updatedSpinner, cmd := m.spinner.Update(msg) + *m.spinner = updatedSpinner + return cmd + } + } + return nil +} + +// View renders the message history with border +func (m *MessageHistoryView) View(t theme.Theme) string { + // Render messages + messageLines := m.renderMessages(t) + + // Add loading spinner if enabled + if m.showSpinner && m.spinner != nil { + spinnerView := m.spinner.View() + if spinnerView != "" { + textStyle := lipgloss.NewStyle().Foreground(t.Accent()).Background(t.Background()) + loadingLine := spinnerView + textStyle.Render(" thinking...") + messageLines = append(messageLines, loadingLine) + } + } + + // Update viewport content + if m.viewport != nil { + m.viewport.SetContent(strings.Join(messageLines, "\n")) + // Auto-scroll to bottom when not focused + if !m.focused { + m.viewport.GotoBottom() + } + } + + messageHistory := "" + if m.viewport != nil { + messageHistory = m.viewport.View() + } else { + messageHistory = strings.Join(messageLines, "\n") + } + + // Border color based on focus + borderColor := t.TextMuted() + if m.focused { + borderColor = t.Primary() + } + + // Calculate content height (viewport height) to prevent overflow + // Viewport is already sized to m.height - 4 to account for border/padding + contentHeight := m.height - 4 + if contentHeight < 0 { + contentHeight = 0 + } + + historyStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(borderColor). + Background(t.Background()). + Padding(1, 1). + Width(m.width - 4). + Height(contentHeight) + + return historyStyle.Render(messageHistory) +} + +// ViewWithoutBorder renders without the border (just the viewport content) +func (m *MessageHistoryView) ViewWithoutBorder(t theme.Theme) string { + // Render messages + messageLines := m.renderMessages(t) + + // Add loading spinner if enabled + if m.showSpinner && m.spinner != nil { + spinnerView := m.spinner.View() + if spinnerView != "" { + textStyle := lipgloss.NewStyle().Foreground(t.Accent()).Background(t.Background()) + loadingLine := spinnerView + textStyle.Render(" thinking...") + messageLines = append(messageLines, loadingLine) + } + } + + // Update viewport content + if m.viewport != nil { + m.viewport.SetContent(strings.Join(messageLines, "\n")) + // Auto-scroll to bottom when not focused + if !m.focused { + m.viewport.GotoBottom() + } + return m.viewport.View() + } + + return strings.Join(messageLines, "\n") +} + +// renderMessages renders all messages with proper formatting +func (m *MessageHistoryView) renderMessages(t theme.Theme) []string { + var messageLines []string + + for _, msg := range m.messages { + var prefix string + var textStyle lipgloss.Style + + // Determine prefix and color based on role + if msg.Role == "assistant" { + prefix = m.assistantPrefix + if m.assistantColor != (compat.AdaptiveColor{}) { + textStyle = lipgloss.NewStyle().Foreground(m.assistantColor) + } else { + textStyle = lipgloss.NewStyle().Foreground(t.Accent()) + } + } else if msg.Role == "system" { + // System messages use success/muted color + prefix = m.assistantPrefix + textStyle = lipgloss.NewStyle().Foreground(t.Success()) + } else { + // Default to user style + prefix = m.userPrefix + if m.userColor != (compat.AdaptiveColor{}) { + textStyle = lipgloss.NewStyle().Foreground(m.userColor) + } else { + textStyle = lipgloss.NewStyle().Foreground(t.Primary()) + } + } + + // Calculate visual width of prefix (accounting for emojis) + // Emojis typically take 2 visual columns in terminals + prefixVisualWidth := calculateVisualWidth(prefix) + availableWidth := m.width - prefixVisualWidth - 8 + if availableWidth < 40 { + availableWidth = 40 + } + + // Create indentation string matching prefix visual width + indentation := strings.Repeat(" ", prefixVisualWidth) + + // Preserve newlines by processing each paragraph separately + paragraphs := strings.Split(msg.Content, "\n") + var allLines []string + for _, para := range paragraphs { + if strings.TrimSpace(para) == "" { + allLines = append(allLines, "") + } else { + wrapped := wrapText(para, availableWidth) + allLines = append(allLines, strings.Split(wrapped, "\n")...) + } + } + + for i, line := range allLines { + if i == 0 { + // First line with prefix + messageLines = append(messageLines, textStyle.Render(prefix+line)) + } else { + // Continuation lines with indentation matching prefix width + messageLines = append(messageLines, textStyle.Render(indentation+line)) + } + } + messageLines = append(messageLines, "") // Blank line between messages + } + + return messageLines +} + +// calculateVisualWidth returns the visual width of a string in terminal columns +// accounting for emojis and other wide characters +func calculateVisualWidth(s string) int { + width := 0 + for _, r := range s { + // Emojis and wide unicode characters typically take 2 columns + // Simple heuristic: if rune is > 0x1F300, it's likely an emoji + if r >= 0x1F300 && r <= 0x1FAFF { + width += 2 + } else { + width += 1 + } + } + return width +} diff --git a/packages/tui/internal/components/scenario_editor.go b/packages/tui/internal/components/scenario_editor.go index 33c6427f..20052674 100644 --- a/packages/tui/internal/components/scenario_editor.go +++ b/packages/tui/internal/components/scenario_editor.go @@ -41,18 +41,13 @@ type ScenarioEditor struct { expectedOutcomeTextArea *TextArea // Interview mode - interviewMode bool // true when in interview mode - interviewSessionID string // current interview session - interviewMessages []InterviewMessage // conversation history - interviewInput *TextArea // multi-line input for user responses - interviewViewport *Viewport // scrollable message history - interviewLoading bool // waiting for AI response - interviewError string // error message - lastUserMessage string // track last user message for display - interviewSpinner Spinner // spinner for loading state - awaitingBusinessCtxApproval bool // waiting for user to approve/edit business context - proposedBusinessContext string // the AI-generated business context for review - approveButtonFocused bool // true when approve button is focused instead of input + interviewMode bool // true when in interview mode + interviewSessionID string // current interview session + interviewChatView *ChatView // reusable chat component + lastUserMessage string // track last user message for display + awaitingBusinessCtxApproval bool // waiting for user to approve/edit business context + proposedBusinessContext string // the AI-generated business context for review + approveButtonFocused bool // true when approve button is focused instead of input // Configuration (set by parent app) - exported so app.go can access ServerURL string // Rogue server URL @@ -94,20 +89,8 @@ func NewScenarioEditor() ScenarioEditor { outTA.ApplyTheme(theme.CurrentTheme()) editor.expectedOutcomeTextArea = &outTA - // Initialize interview components - interviewVP := NewViewport(9995, 80, 15) // Interview message viewport - editor.interviewViewport = &interviewVP - - interviewTA := NewTextArea(9994, 80, 5, theme.CurrentTheme()) // Interview input text area - interviewTA.ApplyTheme(theme.CurrentTheme()) - interviewTA.Placeholder = "Type your response here..." - interviewTA.ShowLineNumbers = false // Disable line numbers for interview input - interviewTA.Focus() // Start focused - editor.interviewInput = &interviewTA - - // Initialize interview spinner - spinner := NewSpinner(9993) // Unique ID for interview spinner - editor.interviewSpinner = spinner + // Interview chat view will be initialized when entering interview mode + // (lazy initialization to have proper dimensions) // Discover scenarios.json location and load editor.filePath = discoverScenariosFile() @@ -153,16 +136,9 @@ func (e *ScenarioEditor) SetSize(width, height int) { e.expectedOutcomeTextArea.SetSize(width-4, 8) // Height will be set dynamically in renderEditView } - // Update interview components size - if e.interviewViewport != nil { - interviewHistoryHeight := height - 20 - if interviewHistoryHeight < 10 { - interviewHistoryHeight = 10 - } - e.interviewViewport.SetSize(width-4, interviewHistoryHeight) - } - if e.interviewInput != nil { - e.interviewInput.SetSize(width-6, 5) // Fixed height for input, account for border + // Update interview chat view size + if e.interviewChatView != nil { + e.interviewChatView.SetSize(width, height) } } @@ -209,10 +185,12 @@ func (e *ScenarioEditor) calculateVisibleItems() { func (e ScenarioEditor) Update(msg tea.Msg) (ScenarioEditor, tea.Cmd) { switch m := msg.(type) { case SpinnerTickMsg: - // Update interview spinner - var cmd tea.Cmd - e.interviewSpinner, cmd = e.interviewSpinner.Update(msg) - return e, cmd + // Update interview chat view spinner + if e.interviewChatView != nil { + cmd := e.interviewChatView.Update(msg) + return e, cmd + } + return e, nil // StartInterviewMsg is NOT handled here - it bubbles up to app.go // app.go will make the API call and send back InterviewStartedMsg case InterviewStartedMsg: @@ -228,13 +206,11 @@ func (e ScenarioEditor) Update(msg tea.Msg) (ScenarioEditor, tea.Cmd) { e.mode = ListMode e.interviewMode = false e.interviewSessionID = "" - e.interviewMessages = nil - e.interviewLoading = false - e.interviewError = "" e.awaitingBusinessCtxApproval = false e.proposedBusinessContext = "" e.approveButtonFocused = false e.lastUserMessage = "" + e.interviewChatView = nil e.infoMsg = "Interview cancelled" } return e, nil @@ -248,9 +224,8 @@ func (e ScenarioEditor) Update(msg tea.Msg) (ScenarioEditor, tea.Cmd) { return e, cmd } case InterviewMode: - if e.interviewInput != nil && !e.interviewLoading { - updatedTextArea, cmd := e.interviewInput.Update(msg) - *e.interviewInput = *updatedTextArea + if e.interviewChatView != nil && !e.interviewChatView.IsLoading() { + cmd := e.interviewChatView.Update(msg) return e, cmd } case EditMode, AddMode: diff --git a/packages/tui/internal/components/scenario_interview.go b/packages/tui/internal/components/scenario_interview.go index 9f34cfde..2ce7db1d 100644 --- a/packages/tui/internal/components/scenario_interview.go +++ b/packages/tui/internal/components/scenario_interview.go @@ -12,8 +12,9 @@ import ( // handleInterviewStarted processes the interview start response func (e ScenarioEditor) handleInterviewStarted(msg InterviewStartedMsg) (ScenarioEditor, tea.Cmd) { - e.interviewLoading = false - e.interviewSpinner.SetActive(false) // Stop spinner + if e.interviewChatView != nil { + e.interviewChatView.SetLoading(false) + } if msg.Error != nil { // Store error in errorMsg so it's visible in ListMode @@ -24,14 +25,9 @@ func (e ScenarioEditor) handleInterviewStarted(msg InterviewStartedMsg) (Scenari // Store session ID and add initial message e.interviewSessionID = msg.SessionID - e.interviewMessages = append(e.interviewMessages, InterviewMessage{ - Role: "assistant", - Content: msg.InitialMessage, - }) - - // Focus input for user response - if e.interviewInput != nil { - e.interviewInput.Focus() + if e.interviewChatView != nil { + e.interviewChatView.AddMessage("assistant", msg.InitialMessage) + e.interviewChatView.FocusInput() } return e, nil @@ -41,124 +37,26 @@ func (e ScenarioEditor) handleInterviewStarted(msg InterviewStartedMsg) (Scenari func (e ScenarioEditor) handleInterviewMode(msg tea.KeyMsg) (ScenarioEditor, tea.Cmd) { // Handle business context approval state if e.awaitingBusinessCtxApproval { - switch msg.String() { - case "down", "tab": - // Move focus to approve button - if !e.approveButtonFocused { - e.approveButtonFocused = true - if e.interviewInput != nil { - e.interviewInput.Blur() - } - } - return e, nil - - case "up", "shift+tab": - // Move focus to input - if e.approveButtonFocused { - e.approveButtonFocused = false - if e.interviewInput != nil { - e.interviewInput.Focus() - } - } - return e, nil - - case "enter": - if e.approveButtonFocused { - // Approve and generate scenarios - e.awaitingBusinessCtxApproval = false - e.approveButtonFocused = false - e.infoMsg = "Generating scenarios..." - e.interviewLoading = true - e.interviewSpinner.SetActive(true) - return e, tea.Batch( - e.generateScenariosCmd(e.proposedBusinessContext), - e.interviewSpinner.Start(), - ) - } else { - // Send edit request message - if e.interviewInput != nil { - message := e.interviewInput.GetValue() - if strings.TrimSpace(message) != "" { - // Request changes - continue interview - e.awaitingBusinessCtxApproval = false - - // Add user message to history - e.interviewMessages = append(e.interviewMessages, InterviewMessage{ - Role: "user", - Content: message, - }) - - // Clear input and set loading - e.interviewInput.SetValue("") - e.interviewLoading = true - e.interviewSpinner.SetActive(true) - - // Send message via command - return e, tea.Batch( - e.sendInterviewMessageCmd(message), - e.interviewSpinner.Start(), - ) - } - } - } - return e, nil - - case "shift+enter": - // Insert newline in the input if input is focused - if !e.approveButtonFocused && e.interviewInput != nil { - e.interviewInput.InsertNewline() - return e, nil - } - return e, nil - - case "escape", "esc": - // Exit interview with confirmation - if !e.interviewLoading { - dialog := NewConfirmationDialog( - "Exit Interview", - "Are you sure you want to cancel the interview? Progress will be lost.", - ) - return e, func() tea.Msg { return DialogOpenMsg{Dialog: dialog} } - } - return e, nil - - default: - // Forward to TextArea for text input (only if input is focused) - if !e.approveButtonFocused && e.interviewInput != nil && !e.interviewLoading { - updatedTextArea, cmd := e.interviewInput.Update(msg) - *e.interviewInput = *updatedTextArea - return e, cmd - } - return e, nil - } + return e.handleApprovalMode(msg) } - // Normal interview mode handling + // Normal interview mode - handle via ChatView for most keys switch msg.String() { case "escape", "esc": // Exit interview with confirmation - if !e.interviewLoading { + if e.interviewChatView != nil && !e.interviewChatView.IsLoading() { dialog := NewConfirmationDialog( "Exit Interview", "Are you sure you want to cancel the interview? Progress will be lost.", ) - // Return to list mode will be handled by dialog result return e, func() tea.Msg { return DialogOpenMsg{Dialog: dialog} } } return e, nil - case "shift+enter": - // Insert newline in the input - if e.interviewInput != nil && !e.interviewLoading { - e.interviewInput.InsertNewline() - return e, nil - } - return e, nil - case "enter": - // Send message if input not empty - if e.interviewInput != nil && !e.interviewLoading { - message := e.interviewInput.GetValue() + // Send message if input not empty and input is focused + if e.interviewChatView != nil && !e.interviewChatView.IsLoading() && !e.interviewChatView.IsViewportFocused() { + message := e.interviewChatView.GetInputValue() if strings.TrimSpace(message) == "" { return e, nil } @@ -167,29 +65,137 @@ func (e ScenarioEditor) handleInterviewMode(msg tea.KeyMsg) (ScenarioEditor, tea e.lastUserMessage = message // Add user message to history immediately - e.interviewMessages = append(e.interviewMessages, InterviewMessage{ - Role: "user", - Content: message, - }) + e.interviewChatView.AddMessage("user", message) // Clear input and set loading - e.interviewInput.SetValue("") - e.interviewLoading = true - e.interviewSpinner.SetActive(true) // Start spinner + e.interviewChatView.ClearInput() + e.interviewChatView.SetLoading(true) // Send message via command and start spinner animation return e, tea.Batch( e.sendInterviewMessageCmd(message), - e.interviewSpinner.Start(), + e.interviewChatView.StartSpinner(), ) } return e, nil default: - // Forward to TextArea for text input - if e.interviewInput != nil && !e.interviewLoading { - updatedTextArea, cmd := e.interviewInput.Update(msg) - *e.interviewInput = *updatedTextArea + // Delegate to ChatView for navigation and text input + if e.interviewChatView != nil { + cmd := e.interviewChatView.Update(msg) + return e, cmd + } + return e, nil + } +} + +// handleApprovalMode handles keyboard input during business context approval +func (e ScenarioEditor) handleApprovalMode(msg tea.KeyMsg) (ScenarioEditor, tea.Cmd) { + switch msg.String() { + case "tab": + // Cycle through: viewport -> input -> button + if e.interviewChatView != nil { + if e.interviewChatView.IsViewportFocused() { + e.interviewChatView.FocusInput() + } else if !e.approveButtonFocused { + e.approveButtonFocused = true + e.interviewChatView.FocusInput() // Blur happens internally + } else { + // From button back to viewport + e.approveButtonFocused = false + e.interviewChatView.FocusViewport() + } + } + return e, nil + + case "shift+tab": + // Cycle backwards: button -> input -> viewport + if e.interviewChatView != nil { + if e.approveButtonFocused { + e.approveButtonFocused = false + e.interviewChatView.FocusInput() + } else if !e.interviewChatView.IsViewportFocused() { + e.interviewChatView.FocusViewport() + } else { + // From viewport back to button + e.approveButtonFocused = true + } + } + return e, nil + + case "down", "up": + // Let ChatView handle scrolling when focused, otherwise navigate + if e.interviewChatView != nil { + if e.interviewChatView.IsViewportFocused() { + // ChatView handles scrolling + cmd := e.interviewChatView.Update(msg) + return e, cmd + } else if msg.String() == "down" && !e.approveButtonFocused { + // From input to button + e.approveButtonFocused = true + } else if msg.String() == "up" && e.approveButtonFocused { + // From button to input + e.approveButtonFocused = false + e.interviewChatView.FocusInput() + } else if msg.String() == "up" && !e.interviewChatView.IsViewportFocused() { + // From input to viewport + e.interviewChatView.FocusViewport() + } + } + return e, nil + + case "enter": + if e.approveButtonFocused { + // Approve and generate scenarios + e.awaitingBusinessCtxApproval = false + e.approveButtonFocused = false + e.infoMsg = "Generating scenarios..." + if e.interviewChatView != nil { + e.interviewChatView.SetLoading(true) + return e, tea.Batch( + e.generateScenariosCmd(e.proposedBusinessContext), + e.interviewChatView.StartSpinner(), + ) + } + return e, e.generateScenariosCmd(e.proposedBusinessContext) + } else if e.interviewChatView != nil && !e.interviewChatView.IsViewportFocused() { + // Send edit request message + message := e.interviewChatView.GetInputValue() + if strings.TrimSpace(message) != "" { + // Request changes - continue interview + e.awaitingBusinessCtxApproval = false + + // Add user message to history + e.interviewChatView.AddMessage("user", message) + + // Clear input and set loading + e.interviewChatView.ClearInput() + e.interviewChatView.SetLoading(true) + + // Send message via command + return e, tea.Batch( + e.sendInterviewMessageCmd(message), + e.interviewChatView.StartSpinner(), + ) + } + } + return e, nil + + case "escape", "esc": + // Exit interview with confirmation + if e.interviewChatView != nil && !e.interviewChatView.IsLoading() { + dialog := NewConfirmationDialog( + "Exit Interview", + "Are you sure you want to cancel the interview? Progress will be lost.", + ) + return e, func() tea.Msg { return DialogOpenMsg{Dialog: dialog} } + } + return e, nil + + default: + // Forward to ChatView for text input (only if input is focused and not on button) + if e.interviewChatView != nil && !e.approveButtonFocused && !e.interviewChatView.IsLoading() { + cmd := e.interviewChatView.Update(msg) return e, cmd } return e, nil @@ -211,42 +217,34 @@ func (e *ScenarioEditor) sendInterviewMessageCmd(message string) tea.Cmd { // handleInterviewResponse processes the AI's response func (e ScenarioEditor) handleInterviewResponse(msg InterviewResponseMsg) (ScenarioEditor, tea.Cmd) { - e.interviewLoading = false - e.interviewSpinner.SetActive(false) // Stop spinner - - if msg.Error != nil { - e.interviewError = msg.Error.Error() - return e, nil - } + if e.interviewChatView != nil { + e.interviewChatView.SetLoading(false) - // Add AI response to history - e.interviewMessages = append(e.interviewMessages, InterviewMessage{ - Role: "assistant", - Content: msg.Response, - }) - - // Clear error - e.interviewError = "" - - // Check if interview is complete - if msg.IsComplete { - // Store proposed business context for user review - e.proposedBusinessContext = msg.Response - e.awaitingBusinessCtxApproval = true - e.approveButtonFocused = false // Start with input focused - e.infoMsg = "Review the business context. Type to request changes or navigate to button to approve." - - // Focus input for potential edits - if e.interviewInput != nil { - e.interviewInput.Focus() + if msg.Error != nil { + e.interviewChatView.SetError(msg.Error.Error()) + return e, nil } - return e, nil - } + // Add AI response to history + e.interviewChatView.AddMessage("assistant", msg.Response) + e.interviewChatView.ClearError() - // Re-focus input for next response - if e.interviewInput != nil { - e.interviewInput.Focus() + // Check if interview is complete + if msg.IsComplete { + // Store proposed business context for user review + e.proposedBusinessContext = msg.Response + e.awaitingBusinessCtxApproval = true + e.approveButtonFocused = false + e.infoMsg = "Review the business context. Type to request changes or navigate to button to approve." + + // Focus input for potential edits + e.interviewChatView.FocusInput() + + return e, nil + } + + // Re-focus input for next response + e.interviewChatView.FocusInput() } return e, nil @@ -263,7 +261,9 @@ func (e *ScenarioEditor) generateScenariosCmd(businessContext string) tea.Cmd { // handleScenariosGenerated processes generated scenarios func (e ScenarioEditor) handleScenariosGenerated(msg ScenariosGeneratedMsg) (ScenarioEditor, tea.Cmd) { - e.interviewSpinner.SetActive(false) // Stop spinner + if e.interviewChatView != nil { + e.interviewChatView.SetLoading(false) + } if msg.Error != nil { // Store error in errorMsg so it's visible in ListMode @@ -287,11 +287,10 @@ func (e ScenarioEditor) handleScenariosGenerated(msg ScenariosGeneratedMsg) (Sce e.mode = ListMode e.interviewMode = false e.interviewSessionID = "" - e.interviewMessages = nil - e.interviewLoading = false e.awaitingBusinessCtxApproval = false e.proposedBusinessContext = "" e.approveButtonFocused = false + e.interviewChatView = nil // Clear chat view e.rebuildFilter() return e, func() tea.Msg { @@ -301,146 +300,76 @@ func (e ScenarioEditor) handleScenariosGenerated(msg ScenariosGeneratedMsg) (Sce // renderInterviewView renders the interview chat interface func (e ScenarioEditor) renderInterviewView(t theme.Theme) string { - // Calculate message count (user messages only) - userMsgCount := 0 - for _, msg := range e.interviewMessages { - if msg.Role == "user" { - userMsgCount++ - } + if e.interviewChatView == nil { + return "Loading chat..." } - // Header with progress - header := lipgloss.NewStyle(). + // Add interview title + title := lipgloss.NewStyle(). Background(t.Background()). Foreground(t.Primary()). Bold(true). - Render(fmt.Sprintf("\n๐Ÿค– AI Interview - Understanding Your Agent (%d/%d responses)", userMsgCount, max(3, userMsgCount))) - - // Render message history - var messageLines []string - for _, msg := range e.interviewMessages { - var prefix string - var textStyle lipgloss.Style + Render("\n๐Ÿค– AI Interview - Understanding Your Agent\n") - if msg.Role == "assistant" { - textStyle = lipgloss.NewStyle().Foreground(t.Accent()) - prefix = "๐Ÿค– AI: " - } else { - textStyle = lipgloss.NewStyle().Foreground(t.Primary()) - prefix = "๐Ÿ‘ค You: " - } - - // Calculate available width for text (accounting for visual prefix width and padding) - // Emojis + "AI: " or "You: " take about 8 visual characters - // Account for border (4) and some padding - visualPrefixWidth := 8 - availableWidth := e.width - visualPrefixWidth - 8 - if availableWidth < 40 { - availableWidth = 40 - } - - // Preserve newlines by processing each paragraph separately - paragraphs := strings.Split(msg.Content, "\n") - var allLines []string - for _, para := range paragraphs { - if strings.TrimSpace(para) == "" { - // Preserve empty lines - allLines = append(allLines, "") - } else { - // Wrap non-empty paragraphs - wrapped := wrapText(para, availableWidth) - allLines = append(allLines, strings.Split(wrapped, "\n")...) - } - } - lines := allLines - - for i, line := range lines { - if i == 0 { - // First line with prefix - messageLines = append(messageLines, textStyle.Render(prefix+line)) - } else { - // Continuation lines with indentation (8 spaces to match visual prefix width) - messageLines = append(messageLines, textStyle.Render(" "+line)) - } - } - messageLines = append(messageLines, "") // Blank line between messages + // Normal interview mode - just render the ChatView + if !e.awaitingBusinessCtxApproval { + return title + "\n" + e.interviewChatView.View(t) } - // Add loading indicator with spinner if loading - if e.interviewLoading { - spinnerView := e.interviewSpinner.View() - if spinnerView != "" { - textStyle := lipgloss.NewStyle().Foreground(t.Accent()).Background(t.Background()) - loadingLine := spinnerView + textStyle.Render(" thinking...") - messageLines = append(messageLines, loadingLine) - } - } + // Approval mode - render ChatView with custom label and approval button + e.interviewChatView.SetInputLabel("Business Context (Review and Approve):") + e.interviewChatView.SetInputPlaceholder("Request changes here...") - // Update viewport with message history - messageHistory := "" - if e.interviewViewport != nil { - e.interviewViewport.SetContent(strings.Join(messageLines, "\n")) - e.interviewViewport.GotoBottom() // Auto-scroll to bottom - messageHistory = e.interviewViewport.View() - } else { - messageHistory = strings.Join(messageLines, "\n") - } + // Use ViewWithoutHelp to avoid double help text + chatViewContent := title + "\n" + e.interviewChatView.ViewWithoutHelp(t) - // Message history section with border - historyStyle := lipgloss.NewStyle(). + // Create approve button + buttonText := "Approve & Generate" + buttonStyle := lipgloss.NewStyle(). + Background(t.BackgroundPanel()). + Foreground(t.Text()). + Padding(0, 2). Border(lipgloss.RoundedBorder()). - BorderForeground(t.Primary()). - Background(t.Background()). - Padding(1, 1). - Width(e.width - 4). - Height((e.height - 24)) // Leave room for input and help - - borderedHistory := historyStyle.Render(messageHistory) - - // Input section - inputLabel := "Your Response:" - var help string - var buttonLine string + BorderForeground(t.TextMuted()) + + if e.approveButtonFocused { + buttonStyle = buttonStyle. + Background(t.Primary()). + Foreground(t.Background()). + BorderForeground(t.Primary()). + Bold(true) + } - // Different UI for business context approval - if e.awaitingBusinessCtxApproval { - inputLabel = "Business Context (Review and Approve):" - e.interviewInput.Placeholder = "Request changes here..." - - // Create approve button - buttonText := "Approve & Generate" - buttonStyle := lipgloss.NewStyle(). - Background(t.BackgroundPanel()). - Foreground(t.Text()). - Padding(0, 2). - Border(lipgloss.RoundedBorder()). - BorderForeground(t.TextMuted()) + button := buttonStyle.Render(buttonText) - if e.approveButtonFocused { - buttonStyle = buttonStyle. - Background(t.Primary()). - Foreground(t.Background()). - BorderForeground(t.Primary()). - Bold(true) - } + // Check for errors first + errorMsg := e.interviewChatView.GetError() + var bottomLine string - button := buttonStyle.Render(buttonText) + if errorMsg != "" { + // Show error instead of button + errorLine := lipgloss.NewStyle(). + Background(t.Background()). + Foreground(t.Error()). + Padding(1, 0). + Render("โš  " + errorMsg) // Help text on the right - help = lipgloss.NewStyle(). + helpText := "Tab/โ†‘โ†“ switch focus Enter confirm Shift+Enter new line Esc cancel" + help := lipgloss.NewStyle(). Background(t.Background()). Foreground(t.TextMuted()). Padding(1, 0). - Render("โ†‘/โ†“ navigate Enter confirm Shift+Enter new line Esc cancel") + Render(helpText) - buttonLine = lipgloss.JoinHorizontal( + bottomLine = lipgloss.JoinHorizontal( lipgloss.Top, lipgloss.Place( (e.width-4)/2, 3, lipgloss.Left, lipgloss.Top, - button, + errorLine, styles.WhitespaceStyle(t.Background()), ), lipgloss.Place( @@ -453,96 +382,28 @@ func (e ScenarioEditor) renderInterviewView(t theme.Theme) string { ), ) } else { - help = lipgloss.NewStyle(). - Background(t.Background()). - Foreground(t.TextMuted()). - Padding(1, 0). - Render("Enter send Esc cancel Shift+Enter new line") - - help = lipgloss.Place( - (e.width - 4), - 3, - lipgloss.Right, - lipgloss.Top, - help, - styles.WhitespaceStyle(t.Background()), - ) - - } - - inputLabelStyled := lipgloss.NewStyle(). - Background(t.Background()). - Foreground(t.Accent()). - Padding(1, 0). - Render(inputLabel) - - var inputArea string - if e.interviewInput != nil { - // Determine if input is focused - inputFocused := !e.awaitingBusinessCtxApproval || !e.approveButtonFocused - - // Wrap input with primary-colored border only when focused - if inputFocused { - inputArea = lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(t.Primary()). - Render(e.interviewInput.View()) + // Show button and help + // Help text - context-aware based on focus + var helpText string + if e.interviewChatView.IsViewportFocused() { + helpText = "โ†‘โ†“ scroll Tab switch focus Enter confirm Shift+Enter new line Esc cancel" } else { - inputArea = lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(t.TextMuted()). - Render(e.interviewInput.View()) + helpText = "Tab/โ†‘โ†“ switch focus Enter confirm Shift+Enter new line Esc cancel" } - } - - // Error display - errorLine := "" - if e.interviewError != "" { - errorLine = lipgloss.NewStyle(). - Background(t.Background()). - Foreground(t.Error()). - Width(e.width/2 - 4). - Render("โš  " + e.interviewError) - - errorLine = lipgloss.JoinHorizontal( - lipgloss.Top, - lipgloss.Place( - (e.width-4)/2, - 3, - lipgloss.Left, - lipgloss.Top, - errorLine, - styles.WhitespaceStyle(t.Background()), - ), - lipgloss.Place( - (e.width-4)/2, - 3, - lipgloss.Right, - lipgloss.Top, - help, - styles.WhitespaceStyle(t.Background()), - ), - ) - } - - // Completion/status message (only for generation phase, not loading) - statusMsg := "" - if userMsgCount >= 3 && e.interviewLoading { - statusMsg = lipgloss.NewStyle(). + help := lipgloss.NewStyle(). Background(t.Background()). - Foreground(t.Success()). - Width(e.width/2 - 4). - Bold(true). - Render("โœ“ Interview complete!") + Foreground(t.TextMuted()). + Padding(1, 0). + Render(helpText) - statusMsg = lipgloss.JoinHorizontal( + bottomLine = lipgloss.JoinHorizontal( lipgloss.Top, lipgloss.Place( (e.width-4)/2, 3, lipgloss.Left, lipgloss.Top, - statusMsg, + button, styles.WhitespaceStyle(t.Background()), ), lipgloss.Place( @@ -556,22 +417,5 @@ func (e ScenarioEditor) renderInterviewView(t theme.Theme) string { ) } - // Build the view - var contentParts []string - contentParts = append(contentParts, header, borderedHistory, inputLabelStyled, inputArea) - - if e.awaitingBusinessCtxApproval { - // Add button line below input in approval mode - contentParts = append(contentParts, buttonLine) - } else if statusMsg != "" { - contentParts = append(contentParts, statusMsg) - } else if errorLine != "" { - contentParts = append(contentParts, errorLine) - } else { - contentParts = append(contentParts, help) - } - - content := strings.Join(contentParts, "\n") - - return content + return chatViewContent + "\n" + bottomLine } diff --git a/packages/tui/internal/components/scenario_list.go b/packages/tui/internal/components/scenario_list.go index a2348b70..1d71aa52 100644 --- a/packages/tui/internal/components/scenario_list.go +++ b/packages/tui/internal/components/scenario_list.go @@ -155,26 +155,27 @@ func (e ScenarioEditor) handleListMode(msg tea.KeyMsg) (ScenarioEditor, tea.Cmd) case "i": // Start interview mode - enter the mode immediately and trigger API call e.mode = InterviewMode - e.interviewLoading = true - e.interviewError = "" - e.interviewMessages = []InterviewMessage{} e.infoMsg = "" e.errorMsg = "" // Clear any previous errors - if e.interviewInput != nil { - e.interviewInput.SetValue("") - // Focus input immediately so cursor is visible - e.interviewInput.Focus() + // Initialize ChatView if not already done + if e.interviewChatView == nil { + chatView := NewChatView(9990, e.width, e.height, theme.CurrentTheme()) + e.interviewChatView = chatView } - // Start spinner for loading state - e.interviewSpinner.SetActive(true) + // Set loading state and clear any previous messages + e.interviewChatView.ClearMessages() + e.interviewChatView.SetLoading(true) // Send message to app.go to start the interview API call - return e, tea.Batch( + cmds := []tea.Cmd{ func() tea.Msg { return StartInterviewMsg{} }, - e.interviewSpinner.Start(), - ) + } + if e.interviewChatView != nil { + cmds = append(cmds, e.interviewChatView.StartSpinner()) + } + return e, tea.Batch(cmds...) default: return e, nil diff --git a/packages/tui/internal/tui/app.go b/packages/tui/internal/tui/app.go index 872dafa6..2dde5cd4 100644 --- a/packages/tui/internal/tui/app.go +++ b/packages/tui/internal/tui/app.go @@ -199,12 +199,11 @@ type Model struct { evalSpinner components.Spinner // Viewports for scrollable content - eventsViewport components.Viewport - summaryViewport components.Viewport - reportViewport components.Viewport - helpViewport components.Viewport - focusedViewport int // 0 = events, 1 = summary - eventsAutoScroll bool // Track if events should auto-scroll to bottom + eventsHistory *components.MessageHistoryView + summaryHistory *components.MessageHistoryView + reportHistory *components.MessageHistoryView + helpViewport components.Viewport + focusedViewport int // 0 = events, 1 = summary // /eval state evalState *EvaluationViewState @@ -285,13 +284,12 @@ func (a *App) Run() error { summarySpinner: components.NewSpinner(2), evalSpinner: components.NewSpinner(3), - // Initialize viewports - eventsViewport: components.NewViewport(1, 80, 20), - summaryViewport: components.NewViewport(2, 80, 20), - reportViewport: components.NewViewport(3, 80, 15), - helpViewport: components.NewViewport(4, 80, 20), - focusedViewport: 0, // Start with events viewport focused - eventsAutoScroll: true, // Start with auto-scroll enabled + // Initialize viewports and message history + eventsHistory: components.NewMessageHistoryView(1, 80, 20, theme.CurrentTheme()), + summaryHistory: components.NewMessageHistoryView(2, 80, 20, theme.CurrentTheme()), + reportHistory: components.NewMessageHistoryView(3, 80, 15, theme.CurrentTheme()), + helpViewport: components.NewViewport(4, 80, 20), + focusedViewport: 0, // Start with events viewport focused } // Load existing configuration @@ -398,8 +396,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.currentScreen = EvaluationDetailScreen // Reset viewport focus to events when entering detail screen m.focusedViewport = 0 - // Enable auto-scroll for new evaluation - m.eventsAutoScroll = true + // Blur events history to enable auto-scroll for new evaluation + if m.eventsHistory != nil { + m.eventsHistory.Blur() + } return m, autoRefreshCmd() } return m, nil @@ -410,18 +410,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.Err != nil { if m.evalState != nil { m.evalState.Summary = fmt.Sprintf("# Summary Generation Failed\n\nError: %v", msg.Err) - // Update report viewport if we're on the report screen - if m.currentScreen == ReportScreen { - m.reportViewport.SetContent(m.evalState.Summary) - } } } else { if m.evalState != nil { m.evalState.Summary = msg.Summary - // Update report viewport if we're on the report screen - if m.currentScreen == ReportScreen { - m.reportViewport.SetContent(m.evalState.Summary) - } } } return m, nil @@ -436,9 +428,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Update viewport sizes viewportWidth := msg.Width - 4 viewportHeight := msg.Height - 8 - m.eventsViewport.SetSize(viewportWidth, viewportHeight) - m.summaryViewport.SetSize(viewportWidth, viewportHeight) - m.reportViewport.SetSize(viewportWidth, viewportHeight) + if m.eventsHistory != nil { + m.eventsHistory.SetSize(viewportWidth, viewportHeight) + } + if m.summaryHistory != nil { + m.summaryHistory.SetSize(viewportWidth, viewportHeight) + } + if m.reportHistory != nil { + m.reportHistory.SetSize(viewportWidth, viewportHeight) + } m.helpViewport.SetSize(viewportWidth, viewportHeight) return m, nil @@ -943,6 +941,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.currentScreen == ReportScreen { // go back to the evaluation view screen + if m.reportHistory != nil { + m.reportHistory.Blur() + } m.currentScreen = EvaluationDetailScreen return m, nil } @@ -962,6 +963,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // If no dialog needed, proceed to dashboard } // Default ESC behavior: back to dashboard + // Blur any focused viewports when leaving + if m.currentScreen == ReportScreen && m.reportHistory != nil { + m.reportHistory.Blur() + } m.currentScreen = DashboardScreen m.commandInput.SetFocus(true) // Keep focused when returning to dashboard m.commandInput.SetValue("") @@ -1202,14 +1207,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Navigate to report if evaluation completed if m.evalState.Completed { m.currentScreen = ReportScreen - // Update report viewport content when entering report screen - var reportContent string - if m.evalState.Summary == "" { - reportContent = "Generating summary, please wait..." - } else { - reportContent = m.evalState.Summary + // Report content will be built in renderReport() + // Focus the report so user can immediately scroll + if m.reportHistory != nil { + m.reportHistory.Focus() } - m.reportViewport.SetContent(reportContent) } return m, nil case "tab": @@ -1220,43 +1222,89 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil case "end": - // Go to bottom and re-enable auto-scroll for events viewport - if m.focusedViewport == 0 { - m.eventsViewport.GotoBottom() - m.eventsAutoScroll = true - } else if m.focusedViewport == 1 { - m.summaryViewport.GotoBottom() + // Go to bottom and blur to re-enable auto-scroll + if m.focusedViewport == 0 && m.eventsHistory != nil { + m.eventsHistory.GotoBottom() + m.eventsHistory.Blur() + } else if m.focusedViewport == 1 && m.summaryHistory != nil { + m.summaryHistory.GotoBottom() + m.summaryHistory.Blur() } return m, nil case "home": - // Go to top and disable auto-scroll for events viewport - if m.focusedViewport == 0 { - m.eventsViewport.GotoTop() - m.eventsAutoScroll = false - } else if m.focusedViewport == 1 { - m.summaryViewport.GotoTop() + // Go to top and focus to disable auto-scroll + if m.focusedViewport == 0 && m.eventsHistory != nil { + m.eventsHistory.GotoTop() + m.eventsHistory.Focus() + } else if m.focusedViewport == 1 && m.summaryHistory != nil { + m.summaryHistory.GotoTop() + m.summaryHistory.Focus() } return m, nil - default: - // Update only the focused viewport for scrolling - if m.focusedViewport == 0 { - // Events viewport is focused - disable auto-scroll when user manually scrolls - m.eventsAutoScroll = false - eventsViewportPtr, cmd := m.eventsViewport.Update(msg) + case "up", "down", "pgup", "pgdown": + // Arrow keys: focus the active viewport and scroll + if m.focusedViewport == 0 && m.eventsHistory != nil { + // Special case: if at bottom and user hits down + if msg.String() == "down" && m.eventsHistory.AtBottom() { + // If summary is visible, switch focus to summary panel + if m.evalState != nil && m.evalState.Completed && + (m.evalState.Summary != "" || m.summarySpinner.IsActive()) { + m.eventsHistory.Blur() + m.focusedViewport = 1 // Switch to summary + return m, nil + } + // Otherwise, just blur to re-enable auto-scroll + m.eventsHistory.Blur() + return m, nil + } + + // Focus events history when user starts scrolling + m.eventsHistory.Focus() + switch msg.String() { + case "up": + m.eventsHistory.ScrollUp(1) + case "down": + m.eventsHistory.ScrollDown(1) + case "pgup": + m.eventsHistory.ScrollUp(10) + case "pgdown": + m.eventsHistory.ScrollDown(10) + } + cmd := m.eventsHistory.Update(msg) if cmd != nil { cmds = append(cmds, cmd) } - m.eventsViewport = *eventsViewportPtr - } else if m.focusedViewport == 1 { - // Summary viewport is focused - summaryViewportPtr, cmd := m.summaryViewport.Update(msg) + } else if m.focusedViewport == 1 && m.summaryHistory != nil { + // Special case: if at top of summary and user hits up, switch back to events + if msg.String() == "up" && m.summaryHistory.AtTop() { + m.focusedViewport = 0 // Switch back to events + if m.eventsHistory != nil { + m.eventsHistory.Focus() + } + return m, nil + } + + // Summary history scrolling + m.summaryHistory.Focus() + switch msg.String() { + case "up": + m.summaryHistory.ScrollUp(1) + case "down": + m.summaryHistory.ScrollDown(1) + case "pgup": + m.summaryHistory.ScrollUp(10) + case "pgdown": + m.summaryHistory.ScrollDown(10) + } + cmd := m.summaryHistory.Update(msg) if cmd != nil { cmds = append(cmds, cmd) } - m.summaryViewport = *summaryViewportPtr } - return m, tea.Batch(cmds...) + default: + // No action for other keys + return m, nil } } @@ -1264,6 +1312,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.currentScreen == ReportScreen && m.evalState != nil { switch msg.String() { case "b": + if m.reportHistory != nil { + m.reportHistory.Blur() + } m.currentScreen = DashboardScreen return m, nil case "r": @@ -1277,20 +1328,38 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case "home": // Go to top of report - m.reportViewport.GotoTop() + if m.reportHistory != nil { + m.reportHistory.GotoTop() + } return m, nil case "end": // Go to bottom of report - m.reportViewport.GotoBottom() + if m.reportHistory != nil { + m.reportHistory.GotoBottom() + } return m, nil - default: - // Update the report viewport for scrolling - reportViewportPtr, cmd := m.reportViewport.Update(msg) - if cmd != nil { - cmds = append(cmds, cmd) + case "up", "down", "pgup", "pgdown": + // Scroll the report + if m.reportHistory != nil { + switch msg.String() { + case "up": + m.reportHistory.ScrollUp(1) + case "down": + m.reportHistory.ScrollDown(1) + case "pgup": + m.reportHistory.ScrollUp(10) + case "pgdown": + m.reportHistory.ScrollDown(10) + } + cmd := m.reportHistory.Update(msg) + if cmd != nil { + cmds = append(cmds, cmd) + } } - m.reportViewport = *reportViewportPtr return m, tea.Batch(cmds...) + default: + // No action for other keys + return m, nil } } diff --git a/packages/tui/internal/tui/eval_detail.go b/packages/tui/internal/tui/eval_detail.go new file mode 100644 index 00000000..f2b0857e --- /dev/null +++ b/packages/tui/internal/tui/eval_detail.go @@ -0,0 +1,255 @@ +package tui + +import ( + "fmt" + + "github.com/charmbracelet/lipgloss/v2" + "github.com/rogue/tui/internal/theme" +) + +// renderEvaluationDetail renders the evaluation detail/running screen +func (m Model) renderEvaluationDetail() string { + t := theme.CurrentTheme() + if m.evalState == nil { + return lipgloss.NewStyle(). + Width(m.width). + Height(m.height). + Background(t.Background()). + Foreground(t.Text()). + Align(lipgloss.Center, lipgloss.Center). + Render("No evaluation active") + } + + // Main container style with full width and height background + mainStyle := lipgloss.NewStyle(). + Width(m.width). + Height(m.height - 1). // -1 for footer + Background(t.Background()) + + // Title style + titleStyle := lipgloss.NewStyle(). + Foreground(t.Primary()). + Background(t.Background()). + Bold(true). + Width(m.width). + Align(lipgloss.Center). + Padding(1, 0) + + header := titleStyle.Render("๐Ÿ“ก Evaluation Running") + + // Status style + statusStyle := lipgloss.NewStyle(). + Foreground(t.Text()). + Background(t.BackgroundPanel()). + Border(lipgloss.RoundedBorder()). + BorderForeground(t.Primary()). + Padding(0, 2). + Width(m.width - 4). + Align(lipgloss.Center) + + var statusText string + if m.evalState.Status != "completed" && m.evalSpinner.IsActive() { + m.evalSpinner.Style = lipgloss.NewStyle().Foreground(t.Success()) + statusText = fmt.Sprintf("Status: %s %s", m.evalState.Status, m.evalSpinner.View()) + } else { + statusText = fmt.Sprintf("Status: %s", m.evalState.Status) + statusStyle = lipgloss.NewStyle(). + Foreground(t.Success()). + Background(t.BackgroundPanel()). + Border(lipgloss.RoundedBorder()). + BorderForeground(t.Primary()). + Padding(0, 2). + Width(m.width - 4). + Align(lipgloss.Center) + } + status := statusStyle.Render(statusText) + + // Calculate available height for content area + // Total layout: header(3) + mainContent + helpText(2) = m.height + // mainContent contains: status(3) + spacers(2-3) + events + [spacer + summary] + // Start with total height minus header and help + availableHeight := m.height - 5 // header(3) + helpText(2) + + // Subtract status bar and spacers + statusAndSpacersHeight := 5 // status(3) + spacers(2) + + var eventsHeight, summaryHeight int + var showSummary bool + + // If evaluation completed and we have a summary OR are generating one, split 50/50 + if m.evalState.Completed && (m.evalState.Summary != "" || m.summarySpinner.IsActive()) { + showSummary = true + // Add one more spacer between events and summary + statusAndSpacersHeight = 6 // status(3) + spacers(3) + + // Remaining height split between events and summary + remainingHeight := availableHeight - statusAndSpacersHeight + eventsHeight = remainingHeight / 2 + summaryHeight = remainingHeight - eventsHeight + } else { + // No summary, events take full remaining height + remainingHeight := availableHeight - statusAndSpacersHeight + eventsHeight = remainingHeight + summaryHeight = 0 + } + + // Clear existing messages and rebuild from events + m.eventsHistory.ClearMessages() + + // Process events and add as messages + for _, ev := range m.evalState.Events { + switch ev.Type { + case "status": + if ev.Status != "running" { + m.eventsHistory.AddMessage("system", fmt.Sprintf("โœ“ %s", ev.Status)) + } + case "chat": + // Normalize role for MessageHistoryView + normalizedRole := normalizeEvaluationRole(ev.Role) + m.eventsHistory.AddMessage(normalizedRole, ev.Content) + case "error": + m.eventsHistory.AddMessage("system", fmt.Sprintf("โš  ERROR: %s", ev.Message)) + } + } + + // Check if we have any messages + if len(m.eventsHistory.GetMessages()) == 0 { + m.eventsHistory.AddMessage("system", "Waiting for evaluation events...") + } + + // Update size + m.eventsHistory.SetSize(m.width, eventsHeight) + + // Customize prefixes to match the evaluation context + m.eventsHistory.SetPrefixes("๐Ÿ” Rogue: ", "๐Ÿค– Agent: ") + + m.eventsHistory.SetColors(t.Primary(), t.Text()) + + // Render events using MessageHistoryView + var eventsContent string + if m.eventsHistory != nil { + eventsContent = m.eventsHistory.View(t) + } + + // Help text style (for bottom of screen) + helpStyle := lipgloss.NewStyle(). + Foreground(t.TextMuted()). + Background(t.Background()). + Width(m.width). + Align(lipgloss.Center). + Padding(0, 1) + + var helpMsg string + if m.evalState.Completed && (m.evalState.Summary != "" || m.summarySpinner.IsActive()) { + // Both viewports are visible, show tab navigation + helpMsg = "b Back s Stop r Report Tab Switch Focus โ†‘โ†“ scroll end auto-scroll" + } else if m.evalState.Completed { + helpMsg = "b Back s Stop r Report โ†‘โ†“ scroll end auto-scroll" + } else { + helpMsg = "b Back s Stop โ†‘โ†“ scroll end auto-scroll" + } + helpText := helpStyle.Render(helpMsg) + + // Calculate main content area height (space between header and footer) + // This should match availableHeight calculated above + mainContentHeight := availableHeight + + // Create content area for the main content (between header and footer) + contentArea := lipgloss.NewStyle(). + Width(m.width). + Height(mainContentHeight). + Background(t.Background()) + + // Create spacing with background color + spacer := lipgloss.NewStyle().Background(t.Background()).Width(m.width).Render("") + + // Create summary section if available + var summaryContent string + if showSummary { + var summaryTitleText string + if m.summarySpinner.IsActive() { + summaryTitleText = fmt.Sprintf("๐Ÿ“Š Evaluation Summary %s", m.summarySpinner.View()) + } else { + summaryTitleText = "๐Ÿ“Š Evaluation Summary" + } + + // Clear existing summary messages + m.summaryHistory.ClearMessages() + + // Add title as a system message + m.summaryHistory.AddMessage("system", summaryTitleText) + + if m.evalState.Summary == "" && m.summarySpinner.IsActive() { + // Show loading message when generating summary + m.summaryHistory.AddMessage("system", "Generating summary with LLM...") + } else if m.evalState.Summary != "" { + // Add summary content as a single message + m.summaryHistory.AddMessage("assistant", m.evalState.Summary) + } + + // Update size for summary history + m.summaryHistory.SetSize(m.width, summaryHeight) + + // Customize prefixes for summary view + m.summaryHistory.SetPrefixes("", "") + + // Set colors for summary + m.summaryHistory.SetColors(t.Success(), t.Text()) + + // Set focus state for border color + if m.focusedViewport == 1 { + m.summaryHistory.Focus() + } else { + m.summaryHistory.Blur() + } + + // Render summary using MessageHistoryView + summaryContent = m.summaryHistory.View(t) + } + + // Arrange content based on whether we have a summary or not + var mainContent string + + if showSummary { + // Split layout: events on top, summary on bottom + upperSection := lipgloss.JoinVertical(lipgloss.Center, spacer, status, spacer, eventsContent) + lowerSection := summaryContent + + // Create split layout + content := lipgloss.JoinVertical(lipgloss.Center, upperSection, spacer, lowerSection) + + mainContent = contentArea.Render( + lipgloss.Place( + m.width, + mainContentHeight, + lipgloss.Center, + lipgloss.Top, + content, + lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())), + ), + ) + } else { + // Single layout: events take full space + content := lipgloss.JoinVertical(lipgloss.Center, spacer, status, spacer, eventsContent) + + mainContent = contentArea.Render( + lipgloss.Place( + m.width, + mainContentHeight, + lipgloss.Center, + lipgloss.Top, + content, + lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())), + ), + ) + } + + // Combine all sections + fullLayout := lipgloss.JoinVertical(lipgloss.Left, + header, + mainContent, + helpText, + ) + + return mainStyle.Render(fullLayout) +} diff --git a/packages/tui/internal/tui/eval_form.go b/packages/tui/internal/tui/eval_form.go new file mode 100644 index 00000000..d52bdb11 --- /dev/null +++ b/packages/tui/internal/tui/eval_form.go @@ -0,0 +1,225 @@ +package tui + +import ( + "fmt" + + "github.com/charmbracelet/lipgloss/v2" + "github.com/rogue/tui/internal/theme" +) + +// renderNewEvaluation renders the new evaluation form screen +func (m Model) renderNewEvaluation() string { + t := theme.CurrentTheme() + if m.evalState == nil { + return lipgloss.NewStyle(). + Width(m.width). + Height(m.height). + Background(t.Background()). + Foreground(t.Text()). + Align(lipgloss.Center, lipgloss.Center). + Render("New evaluation not initialized") + } + + // Main container style with full width and height background + mainStyle := lipgloss.NewStyle(). + Width(m.width). + Height(m.height - 1). // -1 for footer + Background(t.Background()) + + // Title style + titleStyle := lipgloss.NewStyle(). + Foreground(t.Primary()). + Background(t.Background()). + Bold(true). + Width(m.width). + Align(lipgloss.Center). + Padding(1, 0) + + title := titleStyle.Render("๐Ÿงช New Evaluation") + + // Helper function to render a field with inline label and value + renderField := func(fieldIndex int, label, value string) string { + active := m.evalState.currentField == fieldIndex + + labelStyle := lipgloss.NewStyle(). + Foreground(t.TextMuted()). + Background(t.Background()). + Width(20). + Align(lipgloss.Right) + + valueStyle := lipgloss.NewStyle(). + Foreground(t.Text()). + Background(t.Background()). + Padding(0, 1) + + if active { + labelStyle = labelStyle.Foreground(t.Primary()).Bold(true) + valueStyle = valueStyle. + Foreground(t.Primary()). + Background(t.Background()) + // Add cursor at the correct position for active field + runes := []rune(value) + cursorPos := m.evalState.cursorPos + if cursorPos > len(runes) { + cursorPos = len(runes) + } + value = string(runes[:cursorPos]) + "โ–ˆ" + string(runes[cursorPos:]) + } else { + valueStyle = valueStyle. + Background(t.Background()) + } + + // Create a full-width container for the field + fieldContainer := lipgloss.NewStyle(). + Width(m.width-4). + Background(t.Background()). + Padding(0, 2) + + fieldContent := lipgloss.JoinHorizontal(lipgloss.Left, + labelStyle.Render(label), + valueStyle.Render(value), + ) + + return fieldContainer.Render(fieldContent) + } + + // Prepare field values + agent := m.evalState.AgentURL + judge := m.evalState.JudgeModel + deep := "โŒ" + if m.evalState.DeepTest { + deep = "โœ…" + } + + // Helper function to render the start button + renderStartButton := func() string { + active := m.evalState.currentField == 3 + var buttonText string + + if m.evalSpinner.IsActive() { + buttonText = " Starting Evaluation..." + } else { + buttonText = "[ Start Evaluation ]" + } + + buttonStyle := lipgloss.NewStyle(). + Foreground(t.Background()). + Background(t.Primary()). + Padding(0, 2). + Border(lipgloss.RoundedBorder()). + BorderForeground(t.Primary()) + + if active && !m.evalSpinner.IsActive() { + // Highlight when focused (but not when spinning) + buttonStyle = buttonStyle. + Background(t.Accent()). + BorderForeground(t.Accent()). + Bold(true) + buttonText = "โ–ถ [ Start Evaluation ] โ—€" + } else if m.evalSpinner.IsActive() { + // Different style when loading + buttonStyle = buttonStyle. + Background(t.TextMuted()). + BorderForeground(t.TextMuted()) + } + + // Center the button in a full-width container + buttonContainer := lipgloss.NewStyle(). + Width(m.width-4). + Background(t.Background()). + Align(lipgloss.Center). + Padding(1, 0) + + return buttonContainer.Render(buttonStyle.Render(buttonText)) + } + + // Info section style + infoStyle := lipgloss.NewStyle(). + Foreground(t.TextMuted()). + Background(t.Background()). + Width(m.width-4). + Align(lipgloss.Center). + Padding(0, 2) + + // Help text style (for bottom of screen) + helpStyle := lipgloss.NewStyle(). + Foreground(t.TextMuted()). + Background(t.Background()). + Width(m.width). + Align(lipgloss.Center). + Padding(0, 1) + + // Build the content sections + formSection := lipgloss.JoinVertical(lipgloss.Left, + renderField(0, "Agent URL:", agent), + renderField(1, "Judge LLM:", judge), + renderField(2, "Deep Test:", deep), + ) + + var infoLines []string + if m.healthSpinner.IsActive() { + infoLines = append(infoLines, infoStyle.Render(fmt.Sprintf("Server: %s %s", m.evalState.ServerURL, m.healthSpinner.View()))) + } else { + infoLines = append(infoLines, infoStyle.Render(fmt.Sprintf("Server: %s", m.evalState.ServerURL))) + } + infoLines = append(infoLines, infoStyle.Render(fmt.Sprintf("Scenarios: %d", len(m.evalState.Scenarios)))) + + infoSection := lipgloss.JoinVertical(lipgloss.Center, infoLines...) + + buttonSection := renderStartButton() + + helpText := helpStyle.Render("t Test Server โ†‘/โ†“ switch fields โ†/โ†’ move cursor Space toggle Enter activate Esc Back") + + // Calculate content area height (excluding title and help) + contentHeight := m.height - 6 // title(3) + help(1) + margins(2) + + // Create content area with proper spacing + contentArea := lipgloss.NewStyle(). + Width(m.width). + Height(contentHeight). + Background(t.Background()) + + // Create spacing with background color + spacer := lipgloss.NewStyle().Background(t.Background()).Width(m.width).Render("") + + // Arrange content with spacing + content := lipgloss.JoinVertical(lipgloss.Center, + spacer, // spacing + formSection, + spacer, // spacing + infoSection, + spacer, // spacing + buttonSection, + ) + + // Place content in the content area + mainContent := contentArea.Render( + lipgloss.Place( + m.width, + contentHeight, + lipgloss.Center, + lipgloss.Center, + content, + lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())), + ), + ) + + // Combine all sections + fullLayout := lipgloss.JoinVertical(lipgloss.Left, + title, + mainContent, + helpText, + ) + + return mainStyle.Render(fullLayout) +} + +// handleNewEvalEnter handles the Enter key press on the new evaluation form +func (m *Model) handleNewEvalEnter() { + if m.evalState == nil || m.evalState.Running { + return + } + + // Show spinner - the actual evaluation will start after a delay + m.evalSpinner.SetActive(true) +} diff --git a/packages/tui/internal/tui/eval_helpers.go b/packages/tui/internal/tui/eval_helpers.go new file mode 100644 index 00000000..08f7180a --- /dev/null +++ b/packages/tui/internal/tui/eval_helpers.go @@ -0,0 +1,106 @@ +package tui + +import ( + "strings" + + "github.com/charmbracelet/lipgloss/v2" + "github.com/rogue/tui/internal/theme" +) + +// normalizeEvaluationRole maps evaluation-specific roles to MessageHistoryView roles +// MessageHistoryView expects: "user", "assistant", or "system" +// In evaluation context: +// - "user" represents the evaluator/judge (Rogue) +// - "assistant" represents the agent being tested +// - "system" represents system messages +func normalizeEvaluationRole(role string) string { + // Normalize role string (trim whitespace and lowercase for matching) + roleLower := strings.ToLower(strings.TrimSpace(role)) + + // Check if role contains agent-related keywords + if strings.Contains(roleLower, "agent") { + // Agent under test shown as "assistant" (will use assistantPrefix: "๐Ÿค– Agent: ") + return "assistant" + } + + // Check if role contains evaluator/rogue/judge keywords + if strings.Contains(roleLower, "rogue") || + strings.Contains(roleLower, "judge") || + strings.Contains(roleLower, "evaluator") { + // Evaluator/Judge messages shown as "user" (will use userPrefix: "๐Ÿ” Rogue: ") + return "user" + } + + // Check for system messages + if strings.Contains(roleLower, "system") { + return "system" + } + + // Default: if role is empty or unknown, treat as evaluator + // This ensures messages are visible even if role is malformed + return "user" +} + +// renderMarkdownSummary renders the markdown summary with basic styling +func renderMarkdownSummary(t theme.Theme, summary string) string { + lines := strings.Split(summary, "\n") + var styledLines []string + + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + styledLines = append(styledLines, "") + continue + } + + // Basic markdown styling + if strings.HasPrefix(line, "# ") { + // H1 - Main title + title := strings.TrimPrefix(line, "# ") + styledLines = append(styledLines, lipgloss.NewStyle(). + Foreground(t.Primary()). + Bold(true). + Render("๐Ÿ”ท "+title)) + } else if strings.HasPrefix(line, "## ") { + // H2 - Section headers + title := strings.TrimPrefix(line, "## ") + styledLines = append(styledLines, lipgloss.NewStyle(). + Foreground(t.Accent()). + Bold(true). + Render("โ–ช "+title)) + } else if strings.HasPrefix(line, "### ") { + // H3 - Subsection headers + title := strings.TrimPrefix(line, "### ") + styledLines = append(styledLines, lipgloss.NewStyle(). + Foreground(t.Text()). + Bold(true). + Render(" โ€ข "+title)) + } else if strings.HasPrefix(line, "- ") || strings.HasPrefix(line, "* ") { + // Bullet points + content := strings.TrimPrefix(strings.TrimPrefix(line, "- "), "* ") + styledLines = append(styledLines, lipgloss.NewStyle(). + Foreground(t.Text()). + Render(" โ€ข "+content)) + } else if strings.HasPrefix(line, "**") && strings.HasSuffix(line, "**") { + // Bold text + content := strings.TrimSuffix(strings.TrimPrefix(line, "**"), "**") + styledLines = append(styledLines, lipgloss.NewStyle(). + Foreground(t.Text()). + Bold(true). + Render(content)) + } else if strings.Contains(line, "`") { + // Inline code (basic support) + styled := strings.ReplaceAll(line, "`", "") + styledLines = append(styledLines, lipgloss.NewStyle(). + Foreground(t.Success()). + Render(styled)) + } else { + // Regular text + styledLines = append(styledLines, lipgloss.NewStyle(). + Foreground(t.Text()). + Render(line)) + } + } + + return strings.Join(styledLines, "\n") +} diff --git a/packages/tui/internal/tui/eval_view.go b/packages/tui/internal/tui/eval_view.go deleted file mode 100644 index 8609bad0..00000000 --- a/packages/tui/internal/tui/eval_view.go +++ /dev/null @@ -1,617 +0,0 @@ -package tui - -import ( - "fmt" - "strings" - - "github.com/charmbracelet/lipgloss/v2" - "github.com/rogue/tui/internal/theme" -) - -// Render New Evaluation form and Evaluation Detail - -func (m Model) renderNewEvaluation() string { - t := theme.CurrentTheme() - if m.evalState == nil { - return lipgloss.NewStyle(). - Width(m.width). - Height(m.height). - Background(t.Background()). - Foreground(t.Text()). - Align(lipgloss.Center, lipgloss.Center). - Render("New evaluation not initialized") - } - - // Main container style with full width and height background - mainStyle := lipgloss.NewStyle(). - Width(m.width). - Height(m.height - 1). // -1 for footer - Background(t.Background()) - - // Title style - titleStyle := lipgloss.NewStyle(). - Foreground(t.Primary()). - Background(t.Background()). - Bold(true). - Width(m.width). - Align(lipgloss.Center). - Padding(1, 0) - - title := titleStyle.Render("๐Ÿงช New Evaluation") - - // Helper function to render a field with inline label and value - renderField := func(fieldIndex int, label, value string) string { - active := m.evalState.currentField == fieldIndex - - labelStyle := lipgloss.NewStyle(). - Foreground(t.TextMuted()). - Background(t.Background()). - Width(20). - Align(lipgloss.Right) - - valueStyle := lipgloss.NewStyle(). - Foreground(t.Text()). - Background(t.Background()). - Padding(0, 1) - - if active { - labelStyle = labelStyle.Foreground(t.Primary()).Bold(true) - valueStyle = valueStyle. - Foreground(t.Primary()). - Background(t.Background()) - // Add cursor at the correct position for active field - runes := []rune(value) - cursorPos := m.evalState.cursorPos - if cursorPos > len(runes) { - cursorPos = len(runes) - } - value = string(runes[:cursorPos]) + "โ–ˆ" + string(runes[cursorPos:]) - } else { - valueStyle = valueStyle. - Background(t.Background()) - } - - // Create a full-width container for the field - fieldContainer := lipgloss.NewStyle(). - Width(m.width-4). - Background(t.Background()). - Padding(0, 2) - - fieldContent := lipgloss.JoinHorizontal(lipgloss.Left, - labelStyle.Render(label), - valueStyle.Render(value), - ) - - return fieldContainer.Render(fieldContent) - } - - // Prepare field values - agent := m.evalState.AgentURL - judge := m.evalState.JudgeModel - deep := "โŒ" - if m.evalState.DeepTest { - deep = "โœ…" - } - - // Helper function to render the start button - renderStartButton := func() string { - active := m.evalState.currentField == 3 - var buttonText string - - if m.evalSpinner.IsActive() { - buttonText = " Starting Evaluation..." - } else { - buttonText = "[ Start Evaluation ]" - } - - buttonStyle := lipgloss.NewStyle(). - Foreground(t.Background()). - Background(t.Primary()). - Padding(0, 2). - Border(lipgloss.RoundedBorder()). - BorderForeground(t.Primary()) - - if active && !m.evalSpinner.IsActive() { - // Highlight when focused (but not when spinning) - buttonStyle = buttonStyle. - Background(t.Accent()). - BorderForeground(t.Accent()). - Bold(true) - buttonText = "โ–ถ [ Start Evaluation ] โ—€" - } else if m.evalSpinner.IsActive() { - // Different style when loading - buttonStyle = buttonStyle. - Background(t.TextMuted()). - BorderForeground(t.TextMuted()) - } - - // Center the button in a full-width container - buttonContainer := lipgloss.NewStyle(). - Width(m.width-4). - Background(t.Background()). - Align(lipgloss.Center). - Padding(1, 0) - - return buttonContainer.Render(buttonStyle.Render(buttonText)) - } - - // Info section style - infoStyle := lipgloss.NewStyle(). - Foreground(t.TextMuted()). - Background(t.Background()). - Width(m.width-4). - Align(lipgloss.Center). - Padding(0, 2) - - // Help text style (for bottom of screen) - helpStyle := lipgloss.NewStyle(). - Foreground(t.TextMuted()). - Background(t.Background()). - Width(m.width). - Align(lipgloss.Center). - Padding(0, 1) - - // Build the content sections - formSection := lipgloss.JoinVertical(lipgloss.Left, - renderField(0, "Agent URL:", agent), - renderField(1, "Judge LLM:", judge), - renderField(2, "Deep Test:", deep), - ) - - var infoLines []string - if m.healthSpinner.IsActive() { - infoLines = append(infoLines, infoStyle.Render(fmt.Sprintf("Server: %s %s", m.evalState.ServerURL, m.healthSpinner.View()))) - } else { - infoLines = append(infoLines, infoStyle.Render(fmt.Sprintf("Server: %s", m.evalState.ServerURL))) - } - infoLines = append(infoLines, infoStyle.Render(fmt.Sprintf("Scenarios: %d", len(m.evalState.Scenarios)))) - - infoSection := lipgloss.JoinVertical(lipgloss.Center, infoLines...) - - buttonSection := renderStartButton() - - helpText := helpStyle.Render("t Test Server โ†‘/โ†“ switch fields โ†/โ†’ move cursor Space toggle Enter activate Esc Back") - - // Calculate content area height (excluding title and help) - contentHeight := m.height - 6 // title(3) + help(1) + margins(2) - - // Create content area with proper spacing - contentArea := lipgloss.NewStyle(). - Width(m.width). - Height(contentHeight). - Background(t.Background()) - - // Create spacing with background color - spacer := lipgloss.NewStyle().Background(t.Background()).Width(m.width).Render("") - - // Arrange content with spacing - content := lipgloss.JoinVertical(lipgloss.Center, - spacer, // spacing - formSection, - spacer, // spacing - infoSection, - spacer, // spacing - buttonSection, - ) - - // Place content in the content area - mainContent := contentArea.Render( - lipgloss.Place( - m.width, - contentHeight, - lipgloss.Center, - lipgloss.Center, - content, - lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())), - ), - ) - - // Combine all sections - fullLayout := lipgloss.JoinVertical(lipgloss.Left, - title, - mainContent, - helpText, - ) - - return mainStyle.Render(fullLayout) -} - -// Start eval on Enter key (hook from Update) -func (m *Model) handleNewEvalEnter() { - if m.evalState == nil || m.evalState.Running { - return - } - - // Show spinner - the actual evaluation will start after a delay - m.evalSpinner.SetActive(true) -} - -func (m Model) renderEvaluationDetail() string { - t := theme.CurrentTheme() - if m.evalState == nil { - return lipgloss.NewStyle(). - Width(m.width). - Height(m.height). - Background(t.Background()). - Foreground(t.Text()). - Align(lipgloss.Center, lipgloss.Center). - Render("No evaluation active") - } - - // Main container style with full width and height background - mainStyle := lipgloss.NewStyle(). - Width(m.width). - Height(m.height - 1). // -1 for footer - Background(t.Background()) - - // Title style - titleStyle := lipgloss.NewStyle(). - Foreground(t.Primary()). - Background(t.Background()). - Bold(true). - Width(m.width). - Align(lipgloss.Center). - Padding(1, 0) - - header := titleStyle.Render("๐Ÿ“ก Evaluation Running") - - // Status style - statusStyle := lipgloss.NewStyle(). - Foreground(t.Text()). - Background(t.BackgroundPanel()). - Border(lipgloss.RoundedBorder()). - BorderForeground(t.Primary()). - Padding(0, 2). - Width(m.width - 4). - Align(lipgloss.Center) - - var statusText string - if m.evalState.Status != "completed" && m.evalSpinner.IsActive() { - m.evalSpinner.Style = lipgloss.NewStyle().Foreground(t.Success()) - statusText = fmt.Sprintf("Status: %s %s", m.evalState.Status, m.evalSpinner.View()) - } else { - statusText = fmt.Sprintf("Status: %s", m.evalState.Status) - statusStyle = lipgloss.NewStyle(). - Foreground(t.Success()). - Background(t.BackgroundPanel()). - Border(lipgloss.RoundedBorder()). - BorderForeground(t.Primary()). - Padding(0, 2). - Width(m.width - 4). - Align(lipgloss.Center) - } - status := statusStyle.Render(statusText) - - // Calculate available height for content area (excluding header, status, help) - // header(3) + help(1) + margins(2) = 6, plus extra margin for status and spacing - totalContentHeight := m.height - 8 // More conservative calculation to prevent footer override - - var eventsHeight, summaryHeight int - var showSummary bool - - // If evaluation completed and we have a summary OR are generating one, split 50/50 - if m.evalState.Completed && (m.evalState.Summary != "" || m.summarySpinner.IsActive()) { - showSummary = true - eventsHeight = (totalContentHeight / 2) - 2 // Reduced margin to prevent overflow - summaryHeight = totalContentHeight - eventsHeight - 1 // -1 for spacer between them - } else { - // No summary, events take full height with conservative margin - eventsHeight = totalContentHeight - 2 // Leave extra space to prevent footer override - summaryHeight = 0 - } - - // Prepare events content for viewport - var lines []string - for _, ev := range m.evalState.Events { - switch ev.Type { - case "status": - if ev.Status != "running" { - lines = append(lines, lipgloss.NewStyle().Foreground(t.Success()).Render(fmt.Sprintf("โœ“ %s", ev.Status))) - } - case "chat": - // Split multi-line chat messages - chatLines := strings.Split(renderChatMessage(t, ev.Role, ev.Content), "\n") - lines = append(lines, chatLines...) - case "error": - lines = append(lines, lipgloss.NewStyle().Foreground(t.Error()).Render(fmt.Sprintf("โš  ERROR: %s", ev.Message))) - } - } - - if len(lines) == 0 { - lines = append(lines, lipgloss.NewStyle().Foreground(t.TextMuted()).Italic(true).Render("Waiting for evaluation events...")) - } - - // Update events viewport - // Set viewport to match the style height (which includes border and padding) - // Use more conservative sizing to prevent content overflow - m.eventsViewport.SetSize(m.width-4, eventsHeight-8) // -6 for padding and extra margin - m.eventsViewport.SetContent(strings.Join(lines, "\n")) - - // Set border color based on focus - eventsBorderFg := t.Border() - if m.focusedViewport == 0 { - eventsBorderFg = t.Primary() - } - - m.eventsViewport.Style = lipgloss.NewStyle(). - Foreground(t.Text()). - Background(t.BackgroundPanel()). - Border(lipgloss.RoundedBorder()). - BorderForeground(eventsBorderFg). - Padding(1, 2). - Width(m.width - 4). - Height(eventsHeight) - - // Auto-scroll to bottom for new events (like a chat) only if auto-scroll is enabled - if m.eventsAutoScroll { - m.eventsViewport.GotoBottom() - } - - eventsContent := m.eventsViewport.View() - - // Help text style (for bottom of screen) - helpStyle := lipgloss.NewStyle(). - Foreground(t.TextMuted()). - Background(t.Background()). - Width(m.width). - Align(lipgloss.Center). - Padding(0, 1) - - var helpMsg string - if m.evalState.Completed && (m.evalState.Summary != "" || m.summarySpinner.IsActive()) { - // Both viewports are visible, show tab navigation - helpMsg = "b Back s Stop r Report Tab Switch Focus โ†‘โ†“ scroll end auto-scroll" - } else if m.evalState.Completed { - helpMsg = "b Back s Stop r Report โ†‘โ†“ scroll end auto-scroll" - } else { - helpMsg = "b Back s Stop โ†‘โ†“ scroll end auto-scroll" - } - helpText := helpStyle.Render(helpMsg) - - // Calculate main content area height (space between header and footer) - // header(3) + helpText(1) + margins(2) = 6 - mainContentHeight := m.height - 10 - - // Create content area for the main content (between header and footer) - contentArea := lipgloss.NewStyle(). - Width(m.width). - Height(mainContentHeight). - Background(t.Background()) - - // Create spacing with background color - spacer := lipgloss.NewStyle().Background(t.Background()).Width(m.width).Render("") - - // Create summary section if available - var summaryContent string - if showSummary { - var summaryTitleText string - if m.summarySpinner.IsActive() { - summaryTitleText = fmt.Sprintf("๐Ÿ“Š Evaluation Summary %s", m.summarySpinner.View()) - } else { - summaryTitleText = "๐Ÿ“Š Evaluation Summary" - } - - summaryTitle := lipgloss.NewStyle(). - Foreground(t.Primary()). - Bold(true). - Render(summaryTitleText) - - var summaryText string - if m.evalState.Summary == "" && m.summarySpinner.IsActive() { - // Show loading message when generating summary - summaryText = lipgloss.NewStyle(). - Foreground(t.TextMuted()). - Italic(true). - Render("Generating summary with LLM...") - } else { - // Render the markdown summary with styling - summaryText = renderMarkdownSummary(t, m.evalState.Summary) - } - - // Prepare summary content for viewport - summaryBody := summaryTitle + "\n" + summaryText - - // Update summary viewport - // Use conservative sizing to match events viewport - m.summaryViewport.SetSize(m.width-4, summaryHeight-10) - m.summaryViewport.SetContent(summaryBody) - - // Set border color based on focus - summaryBorderFg := t.Primary() // Default border for summary - if m.focusedViewport == 1 { - summaryBorderFg = t.Primary() // Keep primary when focused - } else { - summaryBorderFg = t.Border() // Use normal border when not focused - } - - m.summaryViewport.Style = lipgloss.NewStyle(). - Foreground(t.Text()). - Background(t.BackgroundPanel()). - Border(lipgloss.RoundedBorder()). - BorderForeground(summaryBorderFg). - Padding(1, 2). - Width(m.width - 4). - Height(summaryHeight - 6) - - summaryContent = m.summaryViewport.View() - } - - // Arrange content based on whether we have a summary or not - var mainContent string - - if showSummary { - // Split layout: events on top, summary on bottom - upperSection := lipgloss.JoinVertical(lipgloss.Center, spacer, status, spacer, eventsContent) - lowerSection := summaryContent - - // Create split layout - content := lipgloss.JoinVertical(lipgloss.Center, upperSection, spacer, lowerSection) - - mainContent = contentArea.Render( - lipgloss.Place( - m.width, - mainContentHeight, - lipgloss.Center, - lipgloss.Top, - content, - lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())), - ), - ) - } else { - // Single layout: events take full space - content := lipgloss.JoinVertical(lipgloss.Center, spacer, status, spacer, eventsContent) - - mainContent = contentArea.Render( - lipgloss.Place( - m.width, - mainContentHeight, - lipgloss.Center, - lipgloss.Top, - content, - lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())), - ), - ) - } - - // Combine all sections - fullLayout := lipgloss.JoinVertical(lipgloss.Left, - header, - mainContent, - helpText, - ) - - return mainStyle.Render(fullLayout) -} - -// renderChatMessage renders a chat message with role-specific styling -func renderChatMessage(t theme.Theme, role, content string) string { - var emoji string - var roleStyle lipgloss.Style - var messageStyle lipgloss.Style - - // Determine styling based on role - switch role { - case "Rogue", "judge", "evaluator", "Evaluator Agent": - // Rogue/Judge - the evaluator (our system) - emoji = "๐Ÿ”" // magnifying glass for evaluation - roleStyle = lipgloss.NewStyle(). - Foreground(t.Primary()). - Bold(true) - messageStyle = lipgloss.NewStyle(). - Foreground(t.Text()). - Background(t.BackgroundPanel()). - Padding(0, 1). - MarginLeft(2) - - case "agent", "Agent", "user": - // Agent under test - emoji = "๐Ÿค–" // robot for the agent being tested - roleStyle = lipgloss.NewStyle(). - Foreground(t.Accent()). - Bold(true) - messageStyle = lipgloss.NewStyle(). - Foreground(t.Text()). - Background(t.BackgroundPanel()). - Padding(0, 1). - MarginLeft(2) - - case "system", "System": - // System messages - emoji = "โš™๏ธ" // gear for system - roleStyle = lipgloss.NewStyle(). - Foreground(t.TextMuted()). - Italic(true) - messageStyle = lipgloss.NewStyle(). - Foreground(t.TextMuted()). - Italic(true). - MarginLeft(2) - - default: - // Unknown role - fallback - emoji = "๐Ÿ’ฌ" // generic chat - roleStyle = lipgloss.NewStyle(). - Foreground(t.Text()) - messageStyle = lipgloss.NewStyle(). - Foreground(t.Text()). - MarginLeft(2) - } - - // Format: [emoji] Role: - // Content (indented) - roleHeader := fmt.Sprintf("%s %s:", emoji, role) - roleText := roleStyle.Render(roleHeader) - - // Wrap content for better readability - contentText := messageStyle.Render(content) - - // Add a subtle separator for visual distinction - separator := lipgloss.NewStyle(). - Foreground(t.Border()). - Render("โ”€โ”€โ”€โ”€โ”€") - - return fmt.Sprintf("%s\n%s\n%s", roleText, contentText, separator) -} - -// renderMarkdownSummary renders the markdown summary with basic styling -func renderMarkdownSummary(t theme.Theme, summary string) string { - lines := strings.Split(summary, "\n") - var styledLines []string - - for _, line := range lines { - line = strings.TrimSpace(line) - if line == "" { - styledLines = append(styledLines, "") - continue - } - - // Basic markdown styling - if strings.HasPrefix(line, "# ") { - // H1 - Main title - title := strings.TrimPrefix(line, "# ") - styledLines = append(styledLines, lipgloss.NewStyle(). - Foreground(t.Primary()). - Bold(true). - Render("๐Ÿ”ท "+title)) - } else if strings.HasPrefix(line, "## ") { - // H2 - Section headers - title := strings.TrimPrefix(line, "## ") - styledLines = append(styledLines, lipgloss.NewStyle(). - Foreground(t.Accent()). - Bold(true). - Render("โ–ช "+title)) - } else if strings.HasPrefix(line, "### ") { - // H3 - Subsection headers - title := strings.TrimPrefix(line, "### ") - styledLines = append(styledLines, lipgloss.NewStyle(). - Foreground(t.Text()). - Bold(true). - Render(" โ€ข "+title)) - } else if strings.HasPrefix(line, "- ") || strings.HasPrefix(line, "* ") { - // Bullet points - content := strings.TrimPrefix(strings.TrimPrefix(line, "- "), "* ") - styledLines = append(styledLines, lipgloss.NewStyle(). - Foreground(t.Text()). - Render(" โ€ข "+content)) - } else if strings.HasPrefix(line, "**") && strings.HasSuffix(line, "**") { - // Bold text - content := strings.TrimSuffix(strings.TrimPrefix(line, "**"), "**") - styledLines = append(styledLines, lipgloss.NewStyle(). - Foreground(t.Text()). - Bold(true). - Render(content)) - } else if strings.Contains(line, "`") { - // Inline code (basic support) - styled := strings.ReplaceAll(line, "`", "") - styledLines = append(styledLines, lipgloss.NewStyle(). - Foreground(t.Success()). - Render(styled)) - } else { - // Regular text - styledLines = append(styledLines, lipgloss.NewStyle(). - Foreground(t.Text()). - Render(line)) - } - } - - return strings.Join(styledLines, "\n") -} diff --git a/packages/tui/internal/tui/report_view.go b/packages/tui/internal/tui/report_view.go index 6610e310..90362814 100644 --- a/packages/tui/internal/tui/report_view.go +++ b/packages/tui/internal/tui/report_view.go @@ -5,7 +5,7 @@ import ( "github.com/rogue/tui/internal/theme" ) -// renderReport renders the evaluation report screen with summary using a viewport for scrollable content +// renderReport renders the evaluation report screen with summary using MessageHistoryView for scrollable content func (m Model) renderReport() string { t := theme.CurrentTheme() @@ -36,52 +36,40 @@ func (m Model) renderReport() string { header := titleStyle.Render("๐Ÿ“Š Evaluation Report") - // Prepare report content for the viewport - var reportContent string + // Calculate available height: header(3) + helpText(2) = 5 + reportHeight := m.height - 5 + + // Clear existing messages and rebuild report content + m.reportHistory.ClearMessages() + + // Add title as system message + m.reportHistory.AddMessage("system", "๐Ÿ“Š Evaluation Report") + + // Add report content if m.evalState.Summary == "" { if m.evalState.Completed { // Evaluation completed but no summary yet - reportContent = lipgloss.NewStyle(). - Foreground(t.TextMuted()). - Italic(true). - Render("Generating summary, please wait...") + m.reportHistory.AddMessage("system", "Generating summary, please wait...") } else { // Evaluation not completed - reportContent = lipgloss.NewStyle(). - Foreground(t.TextMuted()). - Italic(true). - Render("Evaluation not completed yet. Complete an evaluation to see the report.") + m.reportHistory.AddMessage("system", "Evaluation not completed yet. Complete an evaluation to see the report.") } } else { - // Show the actual summary - reportContent = renderMarkdownSummary(t, m.evalState.Summary) + // Show the actual summary as an assistant message + m.reportHistory.AddMessage("assistant", m.evalState.Summary) } - // Calculate viewport dimensions - // Reserve space for: header (3 lines) + help text (1 line) + margins (2 lines) - viewportWidth := m.width - 8 - viewportHeight := m.height - 6 - - // Create a temporary copy of the viewport to avoid modifying the original - viewport := m.reportViewport - viewport.SetSize(viewportWidth-4, viewportHeight-4) // Account for border and padding - viewport.SetContent(reportContent) - - // Style the viewport with border - viewportStyle := lipgloss.NewStyle(). - Height(viewportHeight). - Border(lipgloss.RoundedBorder()). - BorderForeground(t.Border()). - BorderBackground(t.BackgroundPanel()). - Background(t.BackgroundPanel()) - - // Apply viewport styling - viewport.Style = lipgloss.NewStyle(). - Foreground(t.Text()). - Background(t.BackgroundPanel()). - Width(viewportWidth-4). - Height(viewportHeight-4). - Padding(1, 2) + // Update size for report history + m.reportHistory.SetSize(m.width, reportHeight) + + // Customize prefixes for report view (no prefixes for cleaner look) + m.reportHistory.SetPrefixes("", "") + + // Set colors for report + m.reportHistory.SetColors(t.Success(), t.Text()) + + // Render report using MessageHistoryView + reportContent := m.reportHistory.View(t) // Help text style helpStyle := lipgloss.NewStyle(). @@ -93,27 +81,24 @@ func (m Model) renderReport() string { // Include scroll indicators in help text scrollInfo := "" - if !viewport.AtTop() || !viewport.AtBottom() { + if m.reportHistory != nil && (!m.reportHistory.AtTop() || !m.reportHistory.AtBottom()) { scrollInfo = "โ†‘โ†“ Scroll " } helpText := helpStyle.Render(scrollInfo + "r Refresh b Back to Dashboard Esc Exit") - // Create the viewport content area - viewportContent := viewportStyle.Render(viewport.View()) - - // Center the viewport in the available space + // Create content area contentArea := lipgloss.NewStyle(). Width(m.width). - Height(viewportHeight). + Height(reportHeight). Background(t.Background()) - centeredViewport := contentArea.Render( + centeredReport := contentArea.Render( lipgloss.Place( m.width, - viewportHeight, + reportHeight, lipgloss.Center, lipgloss.Top, - viewportContent, + reportContent, lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(t.Background())), ), ) @@ -121,7 +106,7 @@ func (m Model) renderReport() string { // Combine all sections fullLayout := lipgloss.JoinVertical(lipgloss.Left, header, - centeredViewport, + centeredReport, helpText, )