all repos — llm_aggregator @ 2d9899ec7612acc35f5ae4e343d6364a9f2dc8b1

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