all repos — llm_aggregator @ 4b4b37a236f75a04bd530d0a4ffaa1fb26b89a27

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