1package core
2
3import (
4 "fmt"
5 "strings"
6 "time"
7
8 tea "github.com/charmbracelet/bubbletea"
9 "github.com/charmbracelet/lipgloss"
10 "github.com/opencode-ai/opencode/internal/config"
11 "github.com/opencode-ai/opencode/internal/llm/models"
12 "github.com/opencode-ai/opencode/internal/lsp"
13 "github.com/opencode-ai/opencode/internal/lsp/protocol"
14 "github.com/opencode-ai/opencode/internal/pubsub"
15 "github.com/opencode-ai/opencode/internal/session"
16 "github.com/opencode-ai/opencode/internal/tui/components/chat"
17 "github.com/opencode-ai/opencode/internal/tui/styles"
18 "github.com/opencode-ai/opencode/internal/tui/theme"
19 "github.com/opencode-ai/opencode/internal/tui/util"
20)
21
22type StatusCmp interface {
23 tea.Model
24 SetHelpWidgetMsg(string)
25}
26
27type statusCmp struct {
28 info util.InfoMsg
29 width int
30 messageTTL time.Duration
31 lspClients map[string]*lsp.Client
32 session session.Session
33}
34
35// clearMessageCmd is a command that clears status messages after a timeout
36func (m statusCmp) clearMessageCmd(ttl time.Duration) tea.Cmd {
37 return tea.Tick(ttl, func(time.Time) tea.Msg {
38 return util.ClearStatusMsg{}
39 })
40}
41
42func (m statusCmp) Init() tea.Cmd {
43 return nil
44}
45
46func (m statusCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
47 switch msg := msg.(type) {
48 case tea.WindowSizeMsg:
49 m.width = msg.Width
50 return m, nil
51 case chat.SessionSelectedMsg:
52 m.session = msg
53 case chat.SessionClearedMsg:
54 m.session = session.Session{}
55 case pubsub.Event[session.Session]:
56 if msg.Type == pubsub.UpdatedEvent {
57 if m.session.ID == msg.Payload.ID {
58 m.session = msg.Payload
59 }
60 }
61 case util.InfoMsg:
62 m.info = msg
63 ttl := msg.TTL
64 if ttl == 0 {
65 ttl = m.messageTTL
66 }
67 return m, m.clearMessageCmd(ttl)
68 case util.ClearStatusMsg:
69 m.info = util.InfoMsg{}
70 }
71 return m, nil
72}
73
74var helpWidget = ""
75
76// getHelpWidget returns the help widget with current theme colors
77func getHelpWidget(helpText string) string {
78 t := theme.CurrentTheme()
79 if helpText == "" {
80 helpText = "ctrl+? help"
81 }
82
83 return styles.Padded().
84 Background(t.TextMuted()).
85 Foreground(t.BackgroundDarker()).
86 Bold(true).
87 Render(helpText)
88}
89
90func formatTokensAndCost(tokens int64, cost float64) string {
91 // Format tokens in human-readable format (e.g., 110K, 1.2M)
92 var formattedTokens string
93 switch {
94 case tokens >= 1_000_000:
95 formattedTokens = fmt.Sprintf("%.1fM", float64(tokens)/1_000_000)
96 case tokens >= 1_000:
97 formattedTokens = fmt.Sprintf("%.1fK", float64(tokens)/1_000)
98 default:
99 formattedTokens = fmt.Sprintf("%d", tokens)
100 }
101
102 // Remove .0 suffix if present
103 if strings.HasSuffix(formattedTokens, ".0K") {
104 formattedTokens = strings.Replace(formattedTokens, ".0K", "K", 1)
105 }
106 if strings.HasSuffix(formattedTokens, ".0M") {
107 formattedTokens = strings.Replace(formattedTokens, ".0M", "M", 1)
108 }
109
110 // Format cost with $ symbol and 2 decimal places
111 formattedCost := fmt.Sprintf("$%.2f", cost)
112
113 return fmt.Sprintf("Tokens: %s, Cost: %s", formattedTokens, formattedCost)
114}
115
116func (m statusCmp) View() string {
117 t := theme.CurrentTheme()
118
119 // Initialize the help widget
120 status := getHelpWidget("")
121
122 if m.session.ID != "" {
123 tokens := formatTokensAndCost(m.session.PromptTokens+m.session.CompletionTokens, m.session.Cost)
124 tokensStyle := styles.Padded().
125 Background(t.Text()).
126 Foreground(t.BackgroundSecondary()).
127 Render(tokens)
128 status += tokensStyle
129 }
130
131 diagnostics := styles.Padded().
132 Background(t.BackgroundDarker()).
133 Render(m.projectDiagnostics())
134
135 if m.info.Msg != "" {
136 infoStyle := styles.Padded().
137 Foreground(t.Background()).
138 Width(m.availableFooterMsgWidth(diagnostics))
139
140 switch m.info.Type {
141 case util.InfoTypeInfo:
142 infoStyle = infoStyle.Background(t.Info())
143 case util.InfoTypeWarn:
144 infoStyle = infoStyle.Background(t.Warning())
145 case util.InfoTypeError:
146 infoStyle = infoStyle.Background(t.Error())
147 }
148
149 // Truncate message if it's longer than available width
150 msg := m.info.Msg
151 availWidth := m.availableFooterMsgWidth(diagnostics) - 10
152 if len(msg) > availWidth && availWidth > 0 {
153 msg = msg[:availWidth] + "..."
154 }
155 status += infoStyle.Render(msg)
156 } else {
157 status += styles.Padded().
158 Foreground(t.Text()).
159 Background(t.BackgroundSecondary()).
160 Width(m.availableFooterMsgWidth(diagnostics)).
161 Render("")
162 }
163
164 status += diagnostics
165 status += m.model()
166 return status
167}
168
169func (m *statusCmp) projectDiagnostics() string {
170 t := theme.CurrentTheme()
171
172 // Check if any LSP server is still initializing
173 initializing := false
174 for _, client := range m.lspClients {
175 if client.GetServerState() == lsp.StateStarting {
176 initializing = true
177 break
178 }
179 }
180
181 // If any server is initializing, show that status
182 if initializing {
183 return lipgloss.NewStyle().
184 Background(t.BackgroundDarker()).
185 Foreground(t.Warning()).
186 Render(fmt.Sprintf("%s Initializing LSP...", styles.SpinnerIcon))
187 }
188
189 errorDiagnostics := []protocol.Diagnostic{}
190 warnDiagnostics := []protocol.Diagnostic{}
191 hintDiagnostics := []protocol.Diagnostic{}
192 infoDiagnostics := []protocol.Diagnostic{}
193 for _, client := range m.lspClients {
194 for _, d := range client.GetDiagnostics() {
195 for _, diag := range d {
196 switch diag.Severity {
197 case protocol.SeverityError:
198 errorDiagnostics = append(errorDiagnostics, diag)
199 case protocol.SeverityWarning:
200 warnDiagnostics = append(warnDiagnostics, diag)
201 case protocol.SeverityHint:
202 hintDiagnostics = append(hintDiagnostics, diag)
203 case protocol.SeverityInformation:
204 infoDiagnostics = append(infoDiagnostics, diag)
205 }
206 }
207 }
208 }
209
210 if len(errorDiagnostics) == 0 && len(warnDiagnostics) == 0 && len(hintDiagnostics) == 0 && len(infoDiagnostics) == 0 {
211 return "No diagnostics"
212 }
213
214 diagnostics := []string{}
215
216 if len(errorDiagnostics) > 0 {
217 errStr := lipgloss.NewStyle().
218 Background(t.BackgroundDarker()).
219 Foreground(t.Error()).
220 Render(fmt.Sprintf("%s %d", styles.ErrorIcon, len(errorDiagnostics)))
221 diagnostics = append(diagnostics, errStr)
222 }
223 if len(warnDiagnostics) > 0 {
224 warnStr := lipgloss.NewStyle().
225 Background(t.BackgroundDarker()).
226 Foreground(t.Warning()).
227 Render(fmt.Sprintf("%s %d", styles.WarningIcon, len(warnDiagnostics)))
228 diagnostics = append(diagnostics, warnStr)
229 }
230 if len(hintDiagnostics) > 0 {
231 hintStr := lipgloss.NewStyle().
232 Background(t.BackgroundDarker()).
233 Foreground(t.Text()).
234 Render(fmt.Sprintf("%s %d", styles.HintIcon, len(hintDiagnostics)))
235 diagnostics = append(diagnostics, hintStr)
236 }
237 if len(infoDiagnostics) > 0 {
238 infoStr := lipgloss.NewStyle().
239 Background(t.BackgroundDarker()).
240 Foreground(t.Info()).
241 Render(fmt.Sprintf("%s %d", styles.InfoIcon, len(infoDiagnostics)))
242 diagnostics = append(diagnostics, infoStr)
243 }
244
245 return strings.Join(diagnostics, " ")
246}
247
248func (m statusCmp) availableFooterMsgWidth(diagnostics string) int {
249 tokens := ""
250 tokensWidth := 0
251 if m.session.ID != "" {
252 tokens = formatTokensAndCost(m.session.PromptTokens+m.session.CompletionTokens, m.session.Cost)
253 tokensWidth = lipgloss.Width(tokens) + 2
254 }
255 return max(0, m.width-lipgloss.Width(helpWidget)-lipgloss.Width(m.model())-lipgloss.Width(diagnostics)-tokensWidth)
256}
257
258func (m statusCmp) model() string {
259 t := theme.CurrentTheme()
260
261 cfg := config.Get()
262
263 coder, ok := cfg.Agents[config.AgentCoder]
264 if !ok {
265 return "Unknown"
266 }
267 model := models.SupportedModels[coder.Model]
268
269 return styles.Padded().
270 Background(t.Secondary()).
271 Foreground(t.Background()).
272 Render(model.Name)
273}
274
275func (m statusCmp) SetHelpWidgetMsg(s string) {
276 // Update the help widget text using the getHelpWidget function
277 helpWidget = getHelpWidget(s)
278}
279
280func NewStatusCmp(lspClients map[string]*lsp.Client) StatusCmp {
281 // Initialize the help widget with default text
282 helpWidget = getHelpWidget("")
283
284 return &statusCmp{
285 messageTTL: 10 * time.Second,
286 lspClients: lspClients,
287 }
288}