status.go

  1package core
  2
  3import (
  4	"fmt"
  5	"strings"
  6	"time"
  7
  8	tea "github.com/charmbracelet/bubbletea/v2"
  9	"github.com/charmbracelet/lipgloss/v2"
 10	"github.com/opencode-ai/opencode/internal/config"
 11	"github.com/opencode-ai/opencode/internal/llm/models"
 12	"github.com/opencode-ai/opencode/internal/logging"
 13	"github.com/opencode-ai/opencode/internal/lsp"
 14	"github.com/opencode-ai/opencode/internal/lsp/protocol"
 15	"github.com/opencode-ai/opencode/internal/pubsub"
 16	"github.com/opencode-ai/opencode/internal/session"
 17	"github.com/opencode-ai/opencode/internal/tui/components/chat"
 18	"github.com/opencode-ai/opencode/internal/tui/styles"
 19	"github.com/opencode-ai/opencode/internal/tui/theme"
 20	"github.com/opencode-ai/opencode/internal/tui/util"
 21)
 22
 23type StatusCmp interface {
 24	util.Model
 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
 52		// Handle sesson messages
 53	case chat.SessionSelectedMsg:
 54		m.session = msg
 55	case chat.SessionClearedMsg:
 56		m.session = session.Session{}
 57	case pubsub.Event[session.Session]:
 58		if msg.Type == pubsub.UpdatedEvent {
 59			if m.session.ID == msg.Payload.ID {
 60				m.session = msg.Payload
 61			}
 62		}
 63
 64	// Handle status info
 65	case util.InfoMsg:
 66		m.info = msg
 67		ttl := msg.TTL
 68		if ttl == 0 {
 69			ttl = m.messageTTL
 70		}
 71		return m, m.clearMessageCmd(ttl)
 72	case util.ClearStatusMsg:
 73		m.info = util.InfoMsg{}
 74
 75	// Handle persistent logs
 76	case pubsub.Event[logging.LogMessage]:
 77		if msg.Payload.Persist {
 78			switch msg.Payload.Level {
 79			case "error":
 80				m.info = util.InfoMsg{
 81					Type: util.InfoTypeError,
 82					Msg:  msg.Payload.Message,
 83					TTL:  msg.Payload.PersistTime,
 84				}
 85			case "info":
 86				m.info = util.InfoMsg{
 87					Type: util.InfoTypeInfo,
 88					Msg:  msg.Payload.Message,
 89					TTL:  msg.Payload.PersistTime,
 90				}
 91			case "warn":
 92				m.info = util.InfoMsg{
 93					Type: util.InfoTypeWarn,
 94					Msg:  msg.Payload.Message,
 95					TTL:  msg.Payload.PersistTime,
 96				}
 97			default:
 98				m.info = util.InfoMsg{
 99					Type: util.InfoTypeInfo,
100					Msg:  msg.Payload.Message,
101					TTL:  msg.Payload.PersistTime,
102				}
103			}
104		}
105	}
106	return m, nil
107}
108
109var helpWidget = ""
110
111// getHelpWidget returns the help widget with current theme colors
112func getHelpWidget() string {
113	t := theme.CurrentTheme()
114	helpText := "ctrl+? help"
115
116	return styles.Padded().
117		Background(t.TextMuted()).
118		Foreground(t.BackgroundDarker()).
119		Bold(true).
120		Render(helpText)
121}
122
123func formatTokensAndCost(tokens, contextWindow int64, cost float64) string {
124	// Format tokens in human-readable format (e.g., 110K, 1.2M)
125	var formattedTokens string
126	switch {
127	case tokens >= 1_000_000:
128		formattedTokens = fmt.Sprintf("%.1fM", float64(tokens)/1_000_000)
129	case tokens >= 1_000:
130		formattedTokens = fmt.Sprintf("%.1fK", float64(tokens)/1_000)
131	default:
132		formattedTokens = fmt.Sprintf("%d", tokens)
133	}
134
135	// Remove .0 suffix if present
136	if strings.HasSuffix(formattedTokens, ".0K") {
137		formattedTokens = strings.Replace(formattedTokens, ".0K", "K", 1)
138	}
139	if strings.HasSuffix(formattedTokens, ".0M") {
140		formattedTokens = strings.Replace(formattedTokens, ".0M", "M", 1)
141	}
142
143	// Format cost with $ symbol and 2 decimal places
144	formattedCost := fmt.Sprintf("$%.2f", cost)
145
146	percentage := (float64(tokens) / float64(contextWindow)) * 100
147	if percentage > 80 {
148		// add the warning icon and percentage
149		formattedTokens = fmt.Sprintf("%s(%d%%)", styles.WarningIcon, int(percentage))
150	}
151
152	return fmt.Sprintf("Context: %s, Cost: %s", formattedTokens, formattedCost)
153}
154
155func (m statusCmp) View() tea.View {
156	t := theme.CurrentTheme()
157	modelID := config.Get().Agents[config.AgentCoder].Model
158	model := models.SupportedModels[modelID]
159
160	// Initialize the help widget
161	status := getHelpWidget()
162
163	tokenInfoWidth := 0
164	if m.session.ID != "" {
165		totalTokens := m.session.PromptTokens + m.session.CompletionTokens
166		tokens := formatTokensAndCost(totalTokens, model.ContextWindow, m.session.Cost)
167		tokensStyle := styles.Padded().
168			Background(t.Text()).
169			Foreground(t.BackgroundSecondary())
170		percentage := (float64(totalTokens) / float64(model.ContextWindow)) * 100
171		if percentage > 80 {
172			tokensStyle = tokensStyle.Background(t.Warning())
173		}
174		tokenInfoWidth = lipgloss.Width(tokens) + 2
175		status += tokensStyle.Render(tokens)
176	}
177
178	diagnostics := styles.Padded().
179		Background(t.BackgroundDarker()).
180		Render(m.projectDiagnostics())
181
182	availableWidht := max(0, m.width-lipgloss.Width(helpWidget)-lipgloss.Width(m.model())-lipgloss.Width(diagnostics)-tokenInfoWidth)
183
184	if m.info.Msg != "" {
185		infoStyle := styles.Padded().
186			Foreground(t.Background()).
187			Width(availableWidht)
188
189		switch m.info.Type {
190		case util.InfoTypeInfo:
191			infoStyle = infoStyle.Background(t.Info())
192		case util.InfoTypeWarn:
193			infoStyle = infoStyle.Background(t.Warning())
194		case util.InfoTypeError:
195			infoStyle = infoStyle.Background(t.Error())
196		}
197
198		infoWidth := availableWidht - 10
199		// Truncate message if it's longer than available width
200		msg := m.info.Msg
201		if len(msg) > infoWidth && infoWidth > 0 {
202			msg = msg[:infoWidth] + "..."
203		}
204		status += infoStyle.Render(msg)
205	} else {
206		status += styles.Padded().
207			Foreground(t.Text()).
208			Background(t.BackgroundSecondary()).
209			Width(availableWidht).
210			Render("")
211	}
212
213	status += diagnostics
214	status += m.model()
215	return tea.NewView(status)
216}
217
218func (m *statusCmp) projectDiagnostics() string {
219	t := theme.CurrentTheme()
220
221	// Check if any LSP server is still initializing
222	initializing := false
223	for _, client := range m.lspClients {
224		if client.GetServerState() == lsp.StateStarting {
225			initializing = true
226			break
227		}
228	}
229
230	// If any server is initializing, show that status
231	if initializing {
232		return lipgloss.NewStyle().
233			Background(t.BackgroundDarker()).
234			Foreground(t.Warning()).
235			Render(fmt.Sprintf("%s Initializing LSP...", styles.SpinnerIcon))
236	}
237
238	errorDiagnostics := []protocol.Diagnostic{}
239	warnDiagnostics := []protocol.Diagnostic{}
240	hintDiagnostics := []protocol.Diagnostic{}
241	infoDiagnostics := []protocol.Diagnostic{}
242	for _, client := range m.lspClients {
243		for _, d := range client.GetDiagnostics() {
244			for _, diag := range d {
245				switch diag.Severity {
246				case protocol.SeverityError:
247					errorDiagnostics = append(errorDiagnostics, diag)
248				case protocol.SeverityWarning:
249					warnDiagnostics = append(warnDiagnostics, diag)
250				case protocol.SeverityHint:
251					hintDiagnostics = append(hintDiagnostics, diag)
252				case protocol.SeverityInformation:
253					infoDiagnostics = append(infoDiagnostics, diag)
254				}
255			}
256		}
257	}
258
259	if len(errorDiagnostics) == 0 && len(warnDiagnostics) == 0 && len(hintDiagnostics) == 0 && len(infoDiagnostics) == 0 {
260		return "No diagnostics"
261	}
262
263	diagnostics := []string{}
264
265	if len(errorDiagnostics) > 0 {
266		errStr := lipgloss.NewStyle().
267			Background(t.BackgroundDarker()).
268			Foreground(t.Error()).
269			Render(fmt.Sprintf("%s %d", styles.ErrorIcon, len(errorDiagnostics)))
270		diagnostics = append(diagnostics, errStr)
271	}
272	if len(warnDiagnostics) > 0 {
273		warnStr := lipgloss.NewStyle().
274			Background(t.BackgroundDarker()).
275			Foreground(t.Warning()).
276			Render(fmt.Sprintf("%s %d", styles.WarningIcon, len(warnDiagnostics)))
277		diagnostics = append(diagnostics, warnStr)
278	}
279	if len(hintDiagnostics) > 0 {
280		hintStr := lipgloss.NewStyle().
281			Background(t.BackgroundDarker()).
282			Foreground(t.Text()).
283			Render(fmt.Sprintf("%s %d", styles.HintIcon, len(hintDiagnostics)))
284		diagnostics = append(diagnostics, hintStr)
285	}
286	if len(infoDiagnostics) > 0 {
287		infoStr := lipgloss.NewStyle().
288			Background(t.BackgroundDarker()).
289			Foreground(t.Info()).
290			Render(fmt.Sprintf("%s %d", styles.InfoIcon, len(infoDiagnostics)))
291		diagnostics = append(diagnostics, infoStr)
292	}
293
294	return strings.Join(diagnostics, " ")
295}
296
297func (m statusCmp) availableFooterMsgWidth(diagnostics, tokenInfo string) int {
298	tokensWidth := 0
299	if m.session.ID != "" {
300		tokensWidth = lipgloss.Width(tokenInfo) + 2
301	}
302	return max(0, m.width-lipgloss.Width(helpWidget)-lipgloss.Width(m.model())-lipgloss.Width(diagnostics)-tokensWidth)
303}
304
305func (m statusCmp) model() string {
306	t := theme.CurrentTheme()
307
308	cfg := config.Get()
309
310	coder, ok := cfg.Agents[config.AgentCoder]
311	if !ok {
312		return "Unknown"
313	}
314	model := models.SupportedModels[coder.Model]
315
316	return styles.Padded().
317		Background(t.Secondary()).
318		Foreground(t.Background()).
319		Render(model.Name)
320}
321
322func NewStatusCmp(lspClients map[string]*lsp.Client) StatusCmp {
323	helpWidget = getHelpWidget()
324
325	return &statusCmp{
326		messageTTL: 10 * time.Second,
327		lspClients: lspClients,
328	}
329}