status.go

  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}