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 errorDiagnostics := []protocol.Diagnostic{}
142 warnDiagnostics := []protocol.Diagnostic{}
143 hintDiagnostics := []protocol.Diagnostic{}
144 infoDiagnostics := []protocol.Diagnostic{}
145 for _, client := range m.lspClients {
146 for _, d := range client.GetDiagnostics() {
147 for _, diag := range d {
148 switch diag.Severity {
149 case protocol.SeverityError:
150 errorDiagnostics = append(errorDiagnostics, diag)
151 case protocol.SeverityWarning:
152 warnDiagnostics = append(warnDiagnostics, diag)
153 case protocol.SeverityHint:
154 hintDiagnostics = append(hintDiagnostics, diag)
155 case protocol.SeverityInformation:
156 infoDiagnostics = append(infoDiagnostics, diag)
157 }
158 }
159 }
160 }
161
162 if len(errorDiagnostics) == 0 && len(warnDiagnostics) == 0 && len(hintDiagnostics) == 0 && len(infoDiagnostics) == 0 {
163 return "No diagnostics"
164 }
165
166 diagnostics := []string{}
167
168 if len(errorDiagnostics) > 0 {
169 errStr := lipgloss.NewStyle().Foreground(styles.Error).Render(fmt.Sprintf("%s %d", styles.ErrorIcon, len(errorDiagnostics)))
170 diagnostics = append(diagnostics, errStr)
171 }
172 if len(warnDiagnostics) > 0 {
173 warnStr := lipgloss.NewStyle().Foreground(styles.Warning).Render(fmt.Sprintf("%s %d", styles.WarningIcon, len(warnDiagnostics)))
174 diagnostics = append(diagnostics, warnStr)
175 }
176 if len(hintDiagnostics) > 0 {
177 hintStr := lipgloss.NewStyle().Foreground(styles.Text).Render(fmt.Sprintf("%s %d", styles.HintIcon, len(hintDiagnostics)))
178 diagnostics = append(diagnostics, hintStr)
179 }
180 if len(infoDiagnostics) > 0 {
181 infoStr := lipgloss.NewStyle().Foreground(styles.Peach).Render(fmt.Sprintf("%s %d", styles.InfoIcon, len(infoDiagnostics)))
182 diagnostics = append(diagnostics, infoStr)
183 }
184
185 return strings.Join(diagnostics, " ")
186}
187
188func (m statusCmp) availableFooterMsgWidth(diagnostics string) int {
189 tokens := ""
190 if m.session.ID != "" {
191 tokens = formatTokensAndCost(m.session.PromptTokens+m.session.CompletionTokens, m.session.Cost)
192 }
193 return max(0, m.width-lipgloss.Width(helpWidget)-lipgloss.Width(m.model())-lipgloss.Width(diagnostics)-lipgloss.Width(tokens))
194}
195
196func (m statusCmp) model() string {
197 cfg := config.Get()
198
199 coder, ok := cfg.Agents[config.AgentCoder]
200 if !ok {
201 return "Unknown"
202 }
203 model := models.SupportedModels[coder.Model]
204 return styles.Padded.Background(styles.Grey).Foreground(styles.Text).Render(model.Name)
205}
206
207func NewStatusCmp(lspClients map[string]*lsp.Client) tea.Model {
208 return &statusCmp{
209 messageTTL: 10 * time.Second,
210 lspClients: lspClients,
211 }
212}