all repos — llm_aggregator @ d3fad3374aee5ee549219c1387a0c5d87ce1a99b

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
 33	titleStyle = lipgloss.NewStyle().
 34			MarginLeft(2).
 35			MarginRight(2).
 36			Padding(0, 1).
 37			Italic(true).
 38			Foreground(lipgloss.Color("15"))
 39
 40	infoStyle = lipgloss.NewStyle().
 41			Foreground(colorSubtle).
 42			Italic(true)
 43
 44	statusStyle = lipgloss.NewStyle().
 45			Foreground(colorHighlight).
 46			Bold(true)
 47
 48	progressBarStyle = lipgloss.NewStyle().
 49				Foreground(colorSuccess)
 50
 51	stepNames = []string{
 52		"Initialising",
 53		"Aggregating feeds",
 54		"Processing articles",
 55		"Connecting to LLM",
 56		"Getting summary",
 57		"Formatting output",
 58		"Complete",
 59	}
 60
 61	// Infobar keybinds - stylised for easy modification
 62	infobarKeybindStyle = lipgloss.NewStyle().
 63		Foreground(colorSubtle).
 64		Bold(true)
 65
 66	infobarSeparatorStyle = lipgloss.NewStyle().
 67		Foreground(colorSubtle).
 68		Italic(false)
 69
 70	infobarActionStyle = lipgloss.NewStyle().
 71		Foreground(colorSubtle).
 72		Italic(true)
 73
 74	// Thinking toggle indicator (separate element above keybinds)
 75	thinkingOnStyle = lipgloss.NewStyle().
 76		Foreground(colorHighlight).
 77		Bold(true)
 78
 79	thinkingOffStyle = lipgloss.NewStyle().
 80		Foreground(colorSubtle).
 81		Italic(true)
 82)
 83
 84type StageMsg string
 85type SubStageMsg string
 86type ArticleCountMsg struct {
 87	Total, Processed int
 88}
 89
 90type TokenEstimateMsg struct {
 91	Total, Used int
 92}
 93
 94type WaitingMsg struct{}
 95
 96type processingDoneMsg struct {
 97	err error
 98}
 99
100type progressMsg float64
101
102type Model struct {
103	progress        progress.Model
104	spinner         spinner.Model
105	viewport        viewport.Model
106	runtime         *runtime.Runtime
107	currentStep     int
108	totalSteps      int
109	status          string
110	subStatus       string
111	startTime       time.Time
112	elapsed         time.Duration
113	width           int
114	height          int
115	done            bool
116	errorMsg        string
117	articlesCount   int
118	processedCount  int
119	tokenCount      int
120	tokenUsed       int
121	waiting         bool
122	waitingStart    time.Time
123	showSummary     bool
124	showThinking    bool   // Toggle state for thinking tags visibility
125	thinkingContent string // Extracted thinking content for toggle
126	cleanSummary    string // Summary with thinking tags removed
127}
128
129func New(rt *runtime.Runtime) *Model {
130	prog := progress.New(
131		progress.WithSolidFill("colorSuccess"),
132		progress.WithWidth(40),
133		progress.WithoutPercentage(),
134	)
135
136	sp := spinner.New()
137	sp.Spinner = spinner.Dot
138	sp.Style = lipgloss.NewStyle().Foreground(colorHighlight)
139
140	return &Model{
141		progress:     prog,
142		spinner:      sp,
143		runtime:      rt,
144		currentStep:  0,
145		totalSteps:   len(stepNames) - 1, // Last step is "Complete"
146		status:       "Initialising...",
147		subStatus:    "Loading configuration",
148		startTime:    time.Now(),
149		showSummary:  false,
150		showThinking: false, // Default: thinking tags are hidden
151	}
152}
153
154func (m *Model) Init() tea.Cmd {
155	// Initialise the viewport with default size (will be resized on WindowSizeMsg)
156	m.viewport = viewport.New(80, 20)
157	m.viewport.MouseWheelEnabled = true
158
159	return tea.Batch(m.spinner.Tick, m.startProcessing)
160}
161
162// This is now the standard bubbletea command pattern.
163// The bubbletea runtime will execute this function in a goroutine.
164// We no longer need to manage the goroutine or use program.Send().
165func (m *Model) startProcessing() tea.Msg {
166	err := m.runtime.Execute(context.Background())
167	return processingDoneMsg{err: err}
168}
169
170func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
171	switch msg := msg.(type) {
172	case tea.KeyMsg:
173		// Let the viewport handle navigation keys if showing summary
174		if m.showSummary {
175			var cmd tea.Cmd
176			m.viewport, cmd = m.viewport.Update(msg)
177			if cmd != nil {
178				return m, cmd
179			}
180			// If viewport handled a key, we're done
181			switch msg.String() {
182			case "j", "down", "k", "up", "g", "G", " ", "b":
183				return m, nil
184			}
185		}
186		switch msg.String() {
187		case "q", "ctrl+c":
188			return m, tea.Quit
189		case "t":
190			// Toggle thinking tags visibility
191			if m.thinkingContent != "" {
192				m.showThinking = !m.showThinking
193				contentWidth := m.viewport.Width
194				var summaryContent string
195				if m.showThinking {
196					// Show thinking at the head of the output (without XML tags)
197					thinkingText := stripThinkingTags(m.thinkingContent)
198					summaryContent = wrapTextGray("💭 Thinking\n\n"+thinkingText, contentWidth) + "\n\n📝 Summary\n\n" + wrapText(m.cleanSummary, contentWidth)
199				} else {
200					// Hide thinking tags (default)
201					summaryContent = "📝 Summary\n\n" + wrapText(m.cleanSummary, contentWidth)
202				}
203				m.viewport.SetContent(summaryContent)
204				// Reset scroll position to top when toggling
205				m.viewport.GotoTop()
206				return m, nil
207			}
208		}
209	case tea.WindowSizeMsg:
210		m.width = msg.Width
211		m.height = msg.Height
212		m.progress.Width = min(msg.Width-padding*2-4, maxWidth)
213		// Resize viewport for summary display
214		if m.showSummary {
215			m.viewport.Width = msg.Width - padding*2
216			m.viewport.Height = msg.Height - 10 // Leave space for header/footer
217			// Re-wrap content to new width when resizing
218			contentWidth := m.viewport.Width
219			var summaryContent string
220			if m.showThinking && m.thinkingContent != "" {
221				thinkingText := stripThinkingTags(m.thinkingContent)
222				summaryContent = wrapTextGray("💭 Thinking\n\n"+thinkingText, contentWidth) + "\n\n📝 Summary\n\n" + wrapText(m.cleanSummary, contentWidth)
223			} else {
224				summaryContent = "📝 Summary\n\n" + wrapText(m.cleanSummary, contentWidth)
225			}
226			m.viewport.SetContent(summaryContent)
227		}
228	case progressMsg:
229		// This message type seems unused, but leaving it in case.
230		progress := float64(msg)
231		if progress >= 1.0 {
232			m.done = true
233			return m, nil
234		}
235		cmd := m.progress.SetPercent(float64(progress))
236		return m, cmd
237	case spinner.TickMsg:
238		// MODIFIED: Stop updating the spinner once we're done.
239		if m.done {
240			return m, nil
241		}
242		var cmd tea.Cmd
243		m.spinner, cmd = m.spinner.Update(msg)
244		return m, cmd
245	case StageMsg:
246		stageName := string(msg)
247		for i, name := range stepNames {
248			if name == stageName {
249				m.currentStep = i
250				m.status = name
251				m.subStatus = "" // Clear sub-status when stage changes
252				break
253			}
254		}
255
256	case SubStageMsg:
257		m.subStatus = string(msg)
258
259	case ArticleCountMsg:
260		m.articlesCount = msg.Total
261		m.processedCount = msg.Processed
262
263	case TokenEstimateMsg:
264		m.tokenCount = msg.Total
265		m.tokenUsed = msg.Used
266
267	case WaitingMsg:
268		m.waiting = true
269		m.waitingStart = time.Now()
270
271	case processingDoneMsg:
272		m.waiting = false
273		if msg.err != nil {
274			m.errorMsg = msg.err.Error()
275			m.done = true // Also mark as done on error
276		} else {
277			m.done = true
278			m.currentStep = m.totalSteps // Go to "Complete" step
279			m.status = stepNames[m.totalSteps]
280			m.subStatus = "Summary generated successfully!"
281			// Enable summary viewport if there's content to display
282			if m.runtime.Summary != "" {
283				m.showSummary = true
284				// Extract and remove thinking tags from the summary
285				m.thinkingContent = thinkingTagRegex.FindString(m.runtime.Summary)
286				m.cleanSummary = thinkingTagRegex.ReplaceAllString(m.runtime.Summary, "")
287				// Clean up extra whitespace left behind after removing thinking tags
288				m.cleanSummary = strings.TrimSpace(m.cleanSummary)
289				// Resize viewport to fit available space
290				m.viewport.Width = m.width - padding*2
291				m.viewport.Height = m.height - 15 // Leave space for header/footer
292				// Wrap content to viewport width before setting
293				contentWidth := m.viewport.Width
294				// Default: show summary without thinking tags
295				summaryContent := "📝 Summary\n\n" + wrapText(m.cleanSummary, contentWidth)
296				m.viewport.SetContent(summaryContent)
297			}
298		}
299		// Capture the final elapsed time.
300		m.elapsed = time.Since(m.startTime)
301		// We are done, so we return a command to quit the program automatically.
302		// The user can see the final summary. If we want the user to quit manually,
303		// just return 'm, nil'.
304		return m, nil
305	case ErrorMsg:
306		m.errorMsg = msg.Error
307		m.status = "Error"
308		return m, tea.Quit
309	}
310
311	return m, nil
312}
313
314func (m *Model) View() string {
315	if m.width == 0 {
316		return "Initialising..."
317	}
318
319	// If we're showing the summary with scrolling, use a different layout
320	if m.showSummary {
321		return m.viewSummary()
322	}
323
324	var sb strings.Builder
325
326	// Title
327	sb.WriteString(titleStyle.Render("🤖 LLM Aggregator"))
328	sb.WriteString("\n\n")
329
330	// If done, show 100% progress.
331	var progressPercent float64
332	switch {
333	case m.done && m.errorMsg == "":
334		progressPercent = 1.0
335	case m.waiting:
336		// Fake progress: 85% base + up to 15% over 60 seconds
337		elapsed := time.Since(m.waitingStart).Seconds()
338		waitFraction := min(elapsed/60.0, 1.0)
339		progressPercent = 0.85 + (waitFraction * 0.15)
340	case m.tokenCount > 0:
341		// Use token-based progress once articles are ready
342		// Tokens sent to LLM as a fraction of model's max token window (8k context assumed)
343		progressPercent = float64(m.tokenUsed) / float64(m.tokenCount)
344		if progressPercent > 1.0 {
345			progressPercent = 1.0
346		}
347	default:
348		progressPercent = float64(m.currentStep) / float64(m.totalSteps)
349	}
350
351	progressView := m.progress.ViewAs(progressPercent)
352	sb.WriteString(progressBarStyle.Render(progressView))
353	sb.WriteString("\n\n")
354
355	// Status with spinner
356	sb.WriteString(statusStyle.Render("Status: "))
357
358	// Replace spinner with a checkmark when done.
359	switch {
360	case m.done && m.errorMsg == "":
361		sb.WriteString(lipgloss.NewStyle().Foreground(colorSuccess).Render("✓"))
362	case m.done && m.errorMsg != "":
363		sb.WriteString(lipgloss.NewStyle().Foreground(colorError).Render("✗"))
364	default:
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// StepMsg represents a progress update message.
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}