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}