root.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410
  1. package cmd
  2. import (
  3. "context"
  4. "fmt"
  5. "os"
  6. "sync"
  7. "time"
  8. "log/slog"
  9. tea "github.com/charmbracelet/bubbletea"
  10. zone "github.com/lrstanley/bubblezone"
  11. "github.com/spf13/cobra"
  12. "github.com/sst/opencode/internal/app"
  13. "github.com/sst/opencode/internal/config"
  14. "github.com/sst/opencode/internal/db"
  15. "github.com/sst/opencode/internal/format"
  16. "github.com/sst/opencode/internal/llm/agent"
  17. "github.com/sst/opencode/internal/logging"
  18. "github.com/sst/opencode/internal/lsp/discovery"
  19. "github.com/sst/opencode/internal/message"
  20. "github.com/sst/opencode/internal/permission"
  21. "github.com/sst/opencode/internal/pubsub"
  22. "github.com/sst/opencode/internal/tui"
  23. "github.com/sst/opencode/internal/tui/components/spinner"
  24. "github.com/sst/opencode/internal/version"
  25. )
  26. type SessionIDHandler struct {
  27. slog.Handler
  28. app *app.App
  29. }
  30. func (h *SessionIDHandler) Handle(ctx context.Context, r slog.Record) error {
  31. if h.app != nil {
  32. sessionID := h.app.CurrentSession.ID
  33. if sessionID != "" {
  34. r.AddAttrs(slog.String("session_id", sessionID))
  35. }
  36. }
  37. return h.Handler.Handle(ctx, r)
  38. }
  39. func (h *SessionIDHandler) WithApp(app *app.App) *SessionIDHandler {
  40. h.app = app
  41. return h
  42. }
  43. var rootCmd = &cobra.Command{
  44. Use: "OpenCode",
  45. Short: "A terminal AI assistant for software development",
  46. Long: `OpenCode is a powerful terminal-based AI assistant that helps with software development tasks.
  47. It provides an interactive chat interface with AI capabilities, code analysis, and LSP integration
  48. to assist developers in writing, debugging, and understanding code directly from the terminal.`,
  49. RunE: func(cmd *cobra.Command, args []string) error {
  50. // If the help flag is set, show the help message
  51. if cmd.Flag("help").Changed {
  52. cmd.Help()
  53. return nil
  54. }
  55. if cmd.Flag("version").Changed {
  56. fmt.Println(version.Version)
  57. return nil
  58. }
  59. // Setup logging
  60. lvl := new(slog.LevelVar)
  61. textHandler := slog.NewTextHandler(logging.NewSlogWriter(), &slog.HandlerOptions{Level: lvl})
  62. sessionAwareHandler := &SessionIDHandler{Handler: textHandler}
  63. logger := slog.New(sessionAwareHandler)
  64. slog.SetDefault(logger)
  65. // Load the config
  66. debug, _ := cmd.Flags().GetBool("debug")
  67. cwd, _ := cmd.Flags().GetString("cwd")
  68. if cwd != "" {
  69. err := os.Chdir(cwd)
  70. if err != nil {
  71. return fmt.Errorf("failed to change directory: %v", err)
  72. }
  73. }
  74. if cwd == "" {
  75. c, err := os.Getwd()
  76. if err != nil {
  77. return fmt.Errorf("failed to get current working directory: %v", err)
  78. }
  79. cwd = c
  80. }
  81. _, err := config.Load(cwd, debug, lvl)
  82. if err != nil {
  83. return err
  84. }
  85. // Check if we're in non-interactive mode
  86. prompt, _ := cmd.Flags().GetString("prompt")
  87. if prompt != "" {
  88. outputFormatStr, _ := cmd.Flags().GetString("output-format")
  89. outputFormat := format.OutputFormat(outputFormatStr)
  90. if !outputFormat.IsValid() {
  91. return fmt.Errorf("invalid output format: %s", outputFormatStr)
  92. }
  93. quiet, _ := cmd.Flags().GetBool("quiet")
  94. return handleNonInteractiveMode(cmd.Context(), prompt, outputFormat, quiet)
  95. }
  96. // Run LSP auto-discovery
  97. if err := discovery.IntegrateLSPServers(cwd); err != nil {
  98. slog.Warn("Failed to auto-discover LSP servers", "error", err)
  99. // Continue anyway, this is not a fatal error
  100. }
  101. // Connect DB, this will also run migrations
  102. conn, err := db.Connect()
  103. if err != nil {
  104. return err
  105. }
  106. // Create main context for the application
  107. ctx, cancel := context.WithCancel(context.Background())
  108. defer cancel()
  109. app, err := app.New(ctx, conn)
  110. if err != nil {
  111. slog.Error("Failed to create app", "error", err)
  112. return err
  113. }
  114. sessionAwareHandler.WithApp(app)
  115. // Set up the TUI
  116. zone.NewGlobal()
  117. program := tea.NewProgram(
  118. tui.New(app),
  119. tea.WithAltScreen(),
  120. )
  121. // Initialize MCP tools in the background
  122. initMCPTools(ctx, app)
  123. // Setup the subscriptions, this will send services events to the TUI
  124. ch, cancelSubs := setupSubscriptions(app, ctx)
  125. // Create a context for the TUI message handler
  126. tuiCtx, tuiCancel := context.WithCancel(ctx)
  127. var tuiWg sync.WaitGroup
  128. tuiWg.Add(1)
  129. // Set up message handling for the TUI
  130. go func() {
  131. defer tuiWg.Done()
  132. defer logging.RecoverPanic("TUI-message-handler", func() {
  133. attemptTUIRecovery(program)
  134. })
  135. for {
  136. select {
  137. case <-tuiCtx.Done():
  138. slog.Info("TUI message handler shutting down")
  139. return
  140. case msg, ok := <-ch:
  141. if !ok {
  142. slog.Info("TUI message channel closed")
  143. return
  144. }
  145. program.Send(msg)
  146. }
  147. }
  148. }()
  149. // Cleanup function for when the program exits
  150. cleanup := func() {
  151. // Cancel subscriptions first
  152. cancelSubs()
  153. // Then shutdown the app
  154. app.Shutdown()
  155. // Then cancel TUI message handler
  156. tuiCancel()
  157. // Wait for TUI message handler to finish
  158. tuiWg.Wait()
  159. slog.Info("All goroutines cleaned up")
  160. }
  161. // Run the TUI
  162. result, err := program.Run()
  163. cleanup()
  164. if err != nil {
  165. slog.Error("TUI error", "error", err)
  166. return fmt.Errorf("TUI error: %v", err)
  167. }
  168. slog.Info("TUI exited", "result", result)
  169. return nil
  170. },
  171. }
  172. // attemptTUIRecovery tries to recover the TUI after a panic
  173. func attemptTUIRecovery(program *tea.Program) {
  174. slog.Info("Attempting to recover TUI after panic")
  175. // We could try to restart the TUI or gracefully exit
  176. // For now, we'll just quit the program to avoid further issues
  177. program.Quit()
  178. }
  179. func initMCPTools(ctx context.Context, app *app.App) {
  180. go func() {
  181. defer logging.RecoverPanic("MCP-goroutine", nil)
  182. // Create a context with timeout for the initial MCP tools fetch
  183. ctxWithTimeout, cancel := context.WithTimeout(ctx, 30*time.Second)
  184. defer cancel()
  185. // Set this up once with proper error handling
  186. agent.GetMcpTools(ctxWithTimeout, app.Permissions)
  187. slog.Info("MCP message handling goroutine exiting")
  188. }()
  189. }
  190. // handleNonInteractiveMode processes a single prompt in non-interactive mode
  191. func handleNonInteractiveMode(ctx context.Context, prompt string, outputFormat format.OutputFormat, quiet bool) error {
  192. slog.Info("Running in non-interactive mode", "prompt", prompt, "format", outputFormat, "quiet", quiet)
  193. // Start spinner if not in quiet mode
  194. var s *spinner.Spinner
  195. if !quiet {
  196. s = spinner.NewSpinner("Thinking...")
  197. s.Start()
  198. defer s.Stop()
  199. }
  200. // Connect DB, this will also run migrations
  201. conn, err := db.Connect()
  202. if err != nil {
  203. return err
  204. }
  205. // Create a context with cancellation
  206. ctx, cancel := context.WithCancel(ctx)
  207. defer cancel()
  208. // Create the app
  209. app, err := app.New(ctx, conn)
  210. if err != nil {
  211. slog.Error("Failed to create app", "error", err)
  212. return err
  213. }
  214. // Auto-approve all permissions for non-interactive mode
  215. permission.AutoApproveSession(ctx, "non-interactive")
  216. // Create a new session for this prompt
  217. session, err := app.Sessions.Create(ctx, "Non-interactive prompt")
  218. if err != nil {
  219. return fmt.Errorf("failed to create session: %w", err)
  220. }
  221. // Set the session as current
  222. app.CurrentSession = &session
  223. // Create the user message
  224. _, err = app.Messages.Create(ctx, session.ID, message.CreateMessageParams{
  225. Role: message.User,
  226. Parts: []message.ContentPart{message.TextContent{Text: prompt}},
  227. })
  228. if err != nil {
  229. return fmt.Errorf("failed to create message: %w", err)
  230. }
  231. // Run the agent to get a response
  232. eventCh, err := app.PrimaryAgent.Run(ctx, session.ID, prompt)
  233. if err != nil {
  234. return fmt.Errorf("failed to run agent: %w", err)
  235. }
  236. // Wait for the response
  237. var response message.Message
  238. for event := range eventCh {
  239. if event.Err() != nil {
  240. return fmt.Errorf("agent error: %w", event.Err())
  241. }
  242. response = event.Response()
  243. }
  244. // Get the text content from the response
  245. content := ""
  246. if textContent := response.Content(); textContent != nil {
  247. content = textContent.Text
  248. }
  249. // Format the output according to the specified format
  250. formattedOutput, err := format.FormatOutput(content, outputFormat)
  251. if err != nil {
  252. return fmt.Errorf("failed to format output: %w", err)
  253. }
  254. // Stop spinner before printing output
  255. if !quiet && s != nil {
  256. s.Stop()
  257. }
  258. // Print the formatted output to stdout
  259. fmt.Println(formattedOutput)
  260. // Shutdown the app
  261. app.Shutdown()
  262. return nil
  263. }
  264. func setupSubscriber[T any](
  265. ctx context.Context,
  266. wg *sync.WaitGroup,
  267. name string,
  268. subscriber func(context.Context) <-chan pubsub.Event[T],
  269. outputCh chan<- tea.Msg,
  270. ) {
  271. wg.Add(1)
  272. go func() {
  273. defer wg.Done()
  274. defer logging.RecoverPanic(fmt.Sprintf("subscription-%s", name), nil)
  275. subCh := subscriber(ctx)
  276. if subCh == nil {
  277. slog.Warn("subscription channel is nil", "name", name)
  278. return
  279. }
  280. for {
  281. select {
  282. case event, ok := <-subCh:
  283. if !ok {
  284. slog.Info("subscription channel closed", "name", name)
  285. return
  286. }
  287. var msg tea.Msg = event
  288. select {
  289. case outputCh <- msg:
  290. case <-time.After(2 * time.Second):
  291. slog.Warn("message dropped due to slow consumer", "name", name)
  292. case <-ctx.Done():
  293. slog.Info("subscription cancelled", "name", name)
  294. return
  295. }
  296. case <-ctx.Done():
  297. slog.Info("subscription cancelled", "name", name)
  298. return
  299. }
  300. }
  301. }()
  302. }
  303. func setupSubscriptions(app *app.App, parentCtx context.Context) (chan tea.Msg, func()) {
  304. ch := make(chan tea.Msg, 100)
  305. wg := sync.WaitGroup{}
  306. ctx, cancel := context.WithCancel(parentCtx) // Inherit from parent context
  307. setupSubscriber(ctx, &wg, "logging", app.Logs.Subscribe, ch)
  308. setupSubscriber(ctx, &wg, "sessions", app.Sessions.Subscribe, ch)
  309. setupSubscriber(ctx, &wg, "messages", app.Messages.Subscribe, ch)
  310. setupSubscriber(ctx, &wg, "permissions", app.Permissions.Subscribe, ch)
  311. setupSubscriber(ctx, &wg, "status", app.Status.Subscribe, ch)
  312. cleanupFunc := func() {
  313. slog.Info("Cancelling all subscriptions")
  314. cancel() // Signal all goroutines to stop
  315. waitCh := make(chan struct{})
  316. go func() {
  317. defer logging.RecoverPanic("subscription-cleanup", nil)
  318. wg.Wait()
  319. close(waitCh)
  320. }()
  321. select {
  322. case <-waitCh:
  323. slog.Info("All subscription goroutines completed successfully")
  324. close(ch) // Only close after all writers are confirmed done
  325. case <-time.After(5 * time.Second):
  326. slog.Warn("Timed out waiting for some subscription goroutines to complete")
  327. close(ch)
  328. }
  329. }
  330. return ch, cleanupFunc
  331. }
  332. func Execute() {
  333. err := rootCmd.Execute()
  334. if err != nil {
  335. os.Exit(1)
  336. }
  337. }
  338. func init() {
  339. rootCmd.Flags().BoolP("help", "h", false, "Help")
  340. rootCmd.Flags().BoolP("version", "v", false, "Version")
  341. rootCmd.Flags().BoolP("debug", "d", false, "Debug")
  342. rootCmd.Flags().StringP("cwd", "c", "", "Current working directory")
  343. rootCmd.Flags().StringP("prompt", "p", "", "Run a single prompt in non-interactive mode")
  344. rootCmd.Flags().StringP("output-format", "f", "text", "Output format for non-interactive mode (text, json)")
  345. rootCmd.Flags().BoolP("quiet", "q", false, "Hide spinner in non-interactive mode")
  346. }