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().
170 Background(styles.BackgroundDarker).
171 Foreground(styles.Error).
172 Render(fmt.Sprintf("%s %d", styles.ErrorIcon, len(errorDiagnostics)))
173 diagnostics = append(diagnostics, errStr)
174 }
175 if len(warnDiagnostics) > 0 {
176 warnStr := lipgloss.NewStyle().
177 Background(styles.BackgroundDarker).
178 Foreground(styles.Warning).
179 Render(fmt.Sprintf("%s %d", styles.WarningIcon, len(warnDiagnostics)))
180 diagnostics = append(diagnostics, warnStr)
181 }
182 if len(hintDiagnostics) > 0 {
183 hintStr := lipgloss.NewStyle().
184 Background(styles.BackgroundDarker).
185 Foreground(styles.Text).
186 Render(fmt.Sprintf("%s %d", styles.HintIcon, len(hintDiagnostics)))
187 diagnostics = append(diagnostics, hintStr)
188 }
189 if len(infoDiagnostics) > 0 {
190 infoStr := lipgloss.NewStyle().
191 Background(styles.BackgroundDarker).
192 Foreground(styles.Peach).
193 Render(fmt.Sprintf("%s %d", styles.InfoIcon, len(infoDiagnostics)))
194 diagnostics = append(diagnostics, infoStr)
195 }
196
197 return strings.Join(diagnostics, " ")
198}
199
200func (m statusCmp) availableFooterMsgWidth(diagnostics string) int {
201 tokens := ""
202 tokensWidth := 0
203 if m.session.ID != "" {
204 tokens = formatTokensAndCost(m.session.PromptTokens+m.session.CompletionTokens, m.session.Cost)
205 tokensWidth = lipgloss.Width(tokens) + 2
206 }
207 return max(0, m.width-lipgloss.Width(helpWidget)-lipgloss.Width(m.model())-lipgloss.Width(diagnostics)-tokensWidth)
208}
209
210func (m statusCmp) model() string {
211 cfg := config.Get()
212
213 coder, ok := cfg.Agents[config.AgentCoder]
214 if !ok {
215 return "Unknown"
216 }
217 model := models.SupportedModels[coder.Model]
218 return styles.Padded.Background(styles.Grey).Foreground(styles.Text).Render(model.Name)
219}
220
221func NewStatusCmp(lspClients map[string]*lsp.Client) tea.Model {
222 return &statusCmp{
223 messageTTL: 10 * time.Second,
224 lspClients: lspClients,
225 }
226}