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