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/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}