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