all repos — llm_aggregator @ 6e3abd0665fe86906baf2db7652595d393f2ffc3

A CLI tool to aggregate RSS feeds and summarise them with LLMs

internal/tui/model.go (view raw)

  1package tui
  2
  3import (
  4	"fmt"
  5	"strings"
  6	"time"
  7
  8	"github.com/charmbracelet/bubbles/progress"
  9	tea "github.com/charmbracelet/bubbletea"
 10	"github.com/charmbracelet/lipgloss"
 11)
 12
 13const (
 14	padding  = 2
 15	maxWidth = 80
 16)
 17
 18var (
 19	subtle    = lipgloss.AdaptiveColor{Light: "#D9DCCF", Dark: "#383838"}
 20	highlight = lipgloss.AdaptiveColor{Light: "#874BFD", Dark: "#7D56F4"}
 21	special   = lipgloss.AdaptiveColor{Light: "#43BF6D", Dark: "#73F59F"}
 22
 23	titleStyle = lipgloss.NewStyle().
 24			MarginLeft(2).
 25			MarginRight(2).
 26			Padding(0, 1).
 27			Italic(true).
 28			Foreground(lipgloss.Color("#FFF7DB")).
 29			SetString("LLM Aggregator")
 30
 31	infoStyle = lipgloss.NewStyle().
 32			Foreground(subtle).
 33			Italic(true)
 34
 35	statusStyle = lipgloss.NewStyle().
 36			Foreground(highlight).
 37			Bold(true)
 38
 39	progressBarStyle = lipgloss.NewStyle().
 40				Foreground(special)
 41
 42	stepNames = []string{
 43		"Initialising",
 44		"Aggregating feeds",
 45		"Processing articles",
 46		"Connecting to DeepSeek",
 47		"Getting summary",
 48		"Formatting output",
 49		"Complete",
 50	}
 51)
 52
 53type progressMsg float64
 54
 55type Model struct {
 56	progress       progress.Model
 57	currentStep    int
 58	totalSteps     int
 59	status         string
 60	subStatus      string
 61	startTime      time.Time
 62	width          int
 63	height         int
 64	done           bool
 65	errorMsg       string
 66	articlesCount  int
 67	processedCount int
 68}
 69
 70func New() *Model {
 71	prog := progress.New(
 72		progress.WithScaledGradient("#FF7CCB", "#FDFF8C"),
 73		progress.WithWidth(40),
 74		progress.WithoutPercentage(),
 75	)
 76
 77	return &Model{
 78		progress:    prog,
 79		currentStep: 0,
 80		totalSteps:  len(stepNames) - 1, // Last step is "Complete"
 81		status:      "Initialising...",
 82		subStatus:   "Loading configuration",
 83		startTime:   time.Now(),
 84	}
 85}
 86
 87func (m *Model) Init() tea.Cmd {
 88	return nil
 89}
 90
 91func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 92	switch msg := msg.(type) {
 93	case tea.KeyMsg:
 94		switch msg.String() {
 95		case "q", "ctrl+c":
 96			return m, tea.Quit
 97		}
 98	case tea.WindowSizeMsg:
 99		m.width = msg.Width
100		m.height = msg.Height
101		m.progress.Width = msg.Width - padding*2 - 4
102		if m.progress.Width > maxWidth {
103			m.progress.Width = maxWidth
104		}
105	case progressMsg:
106		progress := float64(msg)
107		if progress >= 1.0 {
108			m.done = true
109			return m, tea.Quit
110		}
111		cmd := m.progress.SetPercent(float64(progress))
112		return m, cmd
113	case StepMsg:
114		if msg.Step >= 0 && msg.Step < len(stepNames) {
115			m.currentStep = msg.Step
116			m.status = stepNames[msg.Step]
117			if msg.Status != "" {
118				m.subStatus = msg.Status
119			}
120			if msg.ArticlesCount > 0 {
121				m.articlesCount = msg.ArticlesCount
122			}
123			if msg.ProcessedCount > 0 {
124				m.processedCount = msg.ProcessedCount
125			}
126		}
127	case ErrorMsg:
128		m.errorMsg = msg.Error
129		m.status = "Error"
130		return m, tea.Quit
131	}
132
133	return m, nil
134}
135
136func (m *Model) View() string {
137	if m.width == 0 {
138		return "Initialising..."
139	}
140
141	var sb strings.Builder
142
143	// Title
144	sb.WriteString(titleStyle.Render("🤖 LLM RSS Aggregator"))
145	sb.WriteString("\n\n")
146
147	// Progress bar
148	progressPercent := float64(m.currentStep) / float64(m.totalSteps)
149	progressView := m.progress.ViewAs(progressPercent)
150	sb.WriteString(progressBarStyle.Render(progressView))
151	sb.WriteString("\n\n")
152
153	// Status
154	sb.WriteString(statusStyle.Render("Status: "))
155	sb.WriteString(m.status)
156	sb.WriteString("\n")
157
158	// Sub-status
159	if m.subStatus != "" {
160		sb.WriteString(infoStyle.Render("  → "))
161		sb.WriteString(m.subStatus)
162		sb.WriteString("\n")
163	}
164
165	// Statistics
166	if m.articlesCount > 0 {
167		sb.WriteString(infoStyle.Render(fmt.Sprintf("Articles: %d", m.articlesCount)))
168		sb.WriteString("  ")
169	}
170	if m.processedCount > 0 {
171		sb.WriteString(infoStyle.Render(fmt.Sprintf("Processed: %d", m.processedCount)))
172		sb.WriteString("\n")
173	}
174
175	// Elapsed time
176	elapsed := time.Since(m.startTime).Round(time.Second)
177	sb.WriteString(infoStyle.Render(fmt.Sprintf("Elapsed: %v", elapsed)))
178	sb.WriteString("\n\n")
179
180	// Error message (if any)
181	if m.errorMsg != "" {
182		errorStyle := lipgloss.NewStyle().
183			Foreground(lipgloss.Color("#FF0000")).
184			Bold(true)
185		sb.WriteString(errorStyle.Render("Error: "))
186		sb.WriteString(m.errorMsg)
187		sb.WriteString("\n\n")
188	}
189
190	// Help text
191	if !m.done && m.errorMsg == "" {
192		sb.WriteString(infoStyle.Render("Press 'q' or Ctrl+C to quit"))
193	} else if m.done {
194		completeStyle := lipgloss.NewStyle().
195			Foreground(lipgloss.Color("#43BF6D")).
196			Bold(true)
197		sb.WriteString(completeStyle.Render("✓ Processing complete!"))
198		sb.WriteString("\n")
199	}
200
201	return sb.String()
202}
203
204// Messages for updating the TUI
205type StepMsg struct {
206	Step           int
207	Status         string
208	ArticlesCount  int
209	ProcessedCount int
210}
211
212type ErrorMsg struct {
213	Error string
214}
215
216func Step(step int, status string) tea.Cmd {
217	return func() tea.Msg {
218		return StepMsg{Step: step, Status: status}
219	}
220}
221
222func StepWithCounts(step int, status string, articles, processed int) tea.Cmd {
223	return func() tea.Msg {
224		return StepMsg{
225			Step:           step,
226			Status:         status,
227			ArticlesCount:  articles,
228			ProcessedCount: processed,
229		}
230	}
231}
232
233func Error(err string) tea.Cmd {
234	return func() tea.Msg {
235		return ErrorMsg{Error: err}
236	}
237}