status.go 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356
  1. package status
  2. import (
  3. "os"
  4. "os/exec"
  5. "path/filepath"
  6. "strings"
  7. "time"
  8. tea "github.com/charmbracelet/bubbletea/v2"
  9. "github.com/charmbracelet/lipgloss/v2"
  10. "github.com/charmbracelet/lipgloss/v2/compat"
  11. "github.com/fsnotify/fsnotify"
  12. "github.com/sst/opencode/internal/app"
  13. "github.com/sst/opencode/internal/commands"
  14. "github.com/sst/opencode/internal/layout"
  15. "github.com/sst/opencode/internal/styles"
  16. "github.com/sst/opencode/internal/theme"
  17. "github.com/sst/opencode/internal/util"
  18. )
  19. type GitBranchUpdatedMsg struct {
  20. Branch string
  21. }
  22. type StatusComponent interface {
  23. tea.Model
  24. tea.ViewModel
  25. Cleanup()
  26. }
  27. type statusComponent struct {
  28. app *app.App
  29. width int
  30. cwd string
  31. branch string
  32. watcher *fsnotify.Watcher
  33. done chan struct{}
  34. lastUpdate time.Time
  35. }
  36. func (m *statusComponent) Init() tea.Cmd {
  37. return m.startGitWatcher()
  38. }
  39. func (m *statusComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
  40. switch msg := msg.(type) {
  41. case tea.WindowSizeMsg:
  42. m.width = msg.Width
  43. return m, nil
  44. case GitBranchUpdatedMsg:
  45. if m.branch != msg.Branch {
  46. m.branch = msg.Branch
  47. }
  48. // Continue watching for changes (persistent watcher)
  49. return m, m.watchForGitChanges()
  50. }
  51. return m, nil
  52. }
  53. func (m *statusComponent) logo() string {
  54. t := theme.CurrentTheme()
  55. base := styles.NewStyle().Foreground(t.TextMuted()).Background(t.BackgroundElement()).Render
  56. emphasis := styles.NewStyle().
  57. Foreground(t.Text()).
  58. Background(t.BackgroundElement()).
  59. Bold(true).
  60. Render
  61. open := base("open")
  62. code := emphasis("code")
  63. version := base(" " + m.app.Version)
  64. content := open + code
  65. if m.width > 40 {
  66. content += version
  67. }
  68. return styles.NewStyle().
  69. Background(t.BackgroundElement()).
  70. Padding(0, 1).
  71. Render(content)
  72. }
  73. func (m *statusComponent) collapsePath(path string, maxWidth int) string {
  74. if lipgloss.Width(path) <= maxWidth {
  75. return path
  76. }
  77. const ellipsis = ".."
  78. ellipsisLen := len(ellipsis)
  79. if maxWidth <= ellipsisLen {
  80. if maxWidth > 0 {
  81. return "..."[:maxWidth]
  82. }
  83. return ""
  84. }
  85. separator := string(filepath.Separator)
  86. parts := strings.Split(path, separator)
  87. if len(parts) == 1 {
  88. return path[:maxWidth-ellipsisLen] + ellipsis
  89. }
  90. truncatedPath := parts[len(parts)-1]
  91. for i := len(parts) - 2; i >= 0; i-- {
  92. part := parts[i]
  93. if len(truncatedPath)+len(separator)+len(part)+ellipsisLen > maxWidth {
  94. return ellipsis + separator + truncatedPath
  95. }
  96. truncatedPath = part + separator + truncatedPath
  97. }
  98. return truncatedPath
  99. }
  100. func (m *statusComponent) View() string {
  101. t := theme.CurrentTheme()
  102. logo := m.logo()
  103. logoWidth := lipgloss.Width(logo)
  104. var modeBackground compat.AdaptiveColor
  105. var modeForeground compat.AdaptiveColor
  106. switch m.app.AgentIndex {
  107. case 0:
  108. modeBackground = t.BackgroundElement()
  109. modeForeground = t.TextMuted()
  110. case 1:
  111. modeBackground = t.Secondary()
  112. modeForeground = t.BackgroundPanel()
  113. case 2:
  114. modeBackground = t.Accent()
  115. modeForeground = t.BackgroundPanel()
  116. case 3:
  117. modeBackground = t.Success()
  118. modeForeground = t.BackgroundPanel()
  119. case 4:
  120. modeBackground = t.Warning()
  121. modeForeground = t.BackgroundPanel()
  122. case 5:
  123. modeBackground = t.Primary()
  124. modeForeground = t.BackgroundPanel()
  125. case 6:
  126. modeBackground = t.Error()
  127. modeForeground = t.BackgroundPanel()
  128. default:
  129. modeBackground = t.Secondary()
  130. modeForeground = t.BackgroundPanel()
  131. }
  132. command := m.app.Commands[commands.SwitchAgentCommand]
  133. kb := command.Keybindings[0]
  134. key := kb.Key
  135. if kb.RequiresLeader {
  136. key = m.app.Config.Keybinds.Leader + " " + kb.Key
  137. }
  138. agentStyle := styles.NewStyle().Background(modeBackground).Foreground(modeForeground)
  139. agentNameStyle := agentStyle.Bold(true).Render
  140. agentDescStyle := agentStyle.Render
  141. agent := agentNameStyle(strings.ToUpper(m.app.Agent().Name)) + agentDescStyle(" AGENT")
  142. agent = agentStyle.
  143. Padding(0, 1).
  144. BorderLeft(true).
  145. BorderStyle(lipgloss.ThickBorder()).
  146. BorderForeground(modeBackground).
  147. BorderBackground(t.BackgroundPanel()).
  148. Render(agent)
  149. faintStyle := styles.NewStyle().
  150. Faint(true).
  151. Background(t.BackgroundPanel()).
  152. Foreground(t.TextMuted())
  153. agent = faintStyle.Render(key+" ") + agent
  154. modeWidth := lipgloss.Width(agent)
  155. availableWidth := m.width - logoWidth - modeWidth
  156. branchSuffix := ""
  157. if m.branch != "" {
  158. branchSuffix = ":" + m.branch
  159. }
  160. maxCwdWidth := availableWidth - lipgloss.Width(branchSuffix)
  161. cwdDisplay := m.collapsePath(m.cwd, maxCwdWidth)
  162. if m.branch != "" && availableWidth > lipgloss.Width(cwdDisplay)+lipgloss.Width(branchSuffix) {
  163. cwdDisplay += faintStyle.Render(branchSuffix)
  164. }
  165. cwd := styles.NewStyle().
  166. Foreground(t.TextMuted()).
  167. Background(t.BackgroundPanel()).
  168. Padding(0, 1).
  169. Render(cwdDisplay)
  170. background := t.BackgroundPanel()
  171. status := layout.Render(
  172. layout.FlexOptions{
  173. Background: &background,
  174. Direction: layout.Row,
  175. Justify: layout.JustifySpaceBetween,
  176. Align: layout.AlignStretch,
  177. Width: m.width,
  178. },
  179. layout.FlexItem{
  180. View: logo + cwd,
  181. },
  182. layout.FlexItem{
  183. View: agent,
  184. },
  185. )
  186. blank := styles.NewStyle().Background(t.Background()).Width(m.width).Render("")
  187. return blank + "\n" + status
  188. }
  189. func (m *statusComponent) startGitWatcher() tea.Cmd {
  190. cmd := util.CmdHandler(
  191. GitBranchUpdatedMsg{Branch: getCurrentGitBranch(m.app.Info.Path.Root)},
  192. )
  193. if err := m.initWatcher(); err != nil {
  194. return cmd
  195. }
  196. return tea.Batch(cmd, m.watchForGitChanges())
  197. }
  198. func (m *statusComponent) initWatcher() error {
  199. gitDir := filepath.Join(m.app.Info.Path.Root, ".git")
  200. headFile := filepath.Join(gitDir, "HEAD")
  201. if info, err := os.Stat(gitDir); err != nil || !info.IsDir() {
  202. return err
  203. }
  204. watcher, err := fsnotify.NewWatcher()
  205. if err != nil {
  206. return err
  207. }
  208. if err := watcher.Add(headFile); err != nil {
  209. watcher.Close()
  210. return err
  211. }
  212. // Also watch the ref file if HEAD points to a ref
  213. refFile := getGitRefFile(m.app.Info.Path.Cwd)
  214. if refFile != headFile && refFile != "" {
  215. if _, err := os.Stat(refFile); err == nil {
  216. watcher.Add(refFile) // Ignore error, HEAD watching is sufficient
  217. }
  218. }
  219. m.watcher = watcher
  220. m.done = make(chan struct{})
  221. return nil
  222. }
  223. func (m *statusComponent) watchForGitChanges() tea.Cmd {
  224. if m.watcher == nil {
  225. return nil
  226. }
  227. return tea.Cmd(func() tea.Msg {
  228. for {
  229. select {
  230. case event, ok := <-m.watcher.Events:
  231. branch := getCurrentGitBranch(m.app.Info.Path.Root)
  232. if !ok {
  233. return GitBranchUpdatedMsg{Branch: branch}
  234. }
  235. if event.Has(fsnotify.Write) || event.Has(fsnotify.Create) {
  236. // Debounce updates to prevent excessive refreshes
  237. now := time.Now()
  238. if now.Sub(m.lastUpdate) < 100*time.Millisecond {
  239. continue
  240. }
  241. m.lastUpdate = now
  242. if strings.HasSuffix(event.Name, "HEAD") {
  243. m.updateWatchedFiles()
  244. }
  245. return GitBranchUpdatedMsg{Branch: branch}
  246. }
  247. case <-m.watcher.Errors:
  248. // Continue watching even on errors
  249. case <-m.done:
  250. return GitBranchUpdatedMsg{Branch: ""}
  251. }
  252. }
  253. })
  254. }
  255. func (m *statusComponent) updateWatchedFiles() {
  256. if m.watcher == nil {
  257. return
  258. }
  259. refFile := getGitRefFile(m.app.Info.Path.Root)
  260. headFile := filepath.Join(m.app.Info.Path.Root, ".git", "HEAD")
  261. if refFile != headFile && refFile != "" {
  262. if _, err := os.Stat(refFile); err == nil {
  263. // Try to add the new ref file (ignore error if already watching)
  264. m.watcher.Add(refFile)
  265. }
  266. }
  267. }
  268. func getCurrentGitBranch(cwd string) string {
  269. cmd := exec.Command("git", "branch", "--show-current")
  270. cmd.Dir = cwd
  271. output, err := cmd.Output()
  272. if err != nil {
  273. return ""
  274. }
  275. return strings.TrimSpace(string(output))
  276. }
  277. func getGitRefFile(cwd string) string {
  278. headFile := filepath.Join(cwd, ".git", "HEAD")
  279. content, err := os.ReadFile(headFile)
  280. if err != nil {
  281. return ""
  282. }
  283. headContent := strings.TrimSpace(string(content))
  284. if after, ok := strings.CutPrefix(headContent, "ref: "); ok {
  285. // HEAD points to a ref file
  286. refPath := after
  287. return filepath.Join(cwd, ".git", refPath)
  288. }
  289. // HEAD contains a direct commit hash
  290. return headFile
  291. }
  292. func (m *statusComponent) Cleanup() {
  293. if m.done != nil {
  294. close(m.done)
  295. }
  296. if m.watcher != nil {
  297. m.watcher.Close()
  298. }
  299. }
  300. func NewStatusCmp(app *app.App) StatusComponent {
  301. statusComponent := &statusComponent{
  302. app: app,
  303. lastUpdate: time.Now(),
  304. }
  305. homePath, err := os.UserHomeDir()
  306. cwdPath := app.Info.Path.Cwd
  307. if err == nil && homePath != "" && strings.HasPrefix(cwdPath, homePath) {
  308. cwdPath = "~" + cwdPath[len(homePath):]
  309. }
  310. statusComponent.cwd = cwdPath
  311. return statusComponent
  312. }