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