package tui import ( "context" "fmt" "regexp" "strings" "time" "github.com/charmbracelet/bubbles/progress" "github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "llm_aggregator/internal/runtime" ) // Regex pattern to match thinking tags (both and ) var thinkingTagRegex = regexp.MustCompile(`(?s).*?`) const ( padding = 2 maxWidth = 80 ) var ( colorSubtle = lipgloss.Color("7") // Gray (dim text) colorHighlight = lipgloss.Color("13") // Magenta (status) colorSuccess = lipgloss.Color("2") // Green (completion) colorError = lipgloss.Color("1") // Red (errors) colorGradientStart = lipgloss.Color("205") // Pink colorGradientEnd = lipgloss.Color("226") // Yellow titleStyle = lipgloss.NewStyle(). MarginLeft(2). MarginRight(2). Padding(0, 1). Italic(true). Foreground(lipgloss.Color("15")) infoStyle = lipgloss.NewStyle(). Foreground(colorSubtle). Italic(true) statusStyle = lipgloss.NewStyle(). Foreground(colorHighlight). Bold(true) progressBarStyle = lipgloss.NewStyle(). Foreground(colorSuccess) stepNames = []string{ "Initialising", "Aggregating feeds", "Processing articles", "Connecting to LLM", "Getting summary", "Formatting output", "Complete", } ) type StageMsg string type SubStageMsg string type ArticleCountMsg struct { Total, Processed int } type processingDoneMsg struct { err error } type progressMsg float64 type Model struct { progress progress.Model spinner spinner.Model viewport viewport.Model runtime *runtime.Runtime currentStep int totalSteps int status string subStatus string startTime time.Time elapsed time.Duration width int height int done bool errorMsg string articlesCount int processedCount int showSummary bool showThinking bool // Toggle state for thinking tags visibility thinkingContent string // Extracted thinking content for toggle cleanSummary string // Summary with thinking tags removed } func New(rt *runtime.Runtime) *Model { prog := progress.New( progress.WithSolidFill("colorSuccess"), progress.WithWidth(40), progress.WithoutPercentage(), ) sp := spinner.New() sp.Spinner = spinner.Dot sp.Style = lipgloss.NewStyle().Foreground(colorHighlight) return &Model{ progress: prog, spinner: sp, runtime: rt, currentStep: 0, totalSteps: len(stepNames) - 1, // Last step is "Complete" status: "Initialising...", subStatus: "Loading configuration", startTime: time.Now(), showSummary: false, showThinking: false, // Default: thinking tags are hidden } } func (m *Model) Init() tea.Cmd { // Initialise the viewport with default size (will be resized on WindowSizeMsg) m.viewport = viewport.New(80, 20) m.viewport.MouseWheelEnabled = true return tea.Batch(m.spinner.Tick, m.startProcessing) } // This is now the standard bubbletea command pattern. // The bubbletea runtime will execute this function in a goroutine. // We no longer need to manage the goroutine or use program.Send(). func (m *Model) startProcessing() tea.Msg { err := m.runtime.Execute(context.Background()) return processingDoneMsg{err: err} } func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: // Let the viewport handle navigation keys if showing summary if m.showSummary { var cmd tea.Cmd m.viewport, cmd = m.viewport.Update(msg) if cmd != nil { return m, cmd } // If viewport handled a key, we're done switch msg.String() { case "j", "down", "k", "up", "g", "G", " ", "b": return m, nil } } switch msg.String() { case "q", "ctrl+c": return m, tea.Quit case "t": // Toggle thinking tags visibility if m.thinkingContent != "" { m.showThinking = !m.showThinking contentWidth := m.viewport.Width var summaryContent string if m.showThinking { // Show thinking at the head of the output (without XML tags) thinkingText := stripThinkingTags(m.thinkingContent) summaryContent = wrapTextGray("šŸ’­ Thinking\n\n"+thinkingText, contentWidth) + "\n\nšŸ“ Summary\n\n" + wrapText(m.cleanSummary, contentWidth) } else { // Hide thinking tags (default) summaryContent = "šŸ“ Summary\n\n" + wrapText(m.cleanSummary, contentWidth) } m.viewport.SetContent(summaryContent) // Reset scroll position to top when toggling m.viewport.GotoTop() return m, nil } } case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height m.progress.Width = min(msg.Width-padding*2-4, maxWidth) // Resize viewport for summary display if m.showSummary { m.viewport.Width = msg.Width - padding*2 m.viewport.Height = msg.Height - 10 // Leave space for header/footer } case progressMsg: // This message type seems unused, but leaving it in case. progress := float64(msg) if progress >= 1.0 { m.done = true return m, nil } cmd := m.progress.SetPercent(float64(progress)) return m, cmd case spinner.TickMsg: // MODIFIED: Stop updating the spinner once we're done. if m.done { return m, nil } var cmd tea.Cmd m.spinner, cmd = m.spinner.Update(msg) return m, cmd case StageMsg: stageName := string(msg) for i, name := range stepNames { if name == stageName { m.currentStep = i m.status = name m.subStatus = "" // Clear sub-status when stage changes break } } case SubStageMsg: m.subStatus = string(msg) case ArticleCountMsg: m.articlesCount = msg.Total m.processedCount = msg.Processed case processingDoneMsg: if msg.err != nil { m.errorMsg = msg.err.Error() m.done = true // Also mark as done on error } else { m.done = true m.currentStep = m.totalSteps // Go to "Complete" step m.status = stepNames[m.totalSteps] m.subStatus = "Summary generated successfully!" // Enable summary viewport if there's content to display if m.runtime.Summary != "" { m.showSummary = true // Extract and remove thinking tags from the summary m.thinkingContent = thinkingTagRegex.FindString(m.runtime.Summary) m.cleanSummary = thinkingTagRegex.ReplaceAllString(m.runtime.Summary, "") // Clean up extra whitespace left behind after removing thinking tags m.cleanSummary = strings.TrimSpace(m.cleanSummary) // Resize viewport to fit available space m.viewport.Width = m.width - padding*2 m.viewport.Height = m.height - 15 // Leave space for header/footer // Wrap content to viewport width before setting contentWidth := m.viewport.Width // Default: show summary without thinking tags summaryContent := "šŸ“ Summary\n\n" + wrapText(m.cleanSummary, contentWidth) m.viewport.SetContent(summaryContent) } } // Capture the final elapsed time. m.elapsed = time.Since(m.startTime) // We are done, so we return a command to quit the program automatically. // The user can see the final summary. If we want the user to quit manually, // just return 'm, nil'. return m, nil case ErrorMsg: m.errorMsg = msg.Error m.status = "Error" return m, tea.Quit } return m, nil } func (m *Model) View() string { if m.width == 0 { return "Initialising..." } // If we're showing the summary with scrolling, use a different layout if m.showSummary { return m.viewSummary() } var sb strings.Builder // Title sb.WriteString(titleStyle.Render("šŸ¤– LLM Aggregator")) sb.WriteString("\n\n") // If done, show 100% progress. var progressPercent float64 if m.done && m.errorMsg == "" { progressPercent = 1.0 } else if m.articlesCount > 0 && m.currentStep >= 4 { progressPercent = float64(m.processedCount) / float64(m.articlesCount) } else { progressPercent = float64(m.currentStep) / float64(m.totalSteps) } progressView := m.progress.ViewAs(progressPercent) sb.WriteString(progressBarStyle.Render(progressView)) sb.WriteString("\n\n") // Status with spinner sb.WriteString(statusStyle.Render("Status: ")) // Replace spinner with a checkmark when done. if m.done && m.errorMsg == "" { sb.WriteString(lipgloss.NewStyle().Foreground(colorSuccess).Render("āœ“")) } else if m.done && m.errorMsg != "" { sb.WriteString(lipgloss.NewStyle().Foreground(colorError).Render("āœ—")) } else { sb.WriteString(m.spinner.View()) } sb.WriteString(" ") sb.WriteString(m.status) sb.WriteString("\n") // Sub-status if m.subStatus != "" { sb.WriteString(infoStyle.Render(" → ")) sb.WriteString(m.subStatus) sb.WriteString("\n") } // Statistics if m.articlesCount > 0 { sb.WriteString(infoStyle.Render(fmt.Sprintf("Articles: %d", m.articlesCount))) sb.WriteString(" ") } if m.processedCount > 0 { sb.WriteString(infoStyle.Render(fmt.Sprintf("Processed: %d", m.processedCount))) sb.WriteString("\n") } // Elapsed time // MODIFIED: Use the stored elapsed time if done, otherwise calculate it live. elapsed := m.elapsed if !m.done { elapsed = time.Since(m.startTime) } sb.WriteString(infoStyle.Render(fmt.Sprintf("Elapsed: %v", elapsed.Round(time.Second)))) sb.WriteString("\n\n") // Error message (if any) if m.errorMsg != "" { errorStyle := lipgloss.NewStyle(). Foreground(colorError). Bold(true) sb.WriteString(errorStyle.Render("Error: ")) sb.WriteString(m.errorMsg) sb.WriteString("\n\n") } // Simplified help text logic if m.done { // The summary is not part of the TUI, it's printed to stdout *after* the TUI exits. // The TUI's job is just to show the progress. // The `tea.Quit` in the `processingDoneMsg` case will cause the program to exit // and the rest of the `main` function to proceed. } else { sb.WriteString(infoStyle.Render("Press 'q' or Ctrl+C to quit")) } return sb.String() } // Messages for updating the TUI type StepMsg struct { Step int Status string ArticlesCount int ProcessedCount int } type ErrorMsg struct { Error string } func Step(step int, status string) tea.Cmd { return func() tea.Msg { return StepMsg{Step: step, Status: status} } } func StepWithCounts(step int, status string, articles, processed int) tea.Cmd { return func() tea.Msg { return StepMsg{ Step: step, Status: status, ArticlesCount: articles, ProcessedCount: processed, } } } func Error(err string) tea.Cmd { return func() tea.Msg { return ErrorMsg{Error: err} } } // viewSummary renders the summary view with scrolling support. func (m *Model) viewSummary() string { var sb strings.Builder // Header sb.WriteString(titleStyle.Render("šŸ¤– LLM Aggregator")) sb.WriteString("\n\n") // Show completion status sb.WriteString(statusStyle.Render("āœ“ Complete")) sb.WriteString(" ") sb.WriteString(m.subStatus) sb.WriteString("\n\n") // Elapsed time and article stats sb.WriteString(infoStyle.Render(fmt.Sprintf("Articles: %d | Processed: %d | Elapsed: %v", m.articlesCount, m.processedCount, m.elapsed.Round(time.Second)))) sb.WriteString("\n\n") // Scroll progress indicator scrollPercent := int(m.viewport.ScrollPercent() * 100) if m.viewport.TotalLineCount() > m.viewport.VisibleLineCount() { sb.WriteString(infoStyle.Render(fmt.Sprintf("Scroll: %d%% (%d/%d lines)", scrollPercent, m.viewport.TotalLineCount()-m.viewport.VisibleLineCount()-m.viewport.YOffset, m.viewport.TotalLineCount()))) sb.WriteString("\n\n") } // Viewport (scrollable summary) sb.WriteString(m.viewport.View()) sb.WriteString("\n") // Navigation help var toggleIndicator string if m.thinkingContent != "" { if m.showThinking { toggleIndicator = infoStyle.Render(" [šŸ’­ Thinking: ON]") } else { toggleIndicator = infoStyle.Render(" [šŸ’­ Thinking: OFF]") } } sb.WriteString(infoStyle.Render("Navigate: ↑/↓ or j/k (scroll) | Space/B (page) | g/G (start/end) | t (toggle thinking) | q (quit)")) sb.WriteString(toggleIndicator) return sb.String() } // wrapText wraps text to a given width using lipgloss. func wrapText(text string, width int) string { style := lipgloss.NewStyle().Width(width) return style.Render(text) } // wrapTextGray wraps text to a given width using lipgloss with gray colour. func wrapTextGray(text string, width int) string { style := lipgloss.NewStyle(). Width(width). Foreground(colorSubtle) return style.Render(text) } // stripThinkingTags removes the and XML tags from the thinking content. func stripThinkingTags(content string) string { // Remove tag reOpen := regexp.MustCompile(`\s*`) content = reOpen.ReplaceAllString(content, "") // Remove tag reClose := regexp.MustCompile(`\s*`) content = reClose.ReplaceAllString(content, "") return content }