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