status.go 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340
  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. agentColor := util.GetAgentColor(m.app.AgentIndex)
  107. if m.app.AgentIndex == 0 {
  108. modeBackground = t.BackgroundElement()
  109. modeForeground = agentColor
  110. } else {
  111. modeBackground = agentColor
  112. modeForeground = t.BackgroundPanel()
  113. }
  114. command := m.app.Commands[commands.AgentCycleCommand]
  115. kb := command.Keybindings[0]
  116. key := kb.Key
  117. if kb.RequiresLeader {
  118. key = m.app.Config.Keybinds.Leader + " " + kb.Key
  119. }
  120. agentStyle := styles.NewStyle().Background(modeBackground).Foreground(modeForeground)
  121. agentNameStyle := agentStyle.Bold(true).Render
  122. agentDescStyle := agentStyle.Render
  123. agent := agentNameStyle(strings.ToUpper(m.app.Agent().Name)) + agentDescStyle(" AGENT")
  124. agent = agentStyle.
  125. Padding(0, 1).
  126. BorderLeft(true).
  127. BorderStyle(lipgloss.ThickBorder()).
  128. BorderForeground(modeBackground).
  129. BorderBackground(t.BackgroundPanel()).
  130. Render(agent)
  131. faintStyle := styles.NewStyle().
  132. Faint(true).
  133. Background(t.BackgroundPanel()).
  134. Foreground(t.TextMuted())
  135. agent = faintStyle.Render(key+" ") + agent
  136. modeWidth := lipgloss.Width(agent)
  137. availableWidth := m.width - logoWidth - modeWidth
  138. branchSuffix := ""
  139. if m.branch != "" {
  140. branchSuffix = ":" + m.branch
  141. }
  142. maxCwdWidth := availableWidth - lipgloss.Width(branchSuffix)
  143. cwdDisplay := m.collapsePath(m.cwd, maxCwdWidth)
  144. if m.branch != "" && availableWidth > lipgloss.Width(cwdDisplay)+lipgloss.Width(branchSuffix) {
  145. cwdDisplay += faintStyle.Render(branchSuffix)
  146. }
  147. cwd := styles.NewStyle().
  148. Foreground(t.TextMuted()).
  149. Background(t.BackgroundPanel()).
  150. Padding(0, 1).
  151. Render(cwdDisplay)
  152. background := t.BackgroundPanel()
  153. status := layout.Render(
  154. layout.FlexOptions{
  155. Background: &background,
  156. Direction: layout.Row,
  157. Justify: layout.JustifySpaceBetween,
  158. Align: layout.AlignStretch,
  159. Width: m.width,
  160. },
  161. layout.FlexItem{
  162. View: logo + cwd,
  163. },
  164. layout.FlexItem{
  165. View: agent,
  166. },
  167. )
  168. blank := styles.NewStyle().Background(t.Background()).Width(m.width).Render("")
  169. return blank + "\n" + status
  170. }
  171. func (m *statusComponent) startGitWatcher() tea.Cmd {
  172. cmd := util.CmdHandler(
  173. GitBranchUpdatedMsg{Branch: getCurrentGitBranch(m.app.Info.Path.Root)},
  174. )
  175. if err := m.initWatcher(); err != nil {
  176. return cmd
  177. }
  178. return tea.Batch(cmd, m.watchForGitChanges())
  179. }
  180. func (m *statusComponent) initWatcher() error {
  181. gitDir := filepath.Join(m.app.Info.Path.Root, ".git")
  182. headFile := filepath.Join(gitDir, "HEAD")
  183. if info, err := os.Stat(gitDir); err != nil || !info.IsDir() {
  184. return err
  185. }
  186. watcher, err := fsnotify.NewWatcher()
  187. if err != nil {
  188. return err
  189. }
  190. if err := watcher.Add(headFile); err != nil {
  191. watcher.Close()
  192. return err
  193. }
  194. // Also watch the ref file if HEAD points to a ref
  195. refFile := getGitRefFile(m.app.Info.Path.Cwd)
  196. if refFile != headFile && refFile != "" {
  197. if _, err := os.Stat(refFile); err == nil {
  198. watcher.Add(refFile) // Ignore error, HEAD watching is sufficient
  199. }
  200. }
  201. m.watcher = watcher
  202. m.done = make(chan struct{})
  203. return nil
  204. }
  205. func (m *statusComponent) watchForGitChanges() tea.Cmd {
  206. if m.watcher == nil {
  207. return nil
  208. }
  209. return tea.Cmd(func() tea.Msg {
  210. for {
  211. select {
  212. case event, ok := <-m.watcher.Events:
  213. branch := getCurrentGitBranch(m.app.Info.Path.Root)
  214. if !ok {
  215. return GitBranchUpdatedMsg{Branch: branch}
  216. }
  217. if event.Has(fsnotify.Write) || event.Has(fsnotify.Create) {
  218. // Debounce updates to prevent excessive refreshes
  219. now := time.Now()
  220. if now.Sub(m.lastUpdate) < 100*time.Millisecond {
  221. continue
  222. }
  223. m.lastUpdate = now
  224. if strings.HasSuffix(event.Name, "HEAD") {
  225. m.updateWatchedFiles()
  226. }
  227. return GitBranchUpdatedMsg{Branch: branch}
  228. }
  229. case <-m.watcher.Errors:
  230. // Continue watching even on errors
  231. case <-m.done:
  232. return GitBranchUpdatedMsg{Branch: ""}
  233. }
  234. }
  235. })
  236. }
  237. func (m *statusComponent) updateWatchedFiles() {
  238. if m.watcher == nil {
  239. return
  240. }
  241. refFile := getGitRefFile(m.app.Info.Path.Root)
  242. headFile := filepath.Join(m.app.Info.Path.Root, ".git", "HEAD")
  243. if refFile != headFile && refFile != "" {
  244. if _, err := os.Stat(refFile); err == nil {
  245. // Try to add the new ref file (ignore error if already watching)
  246. m.watcher.Add(refFile)
  247. }
  248. }
  249. }
  250. func getCurrentGitBranch(cwd string) string {
  251. cmd := exec.Command("git", "branch", "--show-current")
  252. cmd.Dir = cwd
  253. output, err := cmd.Output()
  254. if err != nil {
  255. return ""
  256. }
  257. return strings.TrimSpace(string(output))
  258. }
  259. func getGitRefFile(cwd string) string {
  260. headFile := filepath.Join(cwd, ".git", "HEAD")
  261. content, err := os.ReadFile(headFile)
  262. if err != nil {
  263. return ""
  264. }
  265. headContent := strings.TrimSpace(string(content))
  266. if after, ok := strings.CutPrefix(headContent, "ref: "); ok {
  267. // HEAD points to a ref file
  268. refPath := after
  269. return filepath.Join(cwd, ".git", refPath)
  270. }
  271. // HEAD contains a direct commit hash
  272. return headFile
  273. }
  274. func (m *statusComponent) Cleanup() {
  275. if m.done != nil {
  276. close(m.done)
  277. }
  278. if m.watcher != nil {
  279. m.watcher.Close()
  280. }
  281. }
  282. func NewStatusCmp(app *app.App) StatusComponent {
  283. statusComponent := &statusComponent{
  284. app: app,
  285. lastUpdate: time.Now(),
  286. }
  287. homePath, err := os.UserHomeDir()
  288. cwdPath := app.Info.Path.Cwd
  289. if err == nil && homePath != "" && strings.HasPrefix(cwdPath, homePath) {
  290. cwdPath = "~" + cwdPath[len(homePath):]
  291. }
  292. statusComponent.cwd = cwdPath
  293. return statusComponent
  294. }