internal/tui/model.go (view raw)
1package tui
2
3import (
4 "fmt"
5 "strings"
6 "time"
7
8 "github.com/charmbracelet/bubbles/progress"
9 tea "github.com/charmbracelet/bubbletea"
10 "github.com/charmbracelet/lipgloss"
11)
12
13const (
14 padding = 2
15 maxWidth = 80
16)
17
18var (
19 subtle = lipgloss.AdaptiveColor{Light: "#D9DCCF", Dark: "#383838"}
20 highlight = lipgloss.AdaptiveColor{Light: "#874BFD", Dark: "#7D56F4"}
21 special = lipgloss.AdaptiveColor{Light: "#43BF6D", Dark: "#73F59F"}
22
23 titleStyle = lipgloss.NewStyle().
24 MarginLeft(2).
25 MarginRight(2).
26 Padding(0, 1).
27 Italic(true).
28 Foreground(lipgloss.Color("#FFF7DB")).
29 SetString("LLM Aggregator")
30
31 infoStyle = lipgloss.NewStyle().
32 Foreground(subtle).
33 Italic(true)
34
35 statusStyle = lipgloss.NewStyle().
36 Foreground(highlight).
37 Bold(true)
38
39 progressBarStyle = lipgloss.NewStyle().
40 Foreground(special)
41
42 stepNames = []string{
43 "Initialising",
44 "Aggregating feeds",
45 "Processing articles",
46 "Connecting to DeepSeek",
47 "Getting summary",
48 "Formatting output",
49 "Complete",
50 }
51)
52
53type progressMsg float64
54
55type Model struct {
56 progress progress.Model
57 currentStep int
58 totalSteps int
59 status string
60 subStatus string
61 startTime time.Time
62 width int
63 height int
64 done bool
65 errorMsg string
66 articlesCount int
67 processedCount int
68}
69
70func New() *Model {
71 prog := progress.New(
72 progress.WithScaledGradient("#FF7CCB", "#FDFF8C"),
73 progress.WithWidth(40),
74 progress.WithoutPercentage(),
75 )
76
77 return &Model{
78 progress: prog,
79 currentStep: 0,
80 totalSteps: len(stepNames) - 1, // Last step is "Complete"
81 status: "Initialising...",
82 subStatus: "Loading configuration",
83 startTime: time.Now(),
84 }
85}
86
87func (m *Model) Init() tea.Cmd {
88 return nil
89}
90
91func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
92 switch msg := msg.(type) {
93 case tea.KeyMsg:
94 switch msg.String() {
95 case "q", "ctrl+c":
96 return m, tea.Quit
97 }
98 case tea.WindowSizeMsg:
99 m.width = msg.Width
100 m.height = msg.Height
101 m.progress.Width = msg.Width - padding*2 - 4
102 if m.progress.Width > maxWidth {
103 m.progress.Width = maxWidth
104 }
105 case progressMsg:
106 progress := float64(msg)
107 if progress >= 1.0 {
108 m.done = true
109 return m, tea.Quit
110 }
111 cmd := m.progress.SetPercent(float64(progress))
112 return m, cmd
113 case StepMsg:
114 if msg.Step >= 0 && msg.Step < len(stepNames) {
115 m.currentStep = msg.Step
116 m.status = stepNames[msg.Step]
117 if msg.Status != "" {
118 m.subStatus = msg.Status
119 }
120 if msg.ArticlesCount > 0 {
121 m.articlesCount = msg.ArticlesCount
122 }
123 if msg.ProcessedCount > 0 {
124 m.processedCount = msg.ProcessedCount
125 }
126 }
127 case ErrorMsg:
128 m.errorMsg = msg.Error
129 m.status = "Error"
130 return m, tea.Quit
131 }
132
133 return m, nil
134}
135
136func (m *Model) View() string {
137 if m.width == 0 {
138 return "Initialising..."
139 }
140
141 var sb strings.Builder
142
143 // Title
144 sb.WriteString(titleStyle.Render("🤖 LLM RSS Aggregator"))
145 sb.WriteString("\n\n")
146
147 // Progress bar
148 progressPercent := float64(m.currentStep) / float64(m.totalSteps)
149 progressView := m.progress.ViewAs(progressPercent)
150 sb.WriteString(progressBarStyle.Render(progressView))
151 sb.WriteString("\n\n")
152
153 // Status
154 sb.WriteString(statusStyle.Render("Status: "))
155 sb.WriteString(m.status)
156 sb.WriteString("\n")
157
158 // Sub-status
159 if m.subStatus != "" {
160 sb.WriteString(infoStyle.Render(" → "))
161 sb.WriteString(m.subStatus)
162 sb.WriteString("\n")
163 }
164
165 // Statistics
166 if m.articlesCount > 0 {
167 sb.WriteString(infoStyle.Render(fmt.Sprintf("Articles: %d", m.articlesCount)))
168 sb.WriteString(" ")
169 }
170 if m.processedCount > 0 {
171 sb.WriteString(infoStyle.Render(fmt.Sprintf("Processed: %d", m.processedCount)))
172 sb.WriteString("\n")
173 }
174
175 // Elapsed time
176 elapsed := time.Since(m.startTime).Round(time.Second)
177 sb.WriteString(infoStyle.Render(fmt.Sprintf("Elapsed: %v", elapsed)))
178 sb.WriteString("\n\n")
179
180 // Error message (if any)
181 if m.errorMsg != "" {
182 errorStyle := lipgloss.NewStyle().
183 Foreground(lipgloss.Color("#FF0000")).
184 Bold(true)
185 sb.WriteString(errorStyle.Render("Error: "))
186 sb.WriteString(m.errorMsg)
187 sb.WriteString("\n\n")
188 }
189
190 // Help text
191 if !m.done && m.errorMsg == "" {
192 sb.WriteString(infoStyle.Render("Press 'q' or Ctrl+C to quit"))
193 } else if m.done {
194 completeStyle := lipgloss.NewStyle().
195 Foreground(lipgloss.Color("#43BF6D")).
196 Bold(true)
197 sb.WriteString(completeStyle.Render("✓ Processing complete!"))
198 sb.WriteString("\n")
199 }
200
201 return sb.String()
202}
203
204// Messages for updating the TUI
205type StepMsg struct {
206 Step int
207 Status string
208 ArticlesCount int
209 ProcessedCount int
210}
211
212type ErrorMsg struct {
213 Error string
214}
215
216func Step(step int, status string) tea.Cmd {
217 return func() tea.Msg {
218 return StepMsg{Step: step, Status: status}
219 }
220}
221
222func StepWithCounts(step int, status string, articles, processed int) tea.Cmd {
223 return func() tea.Msg {
224 return StepMsg{
225 Step: step,
226 Status: status,
227 ArticlesCount: articles,
228 ProcessedCount: processed,
229 }
230 }
231}
232
233func Error(err string) tea.Cmd {
234 return func() tea.Msg {
235 return ErrorMsg{Error: err}
236 }
237}