summaryrefslogtreecommitdiffhomepage
path: root/pkg/tui/components/core
diff options
context:
space:
mode:
Diffstat (limited to 'pkg/tui/components/core')
-rw-r--r--pkg/tui/components/core/status.go359
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
+}