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