all repos — llm_aggregator @ 64f25c518cd22ba98fb9ca131b0dd6d654958fbf

A CLI tool to aggregate RSS feeds and summarise them with LLMs

internal/tui/model.go (view raw)

  1package tui
  2
  3import (
  4	"context"
  5	"fmt"
  6	"regexp"
  7	"strings"
  8	"time"
  9
 10	"github.com/charmbracelet/bubbles/progress"
 11	"github.com/charmbracelet/bubbles/spinner"
 12	"github.com/charmbracelet/bubbles/viewport"
 13	tea "github.com/charmbracelet/bubbletea"
 14	"github.com/charmbracelet/lipgloss"
 15	"llm_aggregator/internal/runtime"
 16)
 17
 18// Regex pattern to match thinking tags (both <think> and </think>)
 19var thinkingTagRegex = regexp.MustCompile(`(?s)<think>.*?</think>`)
 20
 21const (
 22	padding  = 2
 23	maxWidth = 80
 24)
 25
 26var (
 27	colorSubtle        = lipgloss.Color("7")   // Gray (dim text)
 28	colorHighlight     = lipgloss.Color("13")  // Magenta (status)
 29	colorSuccess       = lipgloss.Color("2")   // Green (completion)
 30	colorError         = lipgloss.Color("1")   // Red (errors)
 31	colorGradientStart = lipgloss.Color("205") // Pink
 32	colorGradientEnd   = lipgloss.Color("226") // Yellow
 33
 34	titleStyle = lipgloss.NewStyle().
 35			MarginLeft(2).
 36			MarginRight(2).
 37			Padding(0, 1).
 38			Italic(true).
 39			Foreground(lipgloss.Color("15"))
 40
 41	infoStyle = lipgloss.NewStyle().
 42			Foreground(colorSubtle).
 43			Italic(true)
 44
 45	statusStyle = lipgloss.NewStyle().
 46			Foreground(colorHighlight).
 47			Bold(true)
 48
 49	progressBarStyle = lipgloss.NewStyle().
 50				Foreground(colorSuccess)
 51
 52	stepNames = []string{
 53		"Initialising",
 54		"Aggregating feeds",
 55		"Processing articles",
 56		"Connecting to LLM",
 57		"Getting summary",
 58		"Formatting output",
 59		"Complete",
 60	}
 61)
 62
 63type StageMsg string
 64type SubStageMsg string
 65type ArticleCountMsg struct {
 66	Total, Processed int
 67}
 68
 69type processingDoneMsg struct {
 70	err error
 71}
 72
 73type progressMsg float64
 74
 75type Model struct {
 76	progress        progress.Model
 77	spinner         spinner.Model
 78	viewport        viewport.Model
 79	runtime         *runtime.Runtime
 80	currentStep     int
 81	totalSteps      int
 82	status          string
 83	subStatus       string
 84	startTime       time.Time
 85	elapsed         time.Duration
 86	width           int
 87	height          int
 88	done            bool
 89	errorMsg        string
 90	articlesCount   int
 91	processedCount  int
 92	showSummary     bool
 93	showThinking    bool   // Toggle state for thinking tags visibility
 94	thinkingContent string // Extracted thinking content for toggle
 95	cleanSummary    string // Summary with thinking tags removed
 96}
 97
 98func New(rt *runtime.Runtime) *Model {
 99	prog := progress.New(
100		progress.WithSolidFill("colorSuccess"),
101		progress.WithWidth(40),
102		progress.WithoutPercentage(),
103	)
104
105	sp := spinner.New()
106	sp.Spinner = spinner.Dot
107	sp.Style = lipgloss.NewStyle().Foreground(colorHighlight)
108
109	return &Model{
110		progress:     prog,
111		spinner:      sp,
112		runtime:      rt,
113		currentStep:  0,
114		totalSteps:   len(stepNames) - 1, // Last step is "Complete"
115		status:       "Initialising...",
116		subStatus:    "Loading configuration",
117		startTime:    time.Now(),
118		showSummary:  false,
119		showThinking: false, // Default: thinking tags are hidden
120	}
121}
122
123func (m *Model) Init() tea.Cmd {
124	// Initialise the viewport with default size (will be resized on WindowSizeMsg)
125	m.viewport = viewport.New(80, 20)
126	m.viewport.MouseWheelEnabled = true
127
128	return tea.Batch(m.spinner.Tick, m.startProcessing)
129}
130
131// This is now the standard bubbletea command pattern.
132// The bubbletea runtime will execute this function in a goroutine.
133// We no longer need to manage the goroutine or use program.Send().
134func (m *Model) startProcessing() tea.Msg {
135	err := m.runtime.Execute(context.Background())
136	return processingDoneMsg{err: err}
137}
138
139func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
140	switch msg := msg.(type) {
141	case tea.KeyMsg:
142		// Let the viewport handle navigation keys if showing summary
143		if m.showSummary {
144			var cmd tea.Cmd
145			m.viewport, cmd = m.viewport.Update(msg)
146			if cmd != nil {
147				return m, cmd
148			}
149			// If viewport handled a key, we're done
150			switch msg.String() {
151			case "j", "down", "k", "up", "g", "G", " ", "b":
152				return m, nil
153			}
154		}
155		switch msg.String() {
156		case "q", "ctrl+c":
157			return m, tea.Quit
158		case "t":
159			// Toggle thinking tags visibility
160			if m.thinkingContent != "" {
161				m.showThinking = !m.showThinking
162				contentWidth := m.viewport.Width
163				var summaryContent string
164				if m.showThinking {
165					// Show thinking at the head of the output (without XML tags)
166					thinkingText := stripThinkingTags(m.thinkingContent)
167					summaryContent = wrapTextGray("💭 Thinking\n\n"+thinkingText, contentWidth) + "\n\n📝 Summary\n\n" + wrapText(m.cleanSummary, contentWidth)
168				} else {
169					// Hide thinking tags (default)
170					summaryContent = "📝 Summary\n\n" + wrapText(m.cleanSummary, contentWidth)
171				}
172				m.viewport.SetContent(summaryContent)
173				// Reset scroll position to top when toggling
174				m.viewport.GotoTop()
175				return m, nil
176			}
177		}
178	case tea.WindowSizeMsg:
179		m.width = msg.Width
180		m.height = msg.Height
181		m.progress.Width = min(msg.Width-padding*2-4, maxWidth)
182		// Resize viewport for summary display
183		if m.showSummary {
184			m.viewport.Width = msg.Width - padding*2
185			m.viewport.Height = msg.Height - 10 // Leave space for header/footer
186		}
187	case progressMsg:
188		// This message type seems unused, but leaving it in case.
189		progress := float64(msg)
190		if progress >= 1.0 {
191			m.done = true
192			return m, nil
193		}
194		cmd := m.progress.SetPercent(float64(progress))
195		return m, cmd
196	case spinner.TickMsg:
197		// MODIFIED: Stop updating the spinner once we're done.
198		if m.done {
199			return m, nil
200		}
201		var cmd tea.Cmd
202		m.spinner, cmd = m.spinner.Update(msg)
203		return m, cmd
204	case StageMsg:
205		stageName := string(msg)
206		for i, name := range stepNames {
207			if name == stageName {
208				m.currentStep = i
209				m.status = name
210				m.subStatus = "" // Clear sub-status when stage changes
211				break
212			}
213		}
214
215	case SubStageMsg:
216		m.subStatus = string(msg)
217
218	case ArticleCountMsg:
219		m.articlesCount = msg.Total
220		m.processedCount = msg.Processed
221
222	case processingDoneMsg:
223		if msg.err != nil {
224			m.errorMsg = msg.err.Error()
225			m.done = true // Also mark as done on error
226		} else {
227			m.done = true
228			m.currentStep = m.totalSteps // Go to "Complete" step
229			m.status = stepNames[m.totalSteps]
230			m.subStatus = "Summary generated successfully!"
231			// Enable summary viewport if there's content to display
232			if m.runtime.Summary != "" {
233				m.showSummary = true
234				// Extract and remove thinking tags from the summary
235				m.thinkingContent = thinkingTagRegex.FindString(m.runtime.Summary)
236				m.cleanSummary = thinkingTagRegex.ReplaceAllString(m.runtime.Summary, "")
237				// Clean up extra whitespace left behind after removing thinking tags
238				m.cleanSummary = strings.TrimSpace(m.cleanSummary)
239				// Resize viewport to fit available space
240				m.viewport.Width = m.width - padding*2
241				m.viewport.Height = m.height - 15 // Leave space for header/footer
242				// Wrap content to viewport width before setting
243				contentWidth := m.viewport.Width
244				// Default: show summary without thinking tags
245				summaryContent := "📝 Summary\n\n" + wrapText(m.cleanSummary, contentWidth)
246				m.viewport.SetContent(summaryContent)
247			}
248		}
249		// Capture the final elapsed time.
250		m.elapsed = time.Since(m.startTime)
251		// We are done, so we return a command to quit the program automatically.
252		// The user can see the final summary. If we want the user to quit manually,
253		// just return 'm, nil'.
254		return m, nil
255	case ErrorMsg:
256		m.errorMsg = msg.Error
257		m.status = "Error"
258		return m, tea.Quit
259	}
260
261	return m, nil
262}
263
264func (m *Model) View() string {
265	if m.width == 0 {
266		return "Initialising..."
267	}
268
269	// If we're showing the summary with scrolling, use a different layout
270	if m.showSummary {
271		return m.viewSummary()
272	}
273
274	var sb strings.Builder
275
276	// Title
277	sb.WriteString(titleStyle.Render("🤖 LLM Aggregator"))
278	sb.WriteString("\n\n")
279
280	// If done, show 100% progress.
281	var progressPercent float64
282	if m.done && m.errorMsg == "" {
283		progressPercent = 1.0
284	} else if m.articlesCount > 0 && m.currentStep >= 4 {
285		progressPercent = float64(m.processedCount) / float64(m.articlesCount)
286	} else {
287		progressPercent = float64(m.currentStep) / float64(m.totalSteps)
288	}
289
290	progressView := m.progress.ViewAs(progressPercent)
291	sb.WriteString(progressBarStyle.Render(progressView))
292	sb.WriteString("\n\n")
293
294	// Status with spinner
295	sb.WriteString(statusStyle.Render("Status: "))
296
297	// Replace spinner with a checkmark when done.
298	if m.done && m.errorMsg == "" {
299		sb.WriteString(lipgloss.NewStyle().Foreground(colorSuccess).Render("✓"))
300	} else if m.done && m.errorMsg != "" {
301		sb.WriteString(lipgloss.NewStyle().Foreground(colorError).Render("✗"))
302	} else {
303		sb.WriteString(m.spinner.View())
304	}
305
306	sb.WriteString(" ")
307	sb.WriteString(m.status)
308	sb.WriteString("\n")
309
310	// Sub-status
311	if m.subStatus != "" {
312		sb.WriteString(infoStyle.Render("  → "))
313		sb.WriteString(m.subStatus)
314		sb.WriteString("\n")
315	}
316
317	// Statistics
318	if m.articlesCount > 0 {
319		sb.WriteString(infoStyle.Render(fmt.Sprintf("Articles: %d", m.articlesCount)))
320		sb.WriteString("  ")
321	}
322	if m.processedCount > 0 {
323		sb.WriteString(infoStyle.Render(fmt.Sprintf("Processed: %d", m.processedCount)))
324		sb.WriteString("\n")
325	}
326
327	// Elapsed time
328	// MODIFIED: Use the stored elapsed time if done, otherwise calculate it live.
329	elapsed := m.elapsed
330	if !m.done {
331		elapsed = time.Since(m.startTime)
332	}
333	sb.WriteString(infoStyle.Render(fmt.Sprintf("Elapsed: %v", elapsed.Round(time.Second))))
334	sb.WriteString("\n\n")
335
336	// Error message (if any)
337	if m.errorMsg != "" {
338		errorStyle := lipgloss.NewStyle().
339			Foreground(colorError).
340			Bold(true)
341		sb.WriteString(errorStyle.Render("Error: "))
342		sb.WriteString(m.errorMsg)
343		sb.WriteString("\n\n")
344	}
345
346	// Simplified help text logic
347	if m.done {
348		// The summary is not part of the TUI, it's printed to stdout *after* the TUI exits.
349		// The TUI's job is just to show the progress.
350		// The `tea.Quit` in the `processingDoneMsg` case will cause the program to exit
351		// and the rest of the `main` function to proceed.
352	} else {
353		sb.WriteString(infoStyle.Render("Press 'q' or Ctrl+C to quit"))
354	}
355
356	return sb.String()
357}
358
359// Messages for updating the TUI
360type StepMsg struct {
361	Step           int
362	Status         string
363	ArticlesCount  int
364	ProcessedCount int
365}
366
367type ErrorMsg struct {
368	Error string
369}
370
371func Step(step int, status string) tea.Cmd {
372	return func() tea.Msg {
373		return StepMsg{Step: step, Status: status}
374	}
375}
376
377func StepWithCounts(step int, status string, articles, processed int) tea.Cmd {
378	return func() tea.Msg {
379		return StepMsg{
380			Step:           step,
381			Status:         status,
382			ArticlesCount:  articles,
383			ProcessedCount: processed,
384		}
385	}
386}
387
388func Error(err string) tea.Cmd {
389	return func() tea.Msg {
390		return ErrorMsg{Error: err}
391	}
392}
393
394// viewSummary renders the summary view with scrolling support.
395func (m *Model) viewSummary() string {
396	var sb strings.Builder
397
398	// Header
399	sb.WriteString(titleStyle.Render("🤖 LLM Aggregator"))
400	sb.WriteString("\n\n")
401
402	// Show completion status
403	sb.WriteString(statusStyle.Render("✓ Complete"))
404	sb.WriteString(" ")
405	sb.WriteString(m.subStatus)
406	sb.WriteString("\n\n")
407
408	// Elapsed time and article stats
409	sb.WriteString(infoStyle.Render(fmt.Sprintf("Articles: %d | Processed: %d | Elapsed: %v",
410		m.articlesCount, m.processedCount, m.elapsed.Round(time.Second))))
411	sb.WriteString("\n\n")
412
413	// Scroll progress indicator
414	scrollPercent := int(m.viewport.ScrollPercent() * 100)
415	if m.viewport.TotalLineCount() > m.viewport.VisibleLineCount() {
416		sb.WriteString(infoStyle.Render(fmt.Sprintf("Scroll: %d%% (%d/%d lines)",
417			scrollPercent, m.viewport.TotalLineCount()-m.viewport.VisibleLineCount()-m.viewport.YOffset,
418			m.viewport.TotalLineCount())))
419		sb.WriteString("\n\n")
420	}
421
422	// Viewport (scrollable summary)
423	sb.WriteString(m.viewport.View())
424	sb.WriteString("\n")
425
426	// Navigation help
427	var toggleIndicator string
428	if m.thinkingContent != "" {
429		if m.showThinking {
430			toggleIndicator = infoStyle.Render(" [💭 Thinking: ON]")
431		} else {
432			toggleIndicator = infoStyle.Render(" [💭 Thinking: OFF]")
433		}
434	}
435	sb.WriteString(infoStyle.Render("Navigate: ↑/↓ or j/k (scroll) | Space/B (page) | g/G (start/end) | t (toggle thinking) | q (quit)"))
436	sb.WriteString(toggleIndicator)
437
438	return sb.String()
439}
440
441// wrapText wraps text to a given width using lipgloss.
442func wrapText(text string, width int) string {
443	style := lipgloss.NewStyle().Width(width)
444	return style.Render(text)
445}
446
447// wrapTextGray wraps text to a given width using lipgloss with gray colour.
448func wrapTextGray(text string, width int) string {
449	style := lipgloss.NewStyle().
450		Width(width).
451		Foreground(colorSubtle)
452	return style.Render(text)
453}
454
455// stripThinkingTags removes the <think> and </think> XML tags from the thinking content.
456func stripThinkingTags(content string) string {
457	// Remove <think> tag
458	reOpen := regexp.MustCompile(`<think>\s*`)
459	content = reOpen.ReplaceAllString(content, "")
460
461	// Remove </think> tag
462	reClose := regexp.MustCompile(`\s*</think>`)
463	content = reClose.ReplaceAllString(content, "")
464
465	return content
466}