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