status.go 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265
  1. package core
  2. import (
  3. "fmt"
  4. "strings"
  5. "time"
  6. tea "github.com/charmbracelet/bubbletea/v2"
  7. "github.com/charmbracelet/lipgloss/v2"
  8. "github.com/sst/opencode/internal/app"
  9. "github.com/sst/opencode/internal/layout"
  10. "github.com/sst/opencode/internal/pubsub"
  11. "github.com/sst/opencode/internal/status"
  12. "github.com/sst/opencode/internal/styles"
  13. "github.com/sst/opencode/internal/theme"
  14. )
  15. type StatusComponent interface {
  16. layout.ModelWithView
  17. }
  18. type statusComponent struct {
  19. app *app.App
  20. queue []status.StatusMessage
  21. width int
  22. messageTTL time.Duration
  23. activeUntil time.Time
  24. }
  25. // clearMessageCmd is a command that clears status messages after a timeout
  26. func (m statusComponent) clearMessageCmd() tea.Cmd {
  27. return tea.Tick(time.Second, func(t time.Time) tea.Msg {
  28. return statusCleanupMsg{time: t}
  29. })
  30. }
  31. // statusCleanupMsg is a message that triggers cleanup of expired status messages
  32. type statusCleanupMsg struct {
  33. time time.Time
  34. }
  35. func (m statusComponent) Init() tea.Cmd {
  36. return m.clearMessageCmd()
  37. }
  38. func (m statusComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
  39. switch msg := msg.(type) {
  40. case tea.WindowSizeMsg:
  41. m.width = msg.Width
  42. return m, nil
  43. case pubsub.Event[status.StatusMessage]:
  44. if msg.Type == status.EventStatusPublished {
  45. // If this is a critical message, move it to the front of the queue
  46. if msg.Payload.Critical {
  47. // Insert at the front of the queue
  48. m.queue = append([]status.StatusMessage{msg.Payload}, m.queue...)
  49. // Reset active time to show critical message immediately
  50. m.activeUntil = time.Time{}
  51. } else {
  52. // Otherwise, just add it to the queue
  53. m.queue = append(m.queue, msg.Payload)
  54. // If this is the first message and nothing is active, activate it immediately
  55. if len(m.queue) == 1 && m.activeUntil.IsZero() {
  56. now := time.Now()
  57. duration := m.messageTTL
  58. if msg.Payload.Duration > 0 {
  59. duration = msg.Payload.Duration
  60. }
  61. m.activeUntil = now.Add(duration)
  62. }
  63. }
  64. }
  65. case statusCleanupMsg:
  66. now := msg.time
  67. // If the active message has expired, remove it and activate the next one
  68. if !m.activeUntil.IsZero() && m.activeUntil.Before(now) {
  69. // Current message expired, remove it if we have one
  70. if len(m.queue) > 0 {
  71. m.queue = m.queue[1:]
  72. }
  73. m.activeUntil = time.Time{}
  74. }
  75. // If we have messages in queue but none are active, activate the first one
  76. if len(m.queue) > 0 && m.activeUntil.IsZero() {
  77. // Use custom duration if specified, otherwise use default
  78. duration := m.messageTTL
  79. if m.queue[0].Duration > 0 {
  80. duration = m.queue[0].Duration
  81. }
  82. m.activeUntil = now.Add(duration)
  83. }
  84. return m, m.clearMessageCmd()
  85. }
  86. return m, nil
  87. }
  88. func logo() string {
  89. t := theme.CurrentTheme()
  90. base := lipgloss.NewStyle().Background(t.BackgroundElement()).Foreground(t.TextMuted()).Render
  91. emphasis := lipgloss.NewStyle().Bold(true).Background(t.BackgroundElement()).Foreground(t.Text()).Render
  92. open := base("open")
  93. code := emphasis("code ")
  94. version := base(app.Info.Version)
  95. return styles.Padded().
  96. Background(t.BackgroundElement()).
  97. Render(open + code + version)
  98. }
  99. func formatTokensAndCost(tokens float32, contextWindow float32, cost float32) string {
  100. // Format tokens in human-readable format (e.g., 110K, 1.2M)
  101. var formattedTokens string
  102. switch {
  103. case tokens >= 1_000_000:
  104. formattedTokens = fmt.Sprintf("%.1fM", float64(tokens)/1_000_000)
  105. case tokens >= 1_000:
  106. formattedTokens = fmt.Sprintf("%.1fK", float64(tokens)/1_000)
  107. default:
  108. formattedTokens = fmt.Sprintf("%d", int(tokens))
  109. }
  110. // Remove .0 suffix if present
  111. if strings.HasSuffix(formattedTokens, ".0K") {
  112. formattedTokens = strings.Replace(formattedTokens, ".0K", "K", 1)
  113. }
  114. if strings.HasSuffix(formattedTokens, ".0M") {
  115. formattedTokens = strings.Replace(formattedTokens, ".0M", "M", 1)
  116. }
  117. // Format cost with $ symbol and 2 decimal places
  118. formattedCost := fmt.Sprintf("$%.2f", cost)
  119. percentage := (float64(tokens) / float64(contextWindow)) * 100
  120. return fmt.Sprintf("Tokens: %s (%d%%), Cost: %s", formattedTokens, int(percentage), formattedCost)
  121. }
  122. func (m statusComponent) View() string {
  123. t := theme.CurrentTheme()
  124. if m.app.Session.Id == "" {
  125. return styles.BaseStyle().
  126. Background(t.Background()).
  127. Width(m.width).
  128. Height(2).
  129. Render("")
  130. }
  131. logo := logo()
  132. cwd := styles.Padded().
  133. Foreground(t.TextMuted()).
  134. Background(t.BackgroundSubtle()).
  135. Render(app.Info.Path.Cwd)
  136. sessionInfo := ""
  137. if m.app.Session.Id != "" {
  138. tokens := float32(0)
  139. cost := float32(0)
  140. contextWindow := m.app.Model.Limit.Context
  141. for _, message := range m.app.Messages {
  142. if message.Metadata.Assistant != nil {
  143. cost += message.Metadata.Assistant.Cost
  144. usage := message.Metadata.Assistant.Tokens
  145. if usage.Output > 0 {
  146. tokens = (usage.Input + usage.Output + usage.Reasoning)
  147. }
  148. }
  149. }
  150. sessionInfo = styles.Padded().
  151. Background(t.BackgroundElement()).
  152. Foreground(t.TextMuted()).
  153. Render(formatTokensAndCost(tokens, contextWindow, cost))
  154. }
  155. // diagnostics := styles.Padded().Background(t.BackgroundElement()).Render(m.projectDiagnostics())
  156. space := max(
  157. 0,
  158. m.width-lipgloss.Width(logo)-lipgloss.Width(cwd)-lipgloss.Width(sessionInfo),
  159. )
  160. spacer := lipgloss.NewStyle().Background(t.BackgroundSubtle()).Width(space).Render("")
  161. status := logo + cwd + spacer + sessionInfo
  162. blank := styles.BaseStyle().Background(t.Background()).Width(m.width).Render("")
  163. return blank + "\n" + status
  164. // Display the first status message if available
  165. // var statusMessage string
  166. // if len(m.queue) > 0 {
  167. // sm := m.queue[0]
  168. // infoStyle := styles.Padded().
  169. // Foreground(t.Background())
  170. //
  171. // switch sm.Level {
  172. // case "info":
  173. // infoStyle = infoStyle.Background(t.Info())
  174. // case "warn":
  175. // infoStyle = infoStyle.Background(t.Warning())
  176. // case "error":
  177. // infoStyle = infoStyle.Background(t.Error())
  178. // case "debug":
  179. // infoStyle = infoStyle.Background(t.TextMuted())
  180. // }
  181. //
  182. // // Truncate message if it's longer than available width
  183. // msg := sm.Message
  184. // availWidth := statusWidth - 10
  185. //
  186. // // If we have enough space, show inline
  187. // if availWidth >= minInlineWidth {
  188. // if len(msg) > availWidth && availWidth > 0 {
  189. // msg = msg[:availWidth] + "..."
  190. // }
  191. // status += infoStyle.Width(statusWidth).Render(msg)
  192. // } else {
  193. // // Otherwise, prepare a full-width message to show above
  194. // if len(msg) > m.width-10 && m.width > 10 {
  195. // msg = msg[:m.width-10] + "..."
  196. // }
  197. // statusMessage = infoStyle.Width(m.width).Render(msg)
  198. //
  199. // // Add empty space in the status bar
  200. // status += styles.Padded().
  201. // Foreground(t.Text()).
  202. // Background(t.BackgroundSubtle()).
  203. // Width(statusWidth).
  204. // Render("")
  205. // }
  206. // } else {
  207. // status += styles.Padded().
  208. // Foreground(t.Text()).
  209. // Background(t.BackgroundSubtle()).
  210. // Width(statusWidth).
  211. // Render("")
  212. // }
  213. // status += diagnostics
  214. // status += modelName
  215. // If we have a separate status message, prepend it
  216. // if statusMessage != "" {
  217. // return statusMessage + "\n" + status
  218. // } else {
  219. // blank := styles.BaseStyle().Background(t.Background()).Width(m.width).Render("")
  220. // return blank + "\n" + status
  221. // }
  222. }
  223. func NewStatusCmp(app *app.App) StatusComponent {
  224. statusComponent := &statusComponent{
  225. app: app,
  226. queue: []status.StatusMessage{},
  227. messageTTL: 4 * time.Second,
  228. activeUntil: time.Time{},
  229. }
  230. return statusComponent
  231. }