opencode/internal/tui/components/core/status.go

364 lines
9.9 KiB
Go
Raw Normal View History

2025-03-23 21:56:32 +08:00
package core
import (
"fmt"
"strings"
"time"
2025-03-23 21:56:32 +08:00
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
2025-05-13 23:02:39 +08:00
"github.com/sst/opencode/internal/pubsub"
"github.com/sst/opencode/internal/status"
2025-05-29 04:36:31 +08:00
"github.com/sst/opencode/internal/tui/app"
2025-05-13 23:02:39 +08:00
"github.com/sst/opencode/internal/tui/styles"
"github.com/sst/opencode/internal/tui/theme"
2025-03-23 21:56:32 +08:00
)
2025-04-21 19:33:51 +08:00
type StatusCmp interface {
tea.Model
2025-05-01 18:48:19 +08:00
SetHelpWidgetMsg(string)
2025-04-21 19:33:51 +08:00
}
2025-03-23 21:56:32 +08:00
type statusCmp struct {
2025-05-16 01:04:15 +08:00
app *app.App
queue []status.StatusMessage
width int
messageTTL time.Duration
activeUntil time.Time
}
// clearMessageCmd is a command that clears status messages after a timeout
2025-05-09 01:03:59 +08:00
func (m statusCmp) clearMessageCmd() tea.Cmd {
return tea.Tick(time.Second, func(t time.Time) tea.Msg {
return statusCleanupMsg{time: t}
})
2025-03-23 21:56:32 +08:00
}
2025-05-09 01:03:59 +08:00
// statusCleanupMsg is a message that triggers cleanup of expired status messages
type statusCleanupMsg struct {
time time.Time
}
2025-03-23 21:56:32 +08:00
func (m statusCmp) Init() tea.Cmd {
2025-05-09 01:03:59 +08:00
return m.clearMessageCmd()
2025-03-23 21:56:32 +08:00
}
func (m statusCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
2025-04-10 01:07:39 +08:00
return m, nil
2025-05-09 01:03:59 +08:00
case pubsub.Event[status.StatusMessage]:
2025-05-12 22:44:56 +08:00
if msg.Type == status.EventStatusPublished {
2025-05-16 01:04:15 +08:00
// If this is a critical message, move it to the front of the queue
if msg.Payload.Critical {
// Insert at the front of the queue
m.queue = append([]status.StatusMessage{msg.Payload}, m.queue...)
// Reset active time to show critical message immediately
m.activeUntil = time.Time{}
} else {
// Otherwise, just add it to the queue
m.queue = append(m.queue, msg.Payload)
// If this is the first message and nothing is active, activate it immediately
if len(m.queue) == 1 && m.activeUntil.IsZero() {
now := time.Now()
duration := m.messageTTL
if msg.Payload.Duration > 0 {
duration = msg.Payload.Duration
}
m.activeUntil = now.Add(duration)
}
2025-05-09 01:03:59 +08:00
}
2025-04-10 01:07:39 +08:00
}
2025-05-09 01:03:59 +08:00
case statusCleanupMsg:
2025-05-16 01:04:15 +08:00
now := msg.time
// If the active message has expired, remove it and activate the next one
if !m.activeUntil.IsZero() && m.activeUntil.Before(now) {
// Current message expired, remove it if we have one
if len(m.queue) > 0 {
m.queue = m.queue[1:]
2025-05-09 01:03:59 +08:00
}
2025-05-16 01:04:15 +08:00
m.activeUntil = time.Time{}
2025-05-09 01:03:59 +08:00
}
2025-05-16 01:04:15 +08:00
// If we have messages in queue but none are active, activate the first one
if len(m.queue) > 0 && m.activeUntil.IsZero() {
// Use custom duration if specified, otherwise use default
duration := m.messageTTL
if m.queue[0].Duration > 0 {
duration = m.queue[0].Duration
}
m.activeUntil = now.Add(duration)
}
2025-05-09 01:03:59 +08:00
return m, m.clearMessageCmd()
2025-03-23 21:56:32 +08:00
}
return m, nil
}
2025-04-28 21:46:09 +08:00
var helpWidget = ""
// getHelpWidget returns the help widget with current theme colors
func getHelpWidget(helpText string) string {
t := theme.CurrentTheme()
if helpText == "" {
helpText = "ctrl+? help"
}
return styles.Padded().
Background(t.TextMuted()).
Foreground(t.BackgroundDarker()).
Bold(true).
Render(helpText)
}
2025-03-23 21:56:32 +08:00
2025-05-30 23:37:09 +08:00
func formatTokensAndCost(tokens float32, contextWindow float32, cost float32) string {
2025-04-17 19:45:26 +08:00
// Format tokens in human-readable format (e.g., 110K, 1.2M)
var formattedTokens string
switch {
case tokens >= 1_000_000:
formattedTokens = fmt.Sprintf("%.1fM", float64(tokens)/1_000_000)
case tokens >= 1_000:
formattedTokens = fmt.Sprintf("%.1fK", float64(tokens)/1_000)
default:
2025-05-30 23:37:09 +08:00
formattedTokens = fmt.Sprintf("%d", int(tokens))
2025-04-17 19:45:26 +08:00
}
// Remove .0 suffix if present
if strings.HasSuffix(formattedTokens, ".0K") {
formattedTokens = strings.Replace(formattedTokens, ".0K", "K", 1)
}
if strings.HasSuffix(formattedTokens, ".0M") {
formattedTokens = strings.Replace(formattedTokens, ".0M", "M", 1)
}
// Format cost with $ symbol and 2 decimal places
formattedCost := fmt.Sprintf("$%.2f", cost)
2025-05-13 03:08:57 +08:00
percentage := (float64(tokens) / float64(contextWindow)) * 100
return fmt.Sprintf("Tokens: %s (%d%%), Cost: %s", formattedTokens, int(percentage), formattedCost)
2025-04-17 19:45:26 +08:00
}
2025-03-23 21:56:32 +08:00
func (m statusCmp) View() string {
2025-04-28 21:46:09 +08:00
t := theme.CurrentTheme()
2025-05-30 04:10:44 +08:00
// modelID := config.Get().Agents[config.AgentPrimary].Model
// model := models.SupportedModels[modelID]
2025-04-28 21:46:09 +08:00
// Initialize the help widget
status := getHelpWidget("")
2025-05-30 23:37:09 +08:00
if m.app.Session.Id != "" {
tokens := float32(0)
cost := float32(0)
contextWindow := float32(200_000) // TODO: Get context window from model
for _, message := range m.app.Messages {
if message.Metadata.Assistant != nil {
cost += message.Metadata.Assistant.Cost
usage := message.Metadata.Assistant.Tokens
tokens += (usage.Input + usage.Output + usage.Reasoning)
}
}
tokensInfo := styles.Padded().
Background(t.Text()).
Foreground(t.BackgroundSecondary()).
Render(formatTokensAndCost(tokens, contextWindow, cost))
status += tokensInfo
}
2025-04-17 19:45:26 +08:00
2025-05-09 01:03:59 +08:00
diagnostics := styles.Padded().Background(t.BackgroundDarker()).Render(m.projectDiagnostics())
2025-04-28 21:46:09 +08:00
2025-05-13 03:08:57 +08:00
modelName := m.model()
2025-04-30 03:46:12 +08:00
statusWidth := max(
0,
m.width-
lipgloss.Width(status)-
2025-05-13 03:08:57 +08:00
lipgloss.Width(modelName)-
2025-04-30 03:46:12 +08:00
lipgloss.Width(diagnostics),
)
2025-05-16 01:04:15 +08:00
const minInlineWidth = 30
2025-05-09 01:03:59 +08:00
// Display the first status message if available
2025-05-16 01:04:15 +08:00
var statusMessage string
if len(m.queue) > 0 {
sm := m.queue[0]
2025-04-28 21:46:09 +08:00
infoStyle := styles.Padded().
2025-05-16 01:04:15 +08:00
Foreground(t.Background())
2025-05-09 01:03:59 +08:00
switch sm.Level {
case "info":
2025-04-28 21:46:09 +08:00
infoStyle = infoStyle.Background(t.Info())
2025-05-09 01:03:59 +08:00
case "warn":
2025-04-28 21:46:09 +08:00
infoStyle = infoStyle.Background(t.Warning())
2025-05-09 01:03:59 +08:00
case "error":
2025-04-28 21:46:09 +08:00
infoStyle = infoStyle.Background(t.Error())
2025-05-09 01:03:59 +08:00
case "debug":
infoStyle = infoStyle.Background(t.TextMuted())
2025-04-08 01:43:31 +08:00
}
2025-04-28 21:46:09 +08:00
2025-04-10 01:07:39 +08:00
// Truncate message if it's longer than available width
2025-05-09 01:03:59 +08:00
msg := sm.Message
2025-04-30 03:46:12 +08:00
availWidth := statusWidth - 10
2025-05-09 01:03:59 +08:00
2025-05-16 01:04:15 +08:00
// If we have enough space, show inline
if availWidth >= minInlineWidth {
if len(msg) > availWidth && availWidth > 0 {
msg = msg[:availWidth] + "..."
}
status += infoStyle.Width(statusWidth).Render(msg)
} else {
// Otherwise, prepare a full-width message to show above
if len(msg) > m.width-10 && m.width > 10 {
msg = msg[:m.width-10] + "..."
}
statusMessage = infoStyle.Width(m.width).Render(msg)
// Add empty space in the status bar
status += styles.Padded().
Foreground(t.Text()).
Background(t.BackgroundSecondary()).
Width(statusWidth).
Render("")
}
2025-03-23 21:56:32 +08:00
} else {
2025-04-28 21:46:09 +08:00
status += styles.Padded().
Foreground(t.Text()).
Background(t.BackgroundSecondary()).
2025-04-30 03:46:12 +08:00
Width(statusWidth).
2025-04-08 01:43:31 +08:00
Render("")
2025-03-23 21:56:32 +08:00
}
2025-04-17 19:45:26 +08:00
status += diagnostics
2025-05-13 03:08:57 +08:00
status += modelName
2025-05-16 01:04:15 +08:00
// If we have a separate status message, prepend it
if statusMessage != "" {
return statusMessage + "\n" + status
} else {
blank := styles.BaseStyle().Background(t.Background()).Width(m.width).Render("")
return blank + "\n" + status
}
2025-03-23 21:56:32 +08:00
}
func (m *statusCmp) projectDiagnostics() string {
2025-04-28 21:46:09 +08:00
t := theme.CurrentTheme()
2025-04-19 21:15:29 +08:00
// Check if any LSP server is still initializing
initializing := false
2025-05-29 22:42:56 +08:00
// for _, client := range m.app.LSPClients {
// if client.GetServerState() == lsp.StateStarting {
// initializing = true
// break
// }
// }
2025-04-21 19:33:51 +08:00
2025-04-19 21:15:29 +08:00
// If any server is initializing, show that status
if initializing {
return lipgloss.NewStyle().
2025-04-28 21:46:09 +08:00
Foreground(t.Warning()).
2025-04-19 21:15:29 +08:00
Render(fmt.Sprintf("%s Initializing LSP...", styles.SpinnerIcon))
}
2025-04-21 19:33:51 +08:00
2025-05-30 04:10:44 +08:00
// errorDiagnostics := []protocol.Diagnostic{}
// warnDiagnostics := []protocol.Diagnostic{}
// hintDiagnostics := []protocol.Diagnostic{}
// infoDiagnostics := []protocol.Diagnostic{}
2025-05-29 22:42:56 +08:00
// for _, client := range m.app.LSPClients {
// for _, d := range client.GetDiagnostics() {
// for _, diag := range d {
// switch diag.Severity {
// case protocol.SeverityError:
// errorDiagnostics = append(errorDiagnostics, diag)
// case protocol.SeverityWarning:
// warnDiagnostics = append(warnDiagnostics, diag)
// case protocol.SeverityHint:
// hintDiagnostics = append(hintDiagnostics, diag)
// case protocol.SeverityInformation:
// infoDiagnostics = append(infoDiagnostics, diag)
// }
// }
// }
// }
2025-05-01 04:23:19 +08:00
return styles.ForceReplaceBackgroundWithLipgloss(
2025-05-30 04:10:44 +08:00
styles.Padded().Render("No diagnostics"),
2025-05-01 04:23:19 +08:00
t.BackgroundDarker(),
)
2025-05-30 04:10:44 +08:00
// if len(errorDiagnostics) == 0 &&
// len(warnDiagnostics) == 0 &&
// len(infoDiagnostics) == 0 &&
// len(hintDiagnostics) == 0 {
// return styles.ForceReplaceBackgroundWithLipgloss(
// styles.Padded().Render("No diagnostics"),
// t.BackgroundDarker(),
// )
// }
// diagnostics := []string{}
//
// errStr := lipgloss.NewStyle().
// Background(t.BackgroundDarker()).
// Foreground(t.Error()).
// Render(fmt.Sprintf("%s %d", styles.ErrorIcon, len(errorDiagnostics)))
// diagnostics = append(diagnostics, errStr)
//
// warnStr := lipgloss.NewStyle().
// Background(t.BackgroundDarker()).
// Foreground(t.Warning()).
// Render(fmt.Sprintf("%s %d", styles.WarningIcon, len(warnDiagnostics)))
// diagnostics = append(diagnostics, warnStr)
//
// infoStr := lipgloss.NewStyle().
// Background(t.BackgroundDarker()).
// Foreground(t.Info()).
// Render(fmt.Sprintf("%s %d", styles.InfoIcon, len(infoDiagnostics)))
// diagnostics = append(diagnostics, infoStr)
//
// hintStr := lipgloss.NewStyle().
// Background(t.BackgroundDarker()).
// Foreground(t.Text()).
// Render(fmt.Sprintf("%s %d", styles.HintIcon, len(hintDiagnostics)))
// diagnostics = append(diagnostics, hintStr)
//
// return styles.ForceReplaceBackgroundWithLipgloss(
// styles.Padded().Render(strings.Join(diagnostics, " ")),
// t.BackgroundDarker(),
// )
}
2025-03-28 05:35:48 +08:00
func (m statusCmp) model() string {
2025-04-28 21:46:09 +08:00
t := theme.CurrentTheme()
2025-05-30 04:10:44 +08:00
model := "Claude Sonnet 4" // models.SupportedModels[coder.Model]
2025-04-28 21:46:09 +08:00
return styles.Padded().
Background(t.Secondary()).
Foreground(t.Background()).
2025-05-30 04:10:44 +08:00
Render(model)
2025-03-23 21:56:32 +08:00
}
2025-05-01 18:48:19 +08:00
func (m statusCmp) SetHelpWidgetMsg(s string) {
2025-04-28 21:46:09 +08:00
// Update the help widget text using the getHelpWidget function
helpWidget = getHelpWidget(s)
2025-04-21 19:33:51 +08:00
}
2025-05-15 02:06:09 +08:00
func NewStatusCmp(app *app.App) StatusCmp {
2025-04-28 21:46:09 +08:00
// Initialize the help widget with default text
helpWidget = getHelpWidget("")
2025-05-09 01:03:59 +08:00
statusComponent := &statusCmp{
2025-05-16 01:04:15 +08:00
app: app,
queue: []status.StatusMessage{},
messageTTL: 4 * time.Second,
activeUntil: time.Time{},
}
2025-05-09 01:03:59 +08:00
return statusComponent
2025-03-23 21:56:32 +08:00
}