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/kujtimiihoxha/opencode/internal/config"
11 "github.com/kujtimiihoxha/opencode/internal/llm/models"
12 "github.com/kujtimiihoxha/opencode/internal/lsp"
13 "github.com/kujtimiihoxha/opencode/internal/lsp/protocol"
14 "github.com/kujtimiihoxha/opencode/internal/pubsub"
15 "github.com/kujtimiihoxha/opencode/internal/session"
16 "github.com/kujtimiihoxha/opencode/internal/tui/components/chat"
17 "github.com/kujtimiihoxha/opencode/internal/tui/styles"
18 "github.com/kujtimiihoxha/opencode/internal/tui/util"
19)
20
21type StatusCmp interface {
22 tea.Model
23 SetHelpMsg(string)
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 = styles.Padded.Background(styles.ForgroundMid).Foreground(styles.BackgroundDarker).Bold(true).Render("ctrl+? help")
74
75func formatTokensAndCost(tokens int64, cost float64) string {
76 // Format tokens in human-readable format (e.g., 110K, 1.2M)
77 var formattedTokens string
78 switch {
79 case tokens >= 1_000_000:
80 formattedTokens = fmt.Sprintf("%.1fM", float64(tokens)/1_000_000)
81 case tokens >= 1_000:
82 formattedTokens = fmt.Sprintf("%.1fK", float64(tokens)/1_000)
83 default:
84 formattedTokens = fmt.Sprintf("%d", tokens)
85 }
86
87 // Remove .0 suffix if present
88 if strings.HasSuffix(formattedTokens, ".0K") {
89 formattedTokens = strings.Replace(formattedTokens, ".0K", "K", 1)
90 }
91 if strings.HasSuffix(formattedTokens, ".0M") {
92 formattedTokens = strings.Replace(formattedTokens, ".0M", "M", 1)
93 }
94
95 // Format cost with $ symbol and 2 decimal places
96 formattedCost := fmt.Sprintf("$%.2f", cost)
97
98 return fmt.Sprintf("Tokens: %s, Cost: %s", formattedTokens, formattedCost)
99}
100
101func (m statusCmp) View() string {
102 status := helpWidget
103 if m.session.ID != "" {
104 tokens := formatTokensAndCost(m.session.PromptTokens+m.session.CompletionTokens, m.session.Cost)
105 tokensStyle := styles.Padded.
106 Background(styles.Forground).
107 Foreground(styles.BackgroundDim).
108 Render(tokens)
109 status += tokensStyle
110 }
111
112 diagnostics := styles.Padded.Background(styles.BackgroundDarker).Render(m.projectDiagnostics())
113 if m.info.Msg != "" {
114 infoStyle := styles.Padded.
115 Foreground(styles.Base).
116 Width(m.availableFooterMsgWidth(diagnostics))
117 switch m.info.Type {
118 case util.InfoTypeInfo:
119 infoStyle = infoStyle.Background(styles.BorderColor)
120 case util.InfoTypeWarn:
121 infoStyle = infoStyle.Background(styles.Peach)
122 case util.InfoTypeError:
123 infoStyle = infoStyle.Background(styles.Red)
124 }
125 // Truncate message if it's longer than available width
126 msg := m.info.Msg
127 availWidth := m.availableFooterMsgWidth(diagnostics) - 10
128 if len(msg) > availWidth && availWidth > 0 {
129 msg = msg[:availWidth] + "..."
130 }
131 status += infoStyle.Render(msg)
132 } else {
133 status += styles.Padded.
134 Foreground(styles.Base).
135 Background(styles.BackgroundDim).
136 Width(m.availableFooterMsgWidth(diagnostics)).
137 Render("")
138 }
139
140 status += diagnostics
141 status += m.model()
142 return status
143}
144
145func (m *statusCmp) projectDiagnostics() string {
146 // Check if any LSP server is still initializing
147 initializing := false
148 for _, client := range m.lspClients {
149 if client.GetServerState() == lsp.StateStarting {
150 initializing = true
151 break
152 }
153 }
154
155 // If any server is initializing, show that status
156 if initializing {
157 return lipgloss.NewStyle().
158 Background(styles.BackgroundDarker).
159 Foreground(styles.Peach).
160 Render(fmt.Sprintf("%s Initializing LSP...", styles.SpinnerIcon))
161 }
162
163 errorDiagnostics := []protocol.Diagnostic{}
164 warnDiagnostics := []protocol.Diagnostic{}
165 hintDiagnostics := []protocol.Diagnostic{}
166 infoDiagnostics := []protocol.Diagnostic{}
167 for _, client := range m.lspClients {
168 for _, d := range client.GetDiagnostics() {
169 for _, diag := range d {
170 switch diag.Severity {
171 case protocol.SeverityError:
172 errorDiagnostics = append(errorDiagnostics, diag)
173 case protocol.SeverityWarning:
174 warnDiagnostics = append(warnDiagnostics, diag)
175 case protocol.SeverityHint:
176 hintDiagnostics = append(hintDiagnostics, diag)
177 case protocol.SeverityInformation:
178 infoDiagnostics = append(infoDiagnostics, diag)
179 }
180 }
181 }
182 }
183
184 if len(errorDiagnostics) == 0 && len(warnDiagnostics) == 0 && len(hintDiagnostics) == 0 && len(infoDiagnostics) == 0 {
185 return "No diagnostics"
186 }
187
188 diagnostics := []string{}
189
190 if len(errorDiagnostics) > 0 {
191 errStr := lipgloss.NewStyle().
192 Background(styles.BackgroundDarker).
193 Foreground(styles.Error).
194 Render(fmt.Sprintf("%s %d", styles.ErrorIcon, len(errorDiagnostics)))
195 diagnostics = append(diagnostics, errStr)
196 }
197 if len(warnDiagnostics) > 0 {
198 warnStr := lipgloss.NewStyle().
199 Background(styles.BackgroundDarker).
200 Foreground(styles.Warning).
201 Render(fmt.Sprintf("%s %d", styles.WarningIcon, len(warnDiagnostics)))
202 diagnostics = append(diagnostics, warnStr)
203 }
204 if len(hintDiagnostics) > 0 {
205 hintStr := lipgloss.NewStyle().
206 Background(styles.BackgroundDarker).
207 Foreground(styles.Text).
208 Render(fmt.Sprintf("%s %d", styles.HintIcon, len(hintDiagnostics)))
209 diagnostics = append(diagnostics, hintStr)
210 }
211 if len(infoDiagnostics) > 0 {
212 infoStr := lipgloss.NewStyle().
213 Background(styles.BackgroundDarker).
214 Foreground(styles.Peach).
215 Render(fmt.Sprintf("%s %d", styles.InfoIcon, len(infoDiagnostics)))
216 diagnostics = append(diagnostics, infoStr)
217 }
218
219 return strings.Join(diagnostics, " ")
220}
221
222func (m statusCmp) availableFooterMsgWidth(diagnostics string) int {
223 tokens := ""
224 tokensWidth := 0
225 if m.session.ID != "" {
226 tokens = formatTokensAndCost(m.session.PromptTokens+m.session.CompletionTokens, m.session.Cost)
227 tokensWidth = lipgloss.Width(tokens) + 2
228 }
229 return max(0, m.width-lipgloss.Width(helpWidget)-lipgloss.Width(m.model())-lipgloss.Width(diagnostics)-tokensWidth)
230}
231
232func (m statusCmp) model() string {
233 cfg := config.Get()
234
235 coder, ok := cfg.Agents[config.AgentCoder]
236 if !ok {
237 return "Unknown"
238 }
239 model := models.SupportedModels[coder.Model]
240 return styles.Padded.Background(styles.Grey).Foreground(styles.Text).Render(model.Name)
241}
242
243func (m statusCmp) SetHelpMsg(s string) {
244 helpWidget = styles.Padded.Background(styles.Forground).Foreground(styles.BackgroundDarker).Bold(true).Render(s)
245}
246
247func NewStatusCmp(lspClients map[string]*lsp.Client) StatusCmp {
248 return &statusCmp{
249 messageTTL: 10 * time.Second,
250 lspClients: lspClients,
251 }
252}