all repos — llm_aggregator @ 262b91134ac16b77d435b706d54680bf6cff5de6

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	"strings"
  7	"time"
  8
  9	"github.com/charmbracelet/bubbles/progress"
 10	"github.com/charmbracelet/bubbles/spinner"
 11	"github.com/charmbracelet/bubbles/viewport"
 12	tea "github.com/charmbracelet/bubbletea"
 13	"github.com/charmbracelet/lipgloss"
 14	"llm_aggregator/internal/runtime"
 15)
 16
 17const (
 18	padding  = 2
 19	maxWidth = 80
 20)
 21
 22var (
 23	colorSubtle        = lipgloss.Color("7")   // Gray (dim text)
 24	colorHighlight     = lipgloss.Color("13")  // Magenta (status)
 25	colorSuccess       = lipgloss.Color("2")   // Green (completion)
 26	colorError         = lipgloss.Color("1")   // Red (errors)
 27	colorGradientStart = lipgloss.Color("205") // Pink
 28	colorGradientEnd   = lipgloss.Color("226") // Yellow
 29
 30	titleStyle = lipgloss.NewStyle().
 31			MarginLeft(2).
 32			MarginRight(2).
 33			Padding(0, 1).
 34			Italic(true).
 35			Foreground(lipgloss.Color("15"))
 36
 37	infoStyle = lipgloss.NewStyle().
 38			Foreground(colorSubtle).
 39			Italic(true)
 40
 41	statusStyle = lipgloss.NewStyle().
 42			Foreground(colorHighlight).
 43			Bold(true)
 44
 45	progressBarStyle = lipgloss.NewStyle().
 46				Foreground(colorSuccess)
 47
 48	stepNames = []string{
 49		"Initialising",
 50		"Aggregating feeds",
 51		"Processing articles",
 52		"Connecting to LLM",
 53		"Getting summary",
 54		"Formatting output",
 55		"Complete",
 56	}
 57)
 58
 59type StageMsg string
 60type SubStageMsg string
 61type ArticleCountMsg struct {
 62	Total, Processed int
 63}
 64
 65type processingDoneMsg struct {
 66	err error
 67}
 68
 69type progressMsg float64
 70
 71type Model struct {
 72	progress       progress.Model
 73	spinner        spinner.Model
 74	viewport       viewport.Model
 75	runtime        *runtime.Runtime
 76	currentStep    int
 77	totalSteps     int
 78	status         string
 79	subStatus      string
 80	startTime      time.Time
 81	elapsed        time.Duration
 82	width          int
 83	height         int
 84	done           bool
 85	errorMsg       string
 86	articlesCount  int
 87	processedCount int
 88	showSummary    bool
 89}
 90
 91func New(rt *runtime.Runtime) *Model {
 92	prog := progress.New(
 93		progress.WithSolidFill("colorSuccess"),
 94		progress.WithWidth(40),
 95		progress.WithoutPercentage(),
 96	)
 97
 98	sp := spinner.New()
 99	sp.Spinner = spinner.Dot
100	sp.Style = lipgloss.NewStyle().Foreground(colorHighlight)
101
102	return &Model{
103		progress:    prog,
104		spinner:     sp,
105		runtime:     rt,
106		currentStep: 0,
107		totalSteps:  len(stepNames) - 1, // Last step is "Complete"
108		status:      "Initialising...",
109		subStatus:   "Loading configuration",
110		startTime:   time.Now(),
111		showSummary: false,
112	}
113}
114
115func (m *Model) Init() tea.Cmd {
116	// Initialise the viewport with default size (will be resized on WindowSizeMsg)
117	m.viewport = viewport.New(80, 20)
118	m.viewport.MouseWheelEnabled = true
119
120	return tea.Batch(m.spinner.Tick, m.startProcessing)
121}
122
123// This is now the standard bubbletea command pattern.
124// The bubbletea runtime will execute this function in a goroutine.
125// We no longer need to manage the goroutine or use program.Send().
126func (m *Model) startProcessing() tea.Msg {
127	err := m.runtime.Execute(context.Background())
128	return processingDoneMsg{err: err}
129}
130
131func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
132	switch msg := msg.(type) {
133	case tea.KeyMsg:
134		// Let the viewport handle navigation keys if showing summary
135		if m.showSummary {
136			var cmd tea.Cmd
137			m.viewport, cmd = m.viewport.Update(msg)
138			if cmd != nil {
139				return m, cmd
140			}
141			// If viewport handled a key, we're done
142			switch msg.String() {
143			case "j", "down", "k", "up", "g", "G", " ", "b":
144				return m, nil
145			}
146		}
147		switch msg.String() {
148		case "q", "ctrl+c":
149			return m, tea.Quit
150		}
151	case tea.WindowSizeMsg:
152		m.width = msg.Width
153		m.height = msg.Height
154		m.progress.Width = min(msg.Width-padding*2-4, maxWidth)
155		// Resize viewport for summary display
156		if m.showSummary {
157			m.viewport.Width = msg.Width - padding*2
158			m.viewport.Height = msg.Height - 10 // Leave space for header/footer
159		}
160	case progressMsg:
161		// This message type seems unused, but leaving it in case.
162		progress := float64(msg)
163		if progress >= 1.0 {
164			m.done = true
165			return m, nil
166		}
167		cmd := m.progress.SetPercent(float64(progress))
168		return m, cmd
169	case spinner.TickMsg:
170		// MODIFIED: Stop updating the spinner once we're done.
171		if m.done {
172			return m, nil
173		}
174		var cmd tea.Cmd
175		m.spinner, cmd = m.spinner.Update(msg)
176		return m, cmd
177	case StageMsg:
178		stageName := string(msg)
179		for i, name := range stepNames {
180			if name == stageName {
181				m.currentStep = i
182				m.status = name
183				m.subStatus = "" // Clear sub-status when stage changes
184				break
185			}
186		}
187
188	case SubStageMsg:
189		m.subStatus = string(msg)
190
191	case ArticleCountMsg:
192		m.articlesCount = msg.Total
193		m.processedCount = msg.Processed
194
195	case processingDoneMsg:
196		if msg.err != nil {
197			m.errorMsg = msg.err.Error()
198			m.done = true // Also mark as done on error
199		} else {
200			m.done = true
201			m.currentStep = m.totalSteps // Go to "Complete" step
202			m.status = stepNames[m.totalSteps]
203			m.subStatus = "Summary generated successfully!"
204			// Enable summary viewport if there's content to display
205			if m.runtime.Summary != "" {
206				m.showSummary = true
207				// Resize viewport to fit available space
208				m.viewport.Width = m.width - padding*2
209				m.viewport.Height = m.height - 15 // Leave space for header/footer
210				// Wrap content to viewport width before setting
211				contentWidth := m.viewport.Width
212				summaryContent := "📝 Summary\n\n" + wrapText(m.runtime.Summary, contentWidth)
213				m.viewport.SetContent(summaryContent)
214			}
215		}
216		// Capture the final elapsed time.
217		m.elapsed = time.Since(m.startTime)
218		// We are done, so we return a command to quit the program automatically.
219		// The user can see the final summary. If we want the user to quit manually,
220		// just return 'm, nil'.
221		return m, nil
222	case ErrorMsg:
223		m.errorMsg = msg.Error
224		m.status = "Error"
225		return m, tea.Quit
226	}
227
228	return m, nil
229}
230
231func (m *Model) View() string {
232	if m.width == 0 {
233		return "Initialising..."
234	}
235
236	// If we're showing the summary with scrolling, use a different layout
237	if m.showSummary {
238		return m.viewSummary()
239	}
240
241	var sb strings.Builder
242
243	// Title
244	sb.WriteString(titleStyle.Render("🤖 LLM Aggregator"))
245	sb.WriteString("\n\n")
246
247	// If done, show 100% progress.
248	var progressPercent float64
249	if m.done && m.errorMsg == "" {
250		progressPercent = 1.0
251	} else if m.articlesCount > 0 && m.currentStep >= 4 {
252		progressPercent = float64(m.processedCount) / float64(m.articlesCount)
253	} else {
254		progressPercent = float64(m.currentStep) / float64(m.totalSteps)
255	}
256
257	progressView := m.progress.ViewAs(progressPercent)
258	sb.WriteString(progressBarStyle.Render(progressView))
259	sb.WriteString("\n\n")
260
261	// Status with spinner
262	sb.WriteString(statusStyle.Render("Status: "))
263
264	// Replace spinner with a checkmark when done.
265	if m.done && m.errorMsg == "" {
266		sb.WriteString(lipgloss.NewStyle().Foreground(colorSuccess).Render("✓"))
267	} else if m.done && m.errorMsg != "" {
268		sb.WriteString(lipgloss.NewStyle().Foreground(colorError).Render("✗"))
269	} else {
270		sb.WriteString(m.spinner.View())
271	}
272
273	sb.WriteString(" ")
274	sb.WriteString(m.status)
275	sb.WriteString("\n")
276
277	// Sub-status
278	if m.subStatus != "" {
279		sb.WriteString(infoStyle.Render("  → "))
280		sb.WriteString(m.subStatus)
281		sb.WriteString("\n")
282	}
283
284	// Statistics
285	if m.articlesCount > 0 {
286		sb.WriteString(infoStyle.Render(fmt.Sprintf("Articles: %d", m.articlesCount)))
287		sb.WriteString("  ")
288	}
289	if m.processedCount > 0 {
290		sb.WriteString(infoStyle.Render(fmt.Sprintf("Processed: %d", m.processedCount)))
291		sb.WriteString("\n")
292	}
293
294	// Elapsed time
295	// MODIFIED: Use the stored elapsed time if done, otherwise calculate it live.
296	elapsed := m.elapsed
297	if !m.done {
298		elapsed = time.Since(m.startTime)
299	}
300	sb.WriteString(infoStyle.Render(fmt.Sprintf("Elapsed: %v", elapsed.Round(time.Second))))
301	sb.WriteString("\n\n")
302
303	// Error message (if any)
304	if m.errorMsg != "" {
305		errorStyle := lipgloss.NewStyle().
306			Foreground(colorError).
307			Bold(true)
308		sb.WriteString(errorStyle.Render("Error: "))
309		sb.WriteString(m.errorMsg)
310		sb.WriteString("\n\n")
311	}
312
313	// Simplified help text logic
314	if m.done {
315		// The summary is not part of the TUI, it's printed to stdout *after* the TUI exits.
316		// The TUI's job is just to show the progress.
317		// The `tea.Quit` in the `processingDoneMsg` case will cause the program to exit
318		// and the rest of the `main` function to proceed.
319	} else {
320		sb.WriteString(infoStyle.Render("Press 'q' or Ctrl+C to quit"))
321	}
322
323	return sb.String()
324}
325
326// Messages for updating the TUI
327type StepMsg struct {
328	Step           int
329	Status         string
330	ArticlesCount  int
331	ProcessedCount int
332}
333
334type ErrorMsg struct {
335	Error string
336}
337
338func Step(step int, status string) tea.Cmd {
339	return func() tea.Msg {
340		return StepMsg{Step: step, Status: status}
341	}
342}
343
344func StepWithCounts(step int, status string, articles, processed int) tea.Cmd {
345	return func() tea.Msg {
346		return StepMsg{
347			Step:           step,
348			Status:         status,
349			ArticlesCount:  articles,
350			ProcessedCount: processed,
351		}
352	}
353}
354
355func Error(err string) tea.Cmd {
356	return func() tea.Msg {
357		return ErrorMsg{Error: err}
358	}
359}
360
361// viewSummary renders the summary view with scrolling support.
362func (m *Model) viewSummary() string {
363	var sb strings.Builder
364
365	// Header
366	sb.WriteString(titleStyle.Render("🤖 LLM Aggregator"))
367	sb.WriteString("\n\n")
368
369	// Show completion status
370	sb.WriteString(statusStyle.Render("✓ Complete"))
371	sb.WriteString(" ")
372	sb.WriteString(m.subStatus)
373	sb.WriteString("\n\n")
374
375	// Elapsed time and article stats
376	sb.WriteString(infoStyle.Render(fmt.Sprintf("Articles: %d | Processed: %d | Elapsed: %v",
377		m.articlesCount, m.processedCount, m.elapsed.Round(time.Second))))
378	sb.WriteString("\n\n")
379
380	// Scroll progress indicator
381	scrollPercent := int(m.viewport.ScrollPercent() * 100)
382	if m.viewport.TotalLineCount() > m.viewport.VisibleLineCount() {
383		sb.WriteString(infoStyle.Render(fmt.Sprintf("Scroll: %d%% (%d/%d lines)",
384			scrollPercent, m.viewport.TotalLineCount()-m.viewport.VisibleLineCount()-m.viewport.YOffset,
385			m.viewport.TotalLineCount())))
386		sb.WriteString("\n\n")
387	}
388
389	// Viewport (scrollable summary)
390	sb.WriteString(m.viewport.View())
391	sb.WriteString("\n")
392
393	// Navigation help
394	sb.WriteString(infoStyle.Render("Navigate: ↑/↓ or j/k (scroll) | Space/B (page) | g/G (start/end) | q (quit)"))
395
396	return sb.String()
397}
398
399// wrapText wraps text to a given width using lipgloss.
400func wrapText(text string, width int) string {
401	style := lipgloss.NewStyle().Width(width)
402	return style.Render(text)
403}