diff options
Diffstat (limited to 'pkg/tui/components/core')
| -rw-r--r-- | pkg/tui/components/core/status.go | 359 |
1 files changed, 359 insertions, 0 deletions
diff --git a/pkg/tui/components/core/status.go b/pkg/tui/components/core/status.go new file mode 100644 index 000000000..2ac48cfdc --- /dev/null +++ b/pkg/tui/components/core/status.go @@ -0,0 +1,359 @@ +package core + +import ( + "fmt" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/sst/opencode/internal/app" + "github.com/sst/opencode/internal/config" + "github.com/sst/opencode/internal/llm/models" + "github.com/sst/opencode/internal/lsp" + "github.com/sst/opencode/internal/lsp/protocol" + "github.com/sst/opencode/internal/pubsub" + "github.com/sst/opencode/internal/status" + "github.com/sst/opencode/internal/tui/styles" + "github.com/sst/opencode/internal/tui/theme" +) + +type StatusCmp interface { + tea.Model + SetHelpWidgetMsg(string) +} + +type statusCmp struct { + 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 +func (m statusCmp) clearMessageCmd() tea.Cmd { + return tea.Tick(time.Second, func(t time.Time) tea.Msg { + return statusCleanupMsg{time: t} + }) +} + +// statusCleanupMsg is a message that triggers cleanup of expired status messages +type statusCleanupMsg struct { + time time.Time +} + +func (m statusCmp) Init() tea.Cmd { + return m.clearMessageCmd() +} + +func (m statusCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + return m, nil + case pubsub.Event[status.StatusMessage]: + if msg.Type == status.EventStatusPublished { + // 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) + } + } + } + case statusCleanupMsg: + 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:] + } + m.activeUntil = time.Time{} + } + + // 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) + } + + return m, m.clearMessageCmd() + } + return m, nil +} + +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) +} + +func formatTokensAndCost(tokens int64, contextWindow int64, cost float64) string { + // 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: + formattedTokens = fmt.Sprintf("%d", tokens) + } + + // 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) + + percentage := (float64(tokens) / float64(contextWindow)) * 100 + + return fmt.Sprintf("Tokens: %s (%d%%), Cost: %s", formattedTokens, int(percentage), formattedCost) +} + +func (m statusCmp) View() string { + t := theme.CurrentTheme() + modelID := config.Get().Agents[config.AgentPrimary].Model + model := models.SupportedModels[modelID] + + // Initialize the help widget + status := getHelpWidget("") + + if m.app.CurrentSession.ID != "" { + tokens := formatTokensAndCost(m.app.CurrentSession.PromptTokens+m.app.CurrentSession.CompletionTokens, model.ContextWindow, m.app.CurrentSession.Cost) + tokensStyle := styles.Padded(). + Background(t.Text()). + Foreground(t.BackgroundSecondary()). + Render(tokens) + status += tokensStyle + } + + diagnostics := styles.Padded().Background(t.BackgroundDarker()).Render(m.projectDiagnostics()) + + modelName := m.model() + + statusWidth := max( + 0, + m.width- + lipgloss.Width(status)- + lipgloss.Width(modelName)- + lipgloss.Width(diagnostics), + ) + + const minInlineWidth = 30 + + // Display the first status message if available + var statusMessage string + if len(m.queue) > 0 { + sm := m.queue[0] + infoStyle := styles.Padded(). + Foreground(t.Background()) + + switch sm.Level { + case "info": + infoStyle = infoStyle.Background(t.Info()) + case "warn": + infoStyle = infoStyle.Background(t.Warning()) + case "error": + infoStyle = infoStyle.Background(t.Error()) + case "debug": + infoStyle = infoStyle.Background(t.TextMuted()) + } + + // Truncate message if it's longer than available width + msg := sm.Message + availWidth := statusWidth - 10 + + // 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("") + } + } else { + status += styles.Padded(). + Foreground(t.Text()). + Background(t.BackgroundSecondary()). + Width(statusWidth). + Render("") + } + + status += diagnostics + status += modelName + + // 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 + } +} + +func (m *statusCmp) projectDiagnostics() string { + t := theme.CurrentTheme() + + // Check if any LSP server is still initializing + initializing := false + for _, client := range m.app.LSPClients { + if client.GetServerState() == lsp.StateStarting { + initializing = true + break + } + } + + // If any server is initializing, show that status + if initializing { + return lipgloss.NewStyle(). + Foreground(t.Warning()). + Render(fmt.Sprintf("%s Initializing LSP...", styles.SpinnerIcon)) + } + + errorDiagnostics := []protocol.Diagnostic{} + warnDiagnostics := []protocol.Diagnostic{} + hintDiagnostics := []protocol.Diagnostic{} + infoDiagnostics := []protocol.Diagnostic{} + 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) + } + } + } + } + + 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(), + ) +} + +func (m statusCmp) model() string { + t := theme.CurrentTheme() + + cfg := config.Get() + + coder, ok := cfg.Agents[config.AgentPrimary] + if !ok { + return "Unknown" + } + model := models.SupportedModels[coder.Model] + + return styles.Padded(). + Background(t.Secondary()). + Foreground(t.Background()). + Render(model.Name) +} + +func (m statusCmp) SetHelpWidgetMsg(s string) { + // Update the help widget text using the getHelpWidget function + helpWidget = getHelpWidget(s) +} + +func NewStatusCmp(app *app.App) StatusCmp { + // Initialize the help widget with default text + helpWidget = getHelpWidget("") + + statusComponent := &statusCmp{ + app: app, + queue: []status.StatusMessage{}, + messageTTL: 4 * time.Second, + activeUntil: time.Time{}, + } + + return statusComponent +} |
