all repos — llm_aggregator @ 7208dd81944bf3bb82070208a69ea5d1d083dd01

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	tea "github.com/charmbracelet/bubbletea"
 12	"github.com/charmbracelet/lipgloss"
 13	"llm_aggregator/internal/runtime"
 14)
 15
 16const (
 17	padding  = 2
 18	maxWidth = 80
 19)
 20
 21var (
 22	subtle    = lipgloss.AdaptiveColor{Light: "#D9DCCF", Dark: "#383838"}
 23	highlight = lipgloss.AdaptiveColor{Light: "#874BFD", Dark: "#7D56F4"}
 24	special   = lipgloss.AdaptiveColor{Light: "#43BF6D", Dark: "#73F59F"}
 25
 26	titleStyle = lipgloss.NewStyle().
 27			MarginLeft(2).
 28			MarginRight(2).
 29			Padding(0, 1).
 30			Italic(true).
 31			Foreground(lipgloss.Color("#FFF7DB"))
 32
 33	infoStyle = lipgloss.NewStyle().
 34			Foreground(subtle).
 35			Italic(true)
 36
 37	statusStyle = lipgloss.NewStyle().
 38			Foreground(highlight).
 39			Bold(true)
 40
 41	progressBarStyle = lipgloss.NewStyle().
 42				Foreground(special)
 43
 44	stepNames = []string{
 45		"Initialising",
 46		"Aggregating feeds",
 47		"Processing articles",
 48		"Connecting to LLM",
 49		"Getting summary",
 50		"Formatting output",
 51		"Complete",
 52	}
 53)
 54
 55type StageMsg string
 56type SubStageMsg string
 57type ArticleCountMsg struct {
 58	Total, Processed int
 59}
 60
 61type processingDoneMsg struct {
 62	err error
 63}
 64
 65type progressMsg float64
 66
 67type Model struct {
 68	progress       progress.Model
 69	spinner        spinner.Model
 70	runtime        *runtime.Runtime
 71	currentStep    int
 72	totalSteps     int
 73	status         string
 74	subStatus      string
 75	startTime      time.Time
 76	elapsed        time.Duration
 77	width          int
 78	height         int
 79	done           bool
 80	errorMsg       string
 81	articlesCount  int
 82	processedCount int
 83}
 84
 85func New(rt *runtime.Runtime) *Model {
 86	prog := progress.New(
 87		progress.WithScaledGradient("#FF7CCB", "#FDFF8C"),
 88		progress.WithWidth(40),
 89		progress.WithoutPercentage(),
 90	)
 91
 92	sp := spinner.New()
 93	sp.Spinner = spinner.Dot
 94	sp.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF7CCB"))
 95
 96	return &Model{
 97		progress:    prog,
 98		spinner:     sp,
 99		runtime:     rt,
100		currentStep: 0,
101		totalSteps:  len(stepNames) - 1, // Last step is "Complete"
102		status:      "Initialising...",
103		subStatus:   "Loading configuration",
104		startTime:   time.Now(),
105	}
106}
107
108func (m *Model) Init() tea.Cmd {
109	return tea.Batch(m.spinner.Tick, m.startProcessing)
110}
111
112// MODIFIED: This is now the standard bubbletea command pattern.
113// The bubbletea runtime will execute this function in a goroutine.
114// We no longer need to manage the goroutine or use program.Send().
115func (m *Model) startProcessing() tea.Msg {
116	err := m.runtime.Execute(context.Background())
117	return processingDoneMsg{err: err}
118}
119
120func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
121	switch msg := msg.(type) {
122	case tea.KeyMsg:
123		switch msg.String() {
124		case "q", "ctrl+c":
125			return m, tea.Quit
126		}
127	case tea.WindowSizeMsg:
128		m.width = msg.Width
129		m.height = msg.Height
130		m.progress.Width = min(msg.Width-padding*2-4, maxWidth)
131	case progressMsg:
132		// This message type seems unused, but leaving it in case.
133		progress := float64(msg)
134		if progress >= 1.0 {
135			m.done = true
136			return m, nil
137		}
138		cmd := m.progress.SetPercent(float64(progress))
139		return m, cmd
140	case spinner.TickMsg:
141		// MODIFIED: Stop updating the spinner once we're done.
142		if m.done {
143			return m, nil
144		}
145		var cmd tea.Cmd
146		m.spinner, cmd = m.spinner.Update(msg)
147		return m, cmd
148	case StageMsg:
149		stageName := string(msg)
150		for i, name := range stepNames {
151			if name == stageName {
152				m.currentStep = i
153				m.status = name
154				m.subStatus = "" // Clear sub-status when stage changes
155				break
156			}
157		}
158
159	case SubStageMsg:
160		m.subStatus = string(msg)
161
162	case ArticleCountMsg:
163		m.articlesCount = msg.Total
164		m.processedCount = msg.Processed
165
166	case processingDoneMsg:
167		if msg.err != nil {
168			m.errorMsg = msg.err.Error()
169			m.done = true // Also mark as done on error
170		} else {
171			m.done = true
172			m.currentStep = m.totalSteps // Go to "Complete" step
173			m.status = stepNames[m.totalSteps]
174			m.subStatus = "Summary generated successfully!"
175		}
176		// MODIFIED: Capture the final elapsed time.
177		m.elapsed = time.Since(m.startTime)
178		// We are done, so we return a command to quit the program automatically.
179		// The user can see the final summary. If we want the user to quit manually,
180		// just return 'm, nil'.
181		return m, nil
182	case ErrorMsg:
183		m.errorMsg = msg.Error
184		m.status = "Error"
185		return m, tea.Quit
186	}
187
188	return m, nil
189}
190
191func (m *Model) View() string {
192	if m.width == 0 {
193		return "Initialising..."
194	}
195
196	var sb strings.Builder
197
198	// Title
199	sb.WriteString(titleStyle.Render("🤖 LLM Aggregator"))
200	sb.WriteString("\n\n")
201
202	// MODIFIED: If done, show 100% progress.
203	var progressPercent float64
204	if m.done && m.errorMsg == "" {
205		progressPercent = 1.0
206	} else if m.articlesCount > 0 && m.currentStep >= 4 {
207		progressPercent = float64(m.processedCount) / float64(m.articlesCount)
208	} else {
209		progressPercent = float64(m.currentStep) / float64(m.totalSteps)
210	}
211
212	progressView := m.progress.ViewAs(progressPercent)
213	sb.WriteString(progressBarStyle.Render(progressView))
214	sb.WriteString("\n\n")
215
216	// Status with spinner
217	sb.WriteString(statusStyle.Render("Status: "))
218
219	// MODIFIED: Replace spinner with a checkmark when done.
220	if m.done && m.errorMsg == "" {
221		sb.WriteString(lipgloss.NewStyle().Foreground(special).Render("✓"))
222	} else if m.done && m.errorMsg != "" {
223		sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#FF0000")).Render("✗"))
224	} else {
225		sb.WriteString(m.spinner.View())
226	}
227
228	sb.WriteString(" ")
229	sb.WriteString(m.status)
230	sb.WriteString("\n")
231
232	// Sub-status
233	if m.subStatus != "" {
234		sb.WriteString(infoStyle.Render("  → "))
235		sb.WriteString(m.subStatus)
236		sb.WriteString("\n")
237	}
238
239	// Statistics
240	if m.articlesCount > 0 {
241		sb.WriteString(infoStyle.Render(fmt.Sprintf("Articles: %d", m.articlesCount)))
242		sb.WriteString("  ")
243	}
244	if m.processedCount > 0 {
245		sb.WriteString(infoStyle.Render(fmt.Sprintf("Processed: %d", m.processedCount)))
246		sb.WriteString("\n")
247	}
248
249	// Elapsed time
250	// MODIFIED: Use the stored elapsed time if done, otherwise calculate it live.
251	elapsed := m.elapsed
252	if !m.done {
253		elapsed = time.Since(m.startTime)
254	}
255	sb.WriteString(infoStyle.Render(fmt.Sprintf("Elapsed: %v", elapsed.Round(time.Second))))
256	sb.WriteString("\n\n")
257
258	// Error message (if any)
259	if m.errorMsg != "" {
260		errorStyle := lipgloss.NewStyle().
261			Foreground(lipgloss.Color("#FF0000")).
262			Bold(true)
263		sb.WriteString(errorStyle.Render("Error: "))
264		sb.WriteString(m.errorMsg)
265		sb.WriteString("\n\n")
266	}
267
268	// MODIFIED: Simplified help text logic
269	if m.done {
270		// The summary is not part of the TUI, it's printed to stdout *after* the TUI exits.
271		// The TUI's job is just to show the progress.
272		// The `tea.Quit` in the `processingDoneMsg` case will cause the program to exit
273		// and the rest of the `main` function to proceed.
274	} else {
275		sb.WriteString(infoStyle.Render("Press 'q' or Ctrl+C to quit"))
276	}
277
278	// If the runtime has a summary and we are done, we can show it here.
279	// However, the typical flow is for the TUI to finish, and then the main
280	// function prints the final output. If we want to show it *in* the TUI,
281	// we would add this block:
282	if m.done && m.runtime.Summary != "" {
283		summaryTitleStyle := lipgloss.NewStyle().Bold(true).Underline(true)
284		sb.WriteString(summaryTitleStyle.Render("Summary"))
285		sb.WriteString("\n")
286
287		// Calculate a safe width for wrapping (terminal width minus some margin)
288		wrapWidth := max(m.width-4,
289			// Sane minimum fallback
290			20)
291
292		// Create a style that enforces word-wrapping
293		summaryStyle := lipgloss.NewStyle().
294			Width(wrapWidth)
295
296		// Render the summary through the style to apply the wrapping
297		sb.WriteString(summaryStyle.Render(m.runtime.Summary))
298		sb.WriteString("\n\n")
299
300		sb.WriteString(infoStyle.Render("Press 'q' to exit."))
301	} else if m.done {
302		sb.WriteString(infoStyle.Render("Press 'q' to exit."))
303	}
304
305	return sb.String()
306}
307
308// Messages for updating the TUI
309type StepMsg struct {
310	Step           int
311	Status         string
312	ArticlesCount  int
313	ProcessedCount int
314}
315
316type ErrorMsg struct {
317	Error string
318}
319
320func Step(step int, status string) tea.Cmd {
321	return func() tea.Msg {
322		return StepMsg{Step: step, Status: status}
323	}
324}
325
326func StepWithCounts(step int, status string, articles, processed int) tea.Cmd {
327	return func() tea.Msg {
328		return StepMsg{
329			Step:           step,
330			Status:         status,
331			ArticlesCount:  articles,
332			ProcessedCount: processed,
333		}
334	}
335}
336
337func Error(err string) tea.Cmd {
338	return func() tea.Msg {
339		return ErrorMsg{Error: err}
340	}
341}