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}