all repos — llm_aggregator @ b4f6beb3ba126b91482bcd5df0745445088d51d7

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