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