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