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
33 titleStyle = lipgloss.NewStyle().
34 MarginLeft(2).
35 MarginRight(2).
36 Padding(0, 1).
37 Italic(true).
38 Foreground(lipgloss.Color("15"))
39
40 infoStyle = lipgloss.NewStyle().
41 Foreground(colorSubtle).
42 Italic(true)
43
44 statusStyle = lipgloss.NewStyle().
45 Foreground(colorHighlight).
46 Bold(true)
47
48 progressBarStyle = lipgloss.NewStyle().
49 Foreground(colorSuccess)
50
51 stepNames = []string{
52 "Initialising",
53 "Aggregating feeds",
54 "Processing articles",
55 "Connecting to LLM",
56 "Getting summary",
57 "Formatting output",
58 "Complete",
59 }
60
61 // Infobar keybinds - stylised for easy modification
62 infobarKeybindStyle = lipgloss.NewStyle().
63 Foreground(colorSubtle).
64 Bold(true)
65
66 infobarSeparatorStyle = lipgloss.NewStyle().
67 Foreground(colorSubtle).
68 Italic(false)
69
70 infobarActionStyle = lipgloss.NewStyle().
71 Foreground(colorSubtle).
72 Italic(true)
73
74 // Thinking toggle indicator (separate element above keybinds)
75 thinkingOnStyle = lipgloss.NewStyle().
76 Foreground(colorHighlight).
77 Bold(true)
78
79 thinkingOffStyle = lipgloss.NewStyle().
80 Foreground(colorSubtle).
81 Italic(true)
82)
83
84type StageMsg string
85type SubStageMsg string
86type ArticleCountMsg struct {
87 Total, Processed int
88}
89
90type TokenEstimateMsg struct {
91 Total, Used int
92}
93
94type WaitingMsg struct{}
95
96type processingDoneMsg struct {
97 err error
98}
99
100type progressMsg float64
101
102type Model struct {
103 progress progress.Model
104 spinner spinner.Model
105 viewport viewport.Model
106 runtime *runtime.Runtime
107 currentStep int
108 totalSteps int
109 status string
110 subStatus string
111 startTime time.Time
112 elapsed time.Duration
113 width int
114 height int
115 done bool
116 errorMsg string
117 articlesCount int
118 processedCount int
119 tokenCount int
120 tokenUsed int
121 waiting bool
122 waitingStart time.Time
123 showSummary bool
124 showThinking bool // Toggle state for thinking tags visibility
125 thinkingContent string // Extracted thinking content for toggle
126 cleanSummary string // Summary with thinking tags removed
127}
128
129func New(rt *runtime.Runtime) *Model {
130 prog := progress.New(
131 progress.WithSolidFill("colorSuccess"),
132 progress.WithWidth(40),
133 progress.WithoutPercentage(),
134 )
135
136 sp := spinner.New()
137 sp.Spinner = spinner.Dot
138 sp.Style = lipgloss.NewStyle().Foreground(colorHighlight)
139
140 return &Model{
141 progress: prog,
142 spinner: sp,
143 runtime: rt,
144 currentStep: 0,
145 totalSteps: len(stepNames) - 1, // Last step is "Complete"
146 status: "Initialising...",
147 subStatus: "Loading configuration",
148 startTime: time.Now(),
149 showSummary: false,
150 showThinking: false, // Default: thinking tags are hidden
151 }
152}
153
154func (m *Model) Init() tea.Cmd {
155 // Initialise the viewport with default size (will be resized on WindowSizeMsg)
156 m.viewport = viewport.New(80, 20)
157 m.viewport.MouseWheelEnabled = true
158
159 return tea.Batch(m.spinner.Tick, m.startProcessing)
160}
161
162// This is now the standard bubbletea command pattern.
163// The bubbletea runtime will execute this function in a goroutine.
164// We no longer need to manage the goroutine or use program.Send().
165func (m *Model) startProcessing() tea.Msg {
166 err := m.runtime.Execute(context.Background())
167 return processingDoneMsg{err: err}
168}
169
170func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
171 switch msg := msg.(type) {
172 case tea.KeyMsg:
173 // Let the viewport handle navigation keys if showing summary
174 if m.showSummary {
175 var cmd tea.Cmd
176 m.viewport, cmd = m.viewport.Update(msg)
177 if cmd != nil {
178 return m, cmd
179 }
180 // If viewport handled a key, we're done
181 switch msg.String() {
182 case "j", "down", "k", "up", "g", "G", " ", "b":
183 return m, nil
184 }
185 }
186 switch msg.String() {
187 case "q", "ctrl+c":
188 return m, tea.Quit
189 case "t":
190 // Toggle thinking tags visibility
191 if m.thinkingContent != "" {
192 m.showThinking = !m.showThinking
193 contentWidth := m.viewport.Width
194 var summaryContent string
195 if m.showThinking {
196 // Show thinking at the head of the output (without XML tags)
197 thinkingText := stripThinkingTags(m.thinkingContent)
198 summaryContent = wrapTextGray("💭 Thinking\n\n"+thinkingText, contentWidth) + "\n\n📝 Summary\n\n" + wrapText(m.cleanSummary, contentWidth)
199 } else {
200 // Hide thinking tags (default)
201 summaryContent = "📝 Summary\n\n" + wrapText(m.cleanSummary, contentWidth)
202 }
203 m.viewport.SetContent(summaryContent)
204 // Reset scroll position to top when toggling
205 m.viewport.GotoTop()
206 return m, nil
207 }
208 }
209 case tea.WindowSizeMsg:
210 m.width = msg.Width
211 m.height = msg.Height
212 m.progress.Width = min(msg.Width-padding*2-4, maxWidth)
213 // Resize viewport for summary display
214 if m.showSummary {
215 m.viewport.Width = msg.Width - padding*2
216 m.viewport.Height = msg.Height - 10 // Leave space for header/footer
217 // Re-wrap content to new width when resizing
218 contentWidth := m.viewport.Width
219 var summaryContent string
220 if m.showThinking && m.thinkingContent != "" {
221 thinkingText := stripThinkingTags(m.thinkingContent)
222 summaryContent = wrapTextGray("💭 Thinking\n\n"+thinkingText, contentWidth) + "\n\n📝 Summary\n\n" + wrapText(m.cleanSummary, contentWidth)
223 } else {
224 summaryContent = "📝 Summary\n\n" + wrapText(m.cleanSummary, contentWidth)
225 }
226 m.viewport.SetContent(summaryContent)
227 }
228 case progressMsg:
229 // This message type seems unused, but leaving it in case.
230 progress := float64(msg)
231 if progress >= 1.0 {
232 m.done = true
233 return m, nil
234 }
235 cmd := m.progress.SetPercent(float64(progress))
236 return m, cmd
237 case spinner.TickMsg:
238 // MODIFIED: Stop updating the spinner once we're done.
239 if m.done {
240 return m, nil
241 }
242 var cmd tea.Cmd
243 m.spinner, cmd = m.spinner.Update(msg)
244 return m, cmd
245 case StageMsg:
246 stageName := string(msg)
247 for i, name := range stepNames {
248 if name == stageName {
249 m.currentStep = i
250 m.status = name
251 m.subStatus = "" // Clear sub-status when stage changes
252 break
253 }
254 }
255
256 case SubStageMsg:
257 m.subStatus = string(msg)
258
259 case ArticleCountMsg:
260 m.articlesCount = msg.Total
261 m.processedCount = msg.Processed
262
263 case TokenEstimateMsg:
264 m.tokenCount = msg.Total
265 m.tokenUsed = msg.Used
266
267 case WaitingMsg:
268 m.waiting = true
269 m.waitingStart = time.Now()
270
271 case processingDoneMsg:
272 m.waiting = false
273 if msg.err != nil {
274 m.errorMsg = msg.err.Error()
275 m.done = true // Also mark as done on error
276 } else {
277 m.done = true
278 m.currentStep = m.totalSteps // Go to "Complete" step
279 m.status = stepNames[m.totalSteps]
280 m.subStatus = "Summary generated successfully!"
281 // Enable summary viewport if there's content to display
282 if m.runtime.Summary != "" {
283 m.showSummary = true
284 // Extract and remove thinking tags from the summary
285 m.thinkingContent = thinkingTagRegex.FindString(m.runtime.Summary)
286 m.cleanSummary = thinkingTagRegex.ReplaceAllString(m.runtime.Summary, "")
287 // Clean up extra whitespace left behind after removing thinking tags
288 m.cleanSummary = strings.TrimSpace(m.cleanSummary)
289 // Resize viewport to fit available space
290 m.viewport.Width = m.width - padding*2
291 m.viewport.Height = m.height - 15 // Leave space for header/footer
292 // Wrap content to viewport width before setting
293 contentWidth := m.viewport.Width
294 // Default: show summary without thinking tags
295 summaryContent := "📝 Summary\n\n" + wrapText(m.cleanSummary, contentWidth)
296 m.viewport.SetContent(summaryContent)
297 }
298 }
299 // Capture the final elapsed time.
300 m.elapsed = time.Since(m.startTime)
301 // We are done, so we return a command to quit the program automatically.
302 // The user can see the final summary. If we want the user to quit manually,
303 // just return 'm, nil'.
304 return m, nil
305 case ErrorMsg:
306 m.errorMsg = msg.Error
307 m.status = "Error"
308 return m, tea.Quit
309 }
310
311 return m, nil
312}
313
314func (m *Model) View() string {
315 if m.width == 0 {
316 return "Initialising..."
317 }
318
319 // If we're showing the summary with scrolling, use a different layout
320 if m.showSummary {
321 return m.viewSummary()
322 }
323
324 var sb strings.Builder
325
326 // Title
327 sb.WriteString(titleStyle.Render("🤖 LLM Aggregator"))
328 sb.WriteString("\n\n")
329
330 // If done, show 100% progress.
331 var progressPercent float64
332 switch {
333 case m.done && m.errorMsg == "":
334 progressPercent = 1.0
335 case m.waiting:
336 // Fake progress: 85% base + up to 15% over 60 seconds
337 elapsed := time.Since(m.waitingStart).Seconds()
338 waitFraction := min(elapsed/60.0, 1.0)
339 progressPercent = 0.85 + (waitFraction * 0.15)
340 case m.tokenCount > 0:
341 // Use token-based progress once articles are ready
342 // Tokens sent to LLM as a fraction of model's max token window (8k context assumed)
343 progressPercent = float64(m.tokenUsed) / float64(m.tokenCount)
344 if progressPercent > 1.0 {
345 progressPercent = 1.0
346 }
347 default:
348 progressPercent = float64(m.currentStep) / float64(m.totalSteps)
349 }
350
351 progressView := m.progress.ViewAs(progressPercent)
352 sb.WriteString(progressBarStyle.Render(progressView))
353 sb.WriteString("\n\n")
354
355 // Status with spinner
356 sb.WriteString(statusStyle.Render("Status: "))
357
358 // Replace spinner with a checkmark when done.
359 switch {
360 case m.done && m.errorMsg == "":
361 sb.WriteString(lipgloss.NewStyle().Foreground(colorSuccess).Render("✓"))
362 case m.done && m.errorMsg != "":
363 sb.WriteString(lipgloss.NewStyle().Foreground(colorError).Render("✗"))
364 default:
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// StepMsg represents a progress update message.
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}