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	SetHelpWidgetMsg(string)
 25}
 26
 27type statusCmp struct {
 28	info       util.InfoMsg
 29	width      int
 30	messageTTL time.Duration
 31	lspClients map[string]*lsp.Client
 32	session    session.Session
 33}
 34
 35// clearMessageCmd is a command that clears status messages after a timeout
 36func (m statusCmp) clearMessageCmd(ttl time.Duration) tea.Cmd {
 37	return tea.Tick(ttl, func(time.Time) tea.Msg {
 38		return util.ClearStatusMsg{}
 39	})
 40}
 41
 42func (m statusCmp) Init() tea.Cmd {
 43	return nil
 44}
 45
 46func (m statusCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 47	switch msg := msg.(type) {
 48	case tea.WindowSizeMsg:
 49		m.width = msg.Width
 50		return m, nil
 51	case chat.SessionSelectedMsg:
 52		m.session = msg
 53	case chat.SessionClearedMsg:
 54		m.session = session.Session{}
 55	case pubsub.Event[session.Session]:
 56		if msg.Type == pubsub.UpdatedEvent {
 57			if m.session.ID == msg.Payload.ID {
 58				m.session = msg.Payload
 59			}
 60		}
 61	case util.InfoMsg:
 62		m.info = msg
 63		ttl := msg.TTL
 64		if ttl == 0 {
 65			ttl = m.messageTTL
 66		}
 67		return m, m.clearMessageCmd(ttl)
 68	case util.ClearStatusMsg:
 69		m.info = util.InfoMsg{}
 70	}
 71	return m, nil
 72}
 73
 74var helpWidget = ""
 75
 76// getHelpWidget returns the help widget with current theme colors
 77func getHelpWidget(helpText string) string {
 78	t := theme.CurrentTheme()
 79	if helpText == "" {
 80		helpText = "ctrl+? help"
 81	}
 82
 83	return styles.Padded().
 84		Background(t.TextMuted()).
 85		Foreground(t.BackgroundDarker()).
 86		Bold(true).
 87		Render(helpText)
 88}
 89
 90func formatTokensAndCost(tokens int64, cost float64) string {
 91	// Format tokens in human-readable format (e.g., 110K, 1.2M)
 92	var formattedTokens string
 93	switch {
 94	case tokens >= 1_000_000:
 95		formattedTokens = fmt.Sprintf("%.1fM", float64(tokens)/1_000_000)
 96	case tokens >= 1_000:
 97		formattedTokens = fmt.Sprintf("%.1fK", float64(tokens)/1_000)
 98	default:
 99		formattedTokens = fmt.Sprintf("%d", tokens)
100	}
101
102	// Remove .0 suffix if present
103	if strings.HasSuffix(formattedTokens, ".0K") {
104		formattedTokens = strings.Replace(formattedTokens, ".0K", "K", 1)
105	}
106	if strings.HasSuffix(formattedTokens, ".0M") {
107		formattedTokens = strings.Replace(formattedTokens, ".0M", "M", 1)
108	}
109
110	// Format cost with $ symbol and 2 decimal places
111	formattedCost := fmt.Sprintf("$%.2f", cost)
112
113	return fmt.Sprintf("Tokens: %s, Cost: %s", formattedTokens, formattedCost)
114}
115
116func (m statusCmp) View() string {
117	t := theme.CurrentTheme()
118
119	// Initialize the help widget
120	status := getHelpWidget("")
121
122	if m.session.ID != "" {
123		tokens := formatTokensAndCost(m.session.PromptTokens+m.session.CompletionTokens, m.session.Cost)
124		tokensStyle := styles.Padded().
125			Background(t.Text()).
126			Foreground(t.BackgroundSecondary()).
127			Render(tokens)
128		status += tokensStyle
129	}
130
131	diagnostics := styles.Padded().
132		Background(t.BackgroundDarker()).
133		Render(m.projectDiagnostics())
134
135	if m.info.Msg != "" {
136		infoStyle := styles.Padded().
137			Foreground(t.Background()).
138			Width(m.availableFooterMsgWidth(diagnostics))
139
140		switch m.info.Type {
141		case util.InfoTypeInfo:
142			infoStyle = infoStyle.Background(t.Info())
143		case util.InfoTypeWarn:
144			infoStyle = infoStyle.Background(t.Warning())
145		case util.InfoTypeError:
146			infoStyle = infoStyle.Background(t.Error())
147		}
148
149		// Truncate message if it's longer than available width
150		msg := m.info.Msg
151		availWidth := m.availableFooterMsgWidth(diagnostics) - 10
152		if len(msg) > availWidth && availWidth > 0 {
153			msg = msg[:availWidth] + "..."
154		}
155		status += infoStyle.Render(msg)
156	} else {
157		status += styles.Padded().
158			Foreground(t.Text()).
159			Background(t.BackgroundSecondary()).
160			Width(m.availableFooterMsgWidth(diagnostics)).
161			Render("")
162	}
163
164	status += diagnostics
165	status += m.model()
166	return status
167}
168
169func (m *statusCmp) projectDiagnostics() string {
170	t := theme.CurrentTheme()
171
172	// Check if any LSP server is still initializing
173	initializing := false
174	for _, client := range m.lspClients {
175		if client.GetServerState() == lsp.StateStarting {
176			initializing = true
177			break
178		}
179	}
180
181	// If any server is initializing, show that status
182	if initializing {
183		return lipgloss.NewStyle().
184			Background(t.BackgroundDarker()).
185			Foreground(t.Warning()).
186			Render(fmt.Sprintf("%s Initializing LSP...", styles.SpinnerIcon))
187	}
188
189	errorDiagnostics := []protocol.Diagnostic{}
190	warnDiagnostics := []protocol.Diagnostic{}
191	hintDiagnostics := []protocol.Diagnostic{}
192	infoDiagnostics := []protocol.Diagnostic{}
193	for _, client := range m.lspClients {
194		for _, d := range client.GetDiagnostics() {
195			for _, diag := range d {
196				switch diag.Severity {
197				case protocol.SeverityError:
198					errorDiagnostics = append(errorDiagnostics, diag)
199				case protocol.SeverityWarning:
200					warnDiagnostics = append(warnDiagnostics, diag)
201				case protocol.SeverityHint:
202					hintDiagnostics = append(hintDiagnostics, diag)
203				case protocol.SeverityInformation:
204					infoDiagnostics = append(infoDiagnostics, diag)
205				}
206			}
207		}
208	}
209
210	if len(errorDiagnostics) == 0 && len(warnDiagnostics) == 0 && len(hintDiagnostics) == 0 && len(infoDiagnostics) == 0 {
211		return "No diagnostics"
212	}
213
214	diagnostics := []string{}
215
216	if len(errorDiagnostics) > 0 {
217		errStr := lipgloss.NewStyle().
218			Background(t.BackgroundDarker()).
219			Foreground(t.Error()).
220			Render(fmt.Sprintf("%s %d", styles.ErrorIcon, len(errorDiagnostics)))
221		diagnostics = append(diagnostics, errStr)
222	}
223	if len(warnDiagnostics) > 0 {
224		warnStr := lipgloss.NewStyle().
225			Background(t.BackgroundDarker()).
226			Foreground(t.Warning()).
227			Render(fmt.Sprintf("%s %d", styles.WarningIcon, len(warnDiagnostics)))
228		diagnostics = append(diagnostics, warnStr)
229	}
230	if len(hintDiagnostics) > 0 {
231		hintStr := lipgloss.NewStyle().
232			Background(t.BackgroundDarker()).
233			Foreground(t.Text()).
234			Render(fmt.Sprintf("%s %d", styles.HintIcon, len(hintDiagnostics)))
235		diagnostics = append(diagnostics, hintStr)
236	}
237	if len(infoDiagnostics) > 0 {
238		infoStr := lipgloss.NewStyle().
239			Background(t.BackgroundDarker()).
240			Foreground(t.Info()).
241			Render(fmt.Sprintf("%s %d", styles.InfoIcon, len(infoDiagnostics)))
242		diagnostics = append(diagnostics, infoStr)
243	}
244
245	return strings.Join(diagnostics, " ")
246}
247
248func (m statusCmp) availableFooterMsgWidth(diagnostics string) int {
249	tokens := ""
250	tokensWidth := 0
251	if m.session.ID != "" {
252		tokens = formatTokensAndCost(m.session.PromptTokens+m.session.CompletionTokens, m.session.Cost)
253		tokensWidth = lipgloss.Width(tokens) + 2
254	}
255	return max(0, m.width-lipgloss.Width(helpWidget)-lipgloss.Width(m.model())-lipgloss.Width(diagnostics)-tokensWidth)
256}
257
258func (m statusCmp) model() string {
259	t := theme.CurrentTheme()
260
261	cfg := config.Get()
262
263	coder, ok := cfg.Agents[config.AgentCoder]
264	if !ok {
265		return "Unknown"
266	}
267	model := models.SupportedModels[coder.Model]
268
269	return styles.Padded().
270		Background(t.Secondary()).
271		Foreground(t.Background()).
272		Render(model.Name)
273}
274
275func (m statusCmp) SetHelpWidgetMsg(s string) {
276	// Update the help widget text using the getHelpWidget function
277	helpWidget = getHelpWidget(s)
278}
279
280func NewStatusCmp(lspClients map[string]*lsp.Client) StatusCmp {
281	// Initialize the help widget with default text
282	helpWidget = getHelpWidget("")
283
284	return &statusCmp{
285		messageTTL: 10 * time.Second,
286		lspClients: lspClients,
287	}
288}