status.go 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358
  1. package core
  2. import (
  3. "fmt"
  4. "strings"
  5. "time"
  6. tea "github.com/charmbracelet/bubbletea"
  7. "github.com/charmbracelet/lipgloss"
  8. "github.com/sst/opencode/internal/config"
  9. "github.com/sst/opencode/internal/llm/models"
  10. "github.com/sst/opencode/internal/lsp/protocol"
  11. "github.com/sst/opencode/internal/pubsub"
  12. "github.com/sst/opencode/internal/status"
  13. "github.com/sst/opencode/internal/tui/app"
  14. "github.com/sst/opencode/internal/tui/styles"
  15. "github.com/sst/opencode/internal/tui/theme"
  16. )
  17. type StatusCmp interface {
  18. tea.Model
  19. SetHelpWidgetMsg(string)
  20. }
  21. type statusCmp struct {
  22. app *app.App
  23. queue []status.StatusMessage
  24. width int
  25. messageTTL time.Duration
  26. activeUntil time.Time
  27. }
  28. // clearMessageCmd is a command that clears status messages after a timeout
  29. func (m statusCmp) clearMessageCmd() tea.Cmd {
  30. return tea.Tick(time.Second, func(t time.Time) tea.Msg {
  31. return statusCleanupMsg{time: t}
  32. })
  33. }
  34. // statusCleanupMsg is a message that triggers cleanup of expired status messages
  35. type statusCleanupMsg struct {
  36. time time.Time
  37. }
  38. func (m statusCmp) Init() tea.Cmd {
  39. return m.clearMessageCmd()
  40. }
  41. func (m statusCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
  42. switch msg := msg.(type) {
  43. case tea.WindowSizeMsg:
  44. m.width = msg.Width
  45. return m, nil
  46. case pubsub.Event[status.StatusMessage]:
  47. if msg.Type == status.EventStatusPublished {
  48. // If this is a critical message, move it to the front of the queue
  49. if msg.Payload.Critical {
  50. // Insert at the front of the queue
  51. m.queue = append([]status.StatusMessage{msg.Payload}, m.queue...)
  52. // Reset active time to show critical message immediately
  53. m.activeUntil = time.Time{}
  54. } else {
  55. // Otherwise, just add it to the queue
  56. m.queue = append(m.queue, msg.Payload)
  57. // If this is the first message and nothing is active, activate it immediately
  58. if len(m.queue) == 1 && m.activeUntil.IsZero() {
  59. now := time.Now()
  60. duration := m.messageTTL
  61. if msg.Payload.Duration > 0 {
  62. duration = msg.Payload.Duration
  63. }
  64. m.activeUntil = now.Add(duration)
  65. }
  66. }
  67. }
  68. case statusCleanupMsg:
  69. now := msg.time
  70. // If the active message has expired, remove it and activate the next one
  71. if !m.activeUntil.IsZero() && m.activeUntil.Before(now) {
  72. // Current message expired, remove it if we have one
  73. if len(m.queue) > 0 {
  74. m.queue = m.queue[1:]
  75. }
  76. m.activeUntil = time.Time{}
  77. }
  78. // If we have messages in queue but none are active, activate the first one
  79. if len(m.queue) > 0 && m.activeUntil.IsZero() {
  80. // Use custom duration if specified, otherwise use default
  81. duration := m.messageTTL
  82. if m.queue[0].Duration > 0 {
  83. duration = m.queue[0].Duration
  84. }
  85. m.activeUntil = now.Add(duration)
  86. }
  87. return m, m.clearMessageCmd()
  88. }
  89. return m, nil
  90. }
  91. var helpWidget = ""
  92. // getHelpWidget returns the help widget with current theme colors
  93. func getHelpWidget(helpText string) string {
  94. t := theme.CurrentTheme()
  95. if helpText == "" {
  96. helpText = "ctrl+? help"
  97. }
  98. return styles.Padded().
  99. Background(t.TextMuted()).
  100. Foreground(t.BackgroundDarker()).
  101. Bold(true).
  102. Render(helpText)
  103. }
  104. func formatTokensAndCost(tokens int64, contextWindow int64, cost float64) string {
  105. // Format tokens in human-readable format (e.g., 110K, 1.2M)
  106. var formattedTokens string
  107. switch {
  108. case tokens >= 1_000_000:
  109. formattedTokens = fmt.Sprintf("%.1fM", float64(tokens)/1_000_000)
  110. case tokens >= 1_000:
  111. formattedTokens = fmt.Sprintf("%.1fK", float64(tokens)/1_000)
  112. default:
  113. formattedTokens = fmt.Sprintf("%d", tokens)
  114. }
  115. // Remove .0 suffix if present
  116. if strings.HasSuffix(formattedTokens, ".0K") {
  117. formattedTokens = strings.Replace(formattedTokens, ".0K", "K", 1)
  118. }
  119. if strings.HasSuffix(formattedTokens, ".0M") {
  120. formattedTokens = strings.Replace(formattedTokens, ".0M", "M", 1)
  121. }
  122. // Format cost with $ symbol and 2 decimal places
  123. formattedCost := fmt.Sprintf("$%.2f", cost)
  124. percentage := (float64(tokens) / float64(contextWindow)) * 100
  125. return fmt.Sprintf("Tokens: %s (%d%%), Cost: %s", formattedTokens, int(percentage), formattedCost)
  126. }
  127. func (m statusCmp) View() string {
  128. t := theme.CurrentTheme()
  129. modelID := config.Get().Agents[config.AgentPrimary].Model
  130. model := models.SupportedModels[modelID]
  131. // Initialize the help widget
  132. status := getHelpWidget("")
  133. if m.app.CurrentSessionOLD.ID != "" {
  134. tokens := formatTokensAndCost(m.app.CurrentSessionOLD.PromptTokens+m.app.CurrentSessionOLD.CompletionTokens, model.ContextWindow, m.app.CurrentSessionOLD.Cost)
  135. tokensStyle := styles.Padded().
  136. Background(t.Text()).
  137. Foreground(t.BackgroundSecondary()).
  138. Render(tokens)
  139. status += tokensStyle
  140. }
  141. diagnostics := styles.Padded().Background(t.BackgroundDarker()).Render(m.projectDiagnostics())
  142. modelName := m.model()
  143. statusWidth := max(
  144. 0,
  145. m.width-
  146. lipgloss.Width(status)-
  147. lipgloss.Width(modelName)-
  148. lipgloss.Width(diagnostics),
  149. )
  150. const minInlineWidth = 30
  151. // Display the first status message if available
  152. var statusMessage string
  153. if len(m.queue) > 0 {
  154. sm := m.queue[0]
  155. infoStyle := styles.Padded().
  156. Foreground(t.Background())
  157. switch sm.Level {
  158. case "info":
  159. infoStyle = infoStyle.Background(t.Info())
  160. case "warn":
  161. infoStyle = infoStyle.Background(t.Warning())
  162. case "error":
  163. infoStyle = infoStyle.Background(t.Error())
  164. case "debug":
  165. infoStyle = infoStyle.Background(t.TextMuted())
  166. }
  167. // Truncate message if it's longer than available width
  168. msg := sm.Message
  169. availWidth := statusWidth - 10
  170. // If we have enough space, show inline
  171. if availWidth >= minInlineWidth {
  172. if len(msg) > availWidth && availWidth > 0 {
  173. msg = msg[:availWidth] + "..."
  174. }
  175. status += infoStyle.Width(statusWidth).Render(msg)
  176. } else {
  177. // Otherwise, prepare a full-width message to show above
  178. if len(msg) > m.width-10 && m.width > 10 {
  179. msg = msg[:m.width-10] + "..."
  180. }
  181. statusMessage = infoStyle.Width(m.width).Render(msg)
  182. // Add empty space in the status bar
  183. status += styles.Padded().
  184. Foreground(t.Text()).
  185. Background(t.BackgroundSecondary()).
  186. Width(statusWidth).
  187. Render("")
  188. }
  189. } else {
  190. status += styles.Padded().
  191. Foreground(t.Text()).
  192. Background(t.BackgroundSecondary()).
  193. Width(statusWidth).
  194. Render("")
  195. }
  196. status += diagnostics
  197. status += modelName
  198. // If we have a separate status message, prepend it
  199. if statusMessage != "" {
  200. return statusMessage + "\n" + status
  201. } else {
  202. blank := styles.BaseStyle().Background(t.Background()).Width(m.width).Render("")
  203. return blank + "\n" + status
  204. }
  205. }
  206. func (m *statusCmp) projectDiagnostics() string {
  207. t := theme.CurrentTheme()
  208. // Check if any LSP server is still initializing
  209. initializing := false
  210. // for _, client := range m.app.LSPClients {
  211. // if client.GetServerState() == lsp.StateStarting {
  212. // initializing = true
  213. // break
  214. // }
  215. // }
  216. // If any server is initializing, show that status
  217. if initializing {
  218. return lipgloss.NewStyle().
  219. Foreground(t.Warning()).
  220. Render(fmt.Sprintf("%s Initializing LSP...", styles.SpinnerIcon))
  221. }
  222. errorDiagnostics := []protocol.Diagnostic{}
  223. warnDiagnostics := []protocol.Diagnostic{}
  224. hintDiagnostics := []protocol.Diagnostic{}
  225. infoDiagnostics := []protocol.Diagnostic{}
  226. // for _, client := range m.app.LSPClients {
  227. // for _, d := range client.GetDiagnostics() {
  228. // for _, diag := range d {
  229. // switch diag.Severity {
  230. // case protocol.SeverityError:
  231. // errorDiagnostics = append(errorDiagnostics, diag)
  232. // case protocol.SeverityWarning:
  233. // warnDiagnostics = append(warnDiagnostics, diag)
  234. // case protocol.SeverityHint:
  235. // hintDiagnostics = append(hintDiagnostics, diag)
  236. // case protocol.SeverityInformation:
  237. // infoDiagnostics = append(infoDiagnostics, diag)
  238. // }
  239. // }
  240. // }
  241. // }
  242. if len(errorDiagnostics) == 0 &&
  243. len(warnDiagnostics) == 0 &&
  244. len(infoDiagnostics) == 0 &&
  245. len(hintDiagnostics) == 0 {
  246. return styles.ForceReplaceBackgroundWithLipgloss(
  247. styles.Padded().Render("No diagnostics"),
  248. t.BackgroundDarker(),
  249. )
  250. }
  251. diagnostics := []string{}
  252. errStr := lipgloss.NewStyle().
  253. Background(t.BackgroundDarker()).
  254. Foreground(t.Error()).
  255. Render(fmt.Sprintf("%s %d", styles.ErrorIcon, len(errorDiagnostics)))
  256. diagnostics = append(diagnostics, errStr)
  257. warnStr := lipgloss.NewStyle().
  258. Background(t.BackgroundDarker()).
  259. Foreground(t.Warning()).
  260. Render(fmt.Sprintf("%s %d", styles.WarningIcon, len(warnDiagnostics)))
  261. diagnostics = append(diagnostics, warnStr)
  262. infoStr := lipgloss.NewStyle().
  263. Background(t.BackgroundDarker()).
  264. Foreground(t.Info()).
  265. Render(fmt.Sprintf("%s %d", styles.InfoIcon, len(infoDiagnostics)))
  266. diagnostics = append(diagnostics, infoStr)
  267. hintStr := lipgloss.NewStyle().
  268. Background(t.BackgroundDarker()).
  269. Foreground(t.Text()).
  270. Render(fmt.Sprintf("%s %d", styles.HintIcon, len(hintDiagnostics)))
  271. diagnostics = append(diagnostics, hintStr)
  272. return styles.ForceReplaceBackgroundWithLipgloss(
  273. styles.Padded().Render(strings.Join(diagnostics, " ")),
  274. t.BackgroundDarker(),
  275. )
  276. }
  277. func (m statusCmp) model() string {
  278. t := theme.CurrentTheme()
  279. cfg := config.Get()
  280. coder, ok := cfg.Agents[config.AgentPrimary]
  281. if !ok {
  282. return "Unknown"
  283. }
  284. model := models.SupportedModels[coder.Model]
  285. return styles.Padded().
  286. Background(t.Secondary()).
  287. Foreground(t.Background()).
  288. Render(model.Name)
  289. }
  290. func (m statusCmp) SetHelpWidgetMsg(s string) {
  291. // Update the help widget text using the getHelpWidget function
  292. helpWidget = getHelpWidget(s)
  293. }
  294. func NewStatusCmp(app *app.App) StatusCmp {
  295. // Initialize the help widget with default text
  296. helpWidget = getHelpWidget("")
  297. statusComponent := &statusCmp{
  298. app: app,
  299. queue: []status.StatusMessage{},
  300. messageTTL: 4 * time.Second,
  301. activeUntil: time.Time{},
  302. }
  303. return statusComponent
  304. }