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
404// viewSummary renders the summary view with scrolling support.
405func (m *Model) viewSummary() string {
406 var sb strings.Builder
407
408 // Header
409 sb.WriteString(titleStyle.Render("🤖 LLM Aggregator"))
410 sb.WriteString("\n\n")
411
412 // Show completion status
413 sb.WriteString(statusStyle.Render("✓ Complete"))
414 sb.WriteString(" ")
415 sb.WriteString(m.subStatus)
416 sb.WriteString("\n\n")
417
418 // Elapsed time and article stats
419 sb.WriteString(infoStyle.Render(fmt.Sprintf("Articles: %d | Processed: %d | Elapsed: %v",
420 m.articlesCount, m.processedCount, m.elapsed.Round(time.Second))))
421 sb.WriteString("\n\n")
422
423 // Scroll progress indicator
424 scrollPercent := int(m.viewport.ScrollPercent() * 100)
425 if m.viewport.TotalLineCount() > m.viewport.VisibleLineCount() {
426 sb.WriteString(infoStyle.Render(fmt.Sprintf("Scroll: %d%% (%d/%d lines)",
427 scrollPercent, m.viewport.TotalLineCount()-m.viewport.VisibleLineCount()-m.viewport.YOffset,
428 m.viewport.TotalLineCount())))
429 sb.WriteString("\n\n")
430 }
431
432 // Viewport (scrollable summary)
433 sb.WriteString(m.viewport.View())
434 sb.WriteString("\n")
435
436 // Thinking toggle indicator (separate element above keybinds)
437 if m.thinkingContent != "" {
438 if m.showThinking {
439 sb.WriteString(thinkingOnStyle.Render("[💭 Thinking: ON]"))
440 } else {
441 sb.WriteString(thinkingOffStyle.Render("[💭 Thinking: OFF]"))
442 }
443 sb.WriteString("\n")
444 }
445
446 // Infobar - stylised keybinds
447 sb.WriteString(infobarKeybindStyle.Render("↑/↓"))
448 sb.WriteString(infobarSeparatorStyle.Render(" or "))
449 sb.WriteString(infobarKeybindStyle.Render("j/k"))
450 sb.WriteString(infobarActionStyle.Render(" (scroll)"))
451 sb.WriteString(infobarSeparatorStyle.Render(" | "))
452 sb.WriteString(infobarKeybindStyle.Render("Space/B"))
453 sb.WriteString(infobarActionStyle.Render(" (page)"))
454 sb.WriteString(infobarSeparatorStyle.Render(" | "))
455 sb.WriteString(infobarKeybindStyle.Render("g/G"))
456 sb.WriteString(infobarActionStyle.Render(" (start/end)"))
457 sb.WriteString(infobarSeparatorStyle.Render(" | "))
458 sb.WriteString(infobarKeybindStyle.Render("t"))
459 sb.WriteString(infobarActionStyle.Render(" (toggle thinking)"))
460 sb.WriteString(infobarSeparatorStyle.Render(" | "))
461 sb.WriteString(infobarKeybindStyle.Render("q"))
462 sb.WriteString(infobarActionStyle.Render(" (quit)"))
463
464 return sb.String()
465}
466
467// wrapText wraps text to a given width using lipgloss.
468func wrapText(text string, width int) string {
469 // Use glamour to render markdown with proper styling
470 r := getGlamourRenderer(width)
471 out, err := r.Render(text)
472 if err != nil {
473 // Fallback to plain text wrapping if glamour fails
474 style := lipgloss.NewStyle().Width(width)
475 return style.Render(text)
476 }
477 return out
478}
479
480// getGlamourRenderer returns a cached glamour renderer configured for the given width.
481func getGlamourRenderer(width int) *glamour.TermRenderer {
482 // Create a new renderer with the specified width
483 r, err := glamour.NewTermRenderer(
484 glamour.WithWordWrap(width),
485 glamour.WithStandardStyle("dark"),
486 )
487 if err != nil {
488 // Fallback to default renderer if creation fails
489 r, _ = glamour.NewTermRenderer(glamour.WithWordWrap(width))
490 }
491 return r
492}
493
494// wrapTextGray wraps text to a given width using lipgloss with gray colour.
495func wrapTextGray(text string, width int) string {
496 style := lipgloss.NewStyle().
497 Width(width).
498 Foreground(colorSubtle)
499 return style.Render(text)
500}
501
502// stripThinkingTags removes the <think> and </think> XML tags from the thinking content.
503func stripThinkingTags(content string) string {
504 // Remove <think> tag
505 reOpen := regexp.MustCompile(`<think>\s*`)
506 content = reOpen.ReplaceAllString(content, "")
507
508 // Remove </think> tag
509 reClose := regexp.MustCompile(`\s*</think>`)
510 content = reClose.ReplaceAllString(content, "")
511
512 return content
513}