all repos — llm_aggregator @ 658b2cfd3cd0eca5835b921100e08569a426e199

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
404func Step(step int, status string) tea.Cmd {
405	return func() tea.Msg {
406		return StepMsg{Step: step, Status: status}
407	}
408}
409
410func StepWithCounts(step int, status string, articles, processed int) tea.Cmd {
411	return func() tea.Msg {
412		return StepMsg{
413			Step:           step,
414			Status:         status,
415			ArticlesCount:  articles,
416			ProcessedCount: processed,
417		}
418	}
419}
420
421func Error(err string) tea.Cmd {
422	return func() tea.Msg {
423		return ErrorMsg{Error: err}
424	}
425}
426
427// viewSummary renders the summary view with scrolling support.
428func (m *Model) viewSummary() string {
429	var sb strings.Builder
430
431	// Header
432	sb.WriteString(titleStyle.Render("🤖 LLM Aggregator"))
433	sb.WriteString("\n\n")
434
435	// Show completion status
436	sb.WriteString(statusStyle.Render("✓ Complete"))
437	sb.WriteString(" ")
438	sb.WriteString(m.subStatus)
439	sb.WriteString("\n\n")
440
441	// Elapsed time and article stats
442	sb.WriteString(infoStyle.Render(fmt.Sprintf("Articles: %d | Processed: %d | Elapsed: %v",
443		m.articlesCount, m.processedCount, m.elapsed.Round(time.Second))))
444	sb.WriteString("\n\n")
445
446	// Scroll progress indicator
447	scrollPercent := int(m.viewport.ScrollPercent() * 100)
448	if m.viewport.TotalLineCount() > m.viewport.VisibleLineCount() {
449		sb.WriteString(infoStyle.Render(fmt.Sprintf("Scroll: %d%% (%d/%d lines)",
450			scrollPercent, m.viewport.TotalLineCount()-m.viewport.VisibleLineCount()-m.viewport.YOffset,
451			m.viewport.TotalLineCount())))
452		sb.WriteString("\n\n")
453	}
454
455	// Viewport (scrollable summary)
456	sb.WriteString(m.viewport.View())
457	sb.WriteString("\n")
458
459	// Thinking toggle indicator (separate element above keybinds)
460	if m.thinkingContent != "" {
461		if m.showThinking {
462			sb.WriteString(thinkingOnStyle.Render("[💭 Thinking: ON]"))
463		} else {
464			sb.WriteString(thinkingOffStyle.Render("[💭 Thinking: OFF]"))
465		}
466		sb.WriteString("\n")
467	}
468
469	// Infobar - stylised keybinds
470	sb.WriteString(infobarKeybindStyle.Render("↑/↓"))
471	sb.WriteString(infobarSeparatorStyle.Render(" or "))
472	sb.WriteString(infobarKeybindStyle.Render("j/k"))
473	sb.WriteString(infobarActionStyle.Render(" (scroll)"))
474	sb.WriteString(infobarSeparatorStyle.Render(" | "))
475	sb.WriteString(infobarKeybindStyle.Render("Space/B"))
476	sb.WriteString(infobarActionStyle.Render(" (page)"))
477	sb.WriteString(infobarSeparatorStyle.Render(" | "))
478	sb.WriteString(infobarKeybindStyle.Render("g/G"))
479	sb.WriteString(infobarActionStyle.Render(" (start/end)"))
480	sb.WriteString(infobarSeparatorStyle.Render(" | "))
481	sb.WriteString(infobarKeybindStyle.Render("t"))
482	sb.WriteString(infobarActionStyle.Render(" (toggle thinking)"))
483	sb.WriteString(infobarSeparatorStyle.Render(" | "))
484	sb.WriteString(infobarKeybindStyle.Render("q"))
485	sb.WriteString(infobarActionStyle.Render(" (quit)"))
486
487	return sb.String()
488}
489
490// wrapText wraps text to a given width using lipgloss.
491func wrapText(text string, width int) string {
492	// Use glamour to render markdown with proper styling
493	r := getGlamourRenderer(width)
494	out, err := r.Render(text)
495	if err != nil {
496		// Fallback to plain text wrapping if glamour fails
497		style := lipgloss.NewStyle().Width(width)
498		return style.Render(text)
499	}
500	return out
501}
502
503// getGlamourRenderer returns a cached glamour renderer configured for the given width.
504func getGlamourRenderer(width int) *glamour.TermRenderer {
505	// Create a new renderer with the specified width
506	r, err := glamour.NewTermRenderer(
507		glamour.WithWordWrap(width),
508		glamour.WithStandardStyle("dark"),
509	)
510	if err != nil {
511		// Fallback to default renderer if creation fails
512		r, _ = glamour.NewTermRenderer(glamour.WithWordWrap(width))
513	}
514	return r
515}
516
517// wrapTextGray wraps text to a given width using lipgloss with gray colour.
518func wrapTextGray(text string, width int) string {
519	style := lipgloss.NewStyle().
520		Width(width).
521		Foreground(colorSubtle)
522	return style.Render(text)
523}
524
525// stripThinkingTags removes the <think> and </think> XML tags from the thinking content.
526func stripThinkingTags(content string) string {
527	// Remove <think> tag
528	reOpen := regexp.MustCompile(`<think>\s*`)
529	content = reOpen.ReplaceAllString(content, "")
530
531	// Remove </think> tag
532	reClose := regexp.MustCompile(`\s*</think>`)
533	content = reClose.ReplaceAllString(content, "")
534
535	return content
536}