app.go 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325
  1. package app
  2. import (
  3. "context"
  4. "fmt"
  5. "path/filepath"
  6. "sort"
  7. "log/slog"
  8. tea "github.com/charmbracelet/bubbletea"
  9. "github.com/sst/opencode/internal/config"
  10. "github.com/sst/opencode/internal/fileutil"
  11. "github.com/sst/opencode/internal/state"
  12. "github.com/sst/opencode/internal/status"
  13. "github.com/sst/opencode/internal/theme"
  14. "github.com/sst/opencode/internal/util"
  15. "github.com/sst/opencode/pkg/client"
  16. )
  17. type App struct {
  18. ConfigPath string
  19. Config *config.Config
  20. Info *client.AppInfo
  21. Client *client.ClientWithResponses
  22. Provider *client.ProviderInfo
  23. Model *client.ProviderModel
  24. Session *client.SessionInfo
  25. Messages []client.MessageInfo
  26. Status status.Service
  27. // UI state
  28. filepickerOpen bool
  29. completionDialogOpen bool
  30. }
  31. func New(ctx context.Context, httpClient *client.ClientWithResponses) (*App, error) {
  32. err := status.InitService()
  33. if err != nil {
  34. slog.Error("Failed to initialize status service", "error", err)
  35. return nil, err
  36. }
  37. appInfoResponse, _ := httpClient.PostAppInfoWithResponse(ctx)
  38. appInfo := appInfoResponse.JSON200
  39. providersResponse, err := httpClient.PostProviderListWithResponse(ctx)
  40. if err != nil {
  41. return nil, err
  42. }
  43. providers := []client.ProviderInfo{}
  44. var defaultProvider *client.ProviderInfo
  45. var defaultModel *client.ProviderModel
  46. for i, provider := range providersResponse.JSON200.Providers {
  47. if i == 0 || provider.Id == "anthropic" {
  48. defaultProvider = &providersResponse.JSON200.Providers[i]
  49. if match, ok := providersResponse.JSON200.Default[provider.Id]; ok {
  50. model := defaultProvider.Models[match]
  51. defaultModel = &model
  52. } else {
  53. for _, model := range provider.Models {
  54. defaultModel = &model
  55. break
  56. }
  57. }
  58. }
  59. providers = append(providers, provider)
  60. }
  61. if len(providers) == 0 {
  62. return nil, fmt.Errorf("no providers found")
  63. }
  64. appConfigPath := filepath.Join(appInfo.Path.Config, "tui.toml")
  65. appConfig, err := config.LoadConfig(appConfigPath)
  66. if err != nil {
  67. slog.Info("No TUI config found, using default values", "error", err)
  68. appConfig = config.NewConfig("opencode", defaultProvider.Id, defaultModel.Id)
  69. config.SaveConfig(appConfigPath, appConfig)
  70. }
  71. var currentProvider *client.ProviderInfo
  72. var currentModel *client.ProviderModel
  73. for _, provider := range providers {
  74. if provider.Id == appConfig.Provider {
  75. currentProvider = &provider
  76. for _, model := range provider.Models {
  77. if model.Id == appConfig.Model {
  78. currentModel = &model
  79. }
  80. }
  81. }
  82. }
  83. app := &App{
  84. ConfigPath: appConfigPath,
  85. Config: appConfig,
  86. Info: appInfo,
  87. Client: httpClient,
  88. Provider: currentProvider,
  89. Model: currentModel,
  90. Session: &client.SessionInfo{},
  91. Messages: []client.MessageInfo{},
  92. Status: status.GetService(),
  93. }
  94. theme.SetTheme(appConfig.Theme)
  95. fileutil.Init()
  96. return app, nil
  97. }
  98. type Attachment struct {
  99. FilePath string
  100. FileName string
  101. MimeType string
  102. Content []byte
  103. }
  104. func (a *App) IsBusy() bool {
  105. if len(a.Messages) == 0 {
  106. return false
  107. }
  108. lastMessage := a.Messages[len(a.Messages)-1]
  109. return lastMessage.Metadata.Time.Completed == nil
  110. }
  111. func (a *App) SaveConfig() {
  112. config.SaveConfig(a.ConfigPath, a.Config)
  113. }
  114. func (a *App) InitializeProject(ctx context.Context) tea.Cmd {
  115. cmds := []tea.Cmd{}
  116. session, err := a.CreateSession(ctx)
  117. if err != nil {
  118. status.Error(err.Error())
  119. return nil
  120. }
  121. a.Session = session
  122. cmds = append(cmds, util.CmdHandler(state.SessionSelectedMsg(session)))
  123. go func() {
  124. // TODO: Handle no provider or model setup, yet
  125. response, err := a.Client.PostSessionInitialize(ctx, client.PostSessionInitializeJSONRequestBody{
  126. SessionID: a.Session.Id,
  127. ProviderID: a.Provider.Id,
  128. ModelID: a.Model.Id,
  129. })
  130. if err != nil {
  131. status.Error(err.Error())
  132. }
  133. if response != nil && response.StatusCode != 200 {
  134. status.Error(fmt.Sprintf("failed to initialize project: %d", response.StatusCode))
  135. }
  136. }()
  137. return tea.Batch(cmds...)
  138. }
  139. func (a *App) MarkProjectInitialized(ctx context.Context) error {
  140. response, err := a.Client.PostAppInitialize(ctx)
  141. if err != nil {
  142. slog.Error("Failed to mark project as initialized", "error", err)
  143. return err
  144. }
  145. if response != nil && response.StatusCode != 200 {
  146. return fmt.Errorf("failed to initialize project: %d", response.StatusCode)
  147. }
  148. return nil
  149. }
  150. func (a *App) CreateSession(ctx context.Context) (*client.SessionInfo, error) {
  151. resp, err := a.Client.PostSessionCreateWithResponse(ctx)
  152. if err != nil {
  153. return nil, err
  154. }
  155. if resp != nil && resp.StatusCode() != 200 {
  156. return nil, fmt.Errorf("failed to create session: %d", resp.StatusCode())
  157. }
  158. session := resp.JSON200
  159. return session, nil
  160. }
  161. func (a *App) SendChatMessage(ctx context.Context, text string, attachments []Attachment) tea.Cmd {
  162. var cmds []tea.Cmd
  163. if a.Session.Id == "" {
  164. session, err := a.CreateSession(ctx)
  165. if err != nil {
  166. status.Error(err.Error())
  167. return nil
  168. }
  169. a.Session = session
  170. cmds = append(cmds, util.CmdHandler(state.SessionSelectedMsg(session)))
  171. }
  172. // TODO: Handle attachments when API supports them
  173. if len(attachments) > 0 {
  174. // For now, ignore attachments
  175. // return "", fmt.Errorf("attachments not supported yet")
  176. }
  177. part := client.MessagePart{}
  178. part.FromMessagePartText(client.MessagePartText{
  179. Type: "text",
  180. Text: text,
  181. })
  182. parts := []client.MessagePart{part}
  183. go func() {
  184. response, err := a.Client.PostSessionChat(ctx, client.PostSessionChatJSONRequestBody{
  185. SessionID: a.Session.Id,
  186. Parts: parts,
  187. ProviderID: a.Provider.Id,
  188. ModelID: a.Model.Id,
  189. })
  190. if err != nil {
  191. slog.Error("Failed to send message", "error", err)
  192. status.Error(err.Error())
  193. }
  194. if response != nil && response.StatusCode != 200 {
  195. slog.Error("Failed to send message", "error", fmt.Sprintf("failed to send message: %d", response.StatusCode))
  196. status.Error(fmt.Sprintf("failed to send message: %d", response.StatusCode))
  197. }
  198. }()
  199. // The actual response will come through SSE
  200. // For now, just return success
  201. return tea.Batch(cmds...)
  202. }
  203. func (a *App) Cancel(ctx context.Context, sessionID string) error {
  204. response, err := a.Client.PostSessionAbort(ctx, client.PostSessionAbortJSONRequestBody{
  205. SessionID: sessionID,
  206. })
  207. if err != nil {
  208. slog.Error("Failed to cancel session", "error", err)
  209. status.Error(err.Error())
  210. return err
  211. }
  212. if response != nil && response.StatusCode != 200 {
  213. slog.Error("Failed to cancel session", "error", fmt.Sprintf("failed to cancel session: %d", response.StatusCode))
  214. status.Error(fmt.Sprintf("failed to cancel session: %d", response.StatusCode))
  215. return fmt.Errorf("failed to cancel session: %d", response.StatusCode)
  216. }
  217. return nil
  218. }
  219. func (a *App) ListSessions(ctx context.Context) ([]client.SessionInfo, error) {
  220. resp, err := a.Client.PostSessionListWithResponse(ctx)
  221. if err != nil {
  222. return nil, err
  223. }
  224. if resp.StatusCode() != 200 {
  225. return nil, fmt.Errorf("failed to list sessions: %d", resp.StatusCode())
  226. }
  227. if resp.JSON200 == nil {
  228. return []client.SessionInfo{}, nil
  229. }
  230. sessions := *resp.JSON200
  231. sort.Slice(sessions, func(i, j int) bool {
  232. return sessions[i].Time.Created-sessions[j].Time.Created > 0
  233. })
  234. return sessions, nil
  235. }
  236. func (a *App) ListMessages(ctx context.Context, sessionId string) ([]client.MessageInfo, error) {
  237. resp, err := a.Client.PostSessionMessagesWithResponse(ctx, client.PostSessionMessagesJSONRequestBody{SessionID: sessionId})
  238. if err != nil {
  239. return nil, err
  240. }
  241. if resp.StatusCode() != 200 {
  242. return nil, fmt.Errorf("failed to list messages: %d", resp.StatusCode())
  243. }
  244. if resp.JSON200 == nil {
  245. return []client.MessageInfo{}, nil
  246. }
  247. messages := *resp.JSON200
  248. return messages, nil
  249. }
  250. func (a *App) ListProviders(ctx context.Context) ([]client.ProviderInfo, error) {
  251. resp, err := a.Client.PostProviderListWithResponse(ctx)
  252. if err != nil {
  253. return nil, err
  254. }
  255. if resp.StatusCode() != 200 {
  256. return nil, fmt.Errorf("failed to list sessions: %d", resp.StatusCode())
  257. }
  258. if resp.JSON200 == nil {
  259. return []client.ProviderInfo{}, nil
  260. }
  261. providers := *resp.JSON200
  262. return providers.Providers, nil
  263. }
  264. // IsFilepickerOpen returns whether the filepicker is currently open
  265. func (app *App) IsFilepickerOpen() bool {
  266. return app.filepickerOpen
  267. }
  268. // SetFilepickerOpen sets the state of the filepicker
  269. func (app *App) SetFilepickerOpen(open bool) {
  270. app.filepickerOpen = open
  271. }
  272. // IsCompletionDialogOpen returns whether the completion dialog is currently open
  273. func (app *App) IsCompletionDialogOpen() bool {
  274. return app.completionDialogOpen
  275. }
  276. // SetCompletionDialogOpen sets the state of the completion dialog
  277. func (app *App) SetCompletionDialogOpen(open bool) {
  278. app.completionDialogOpen = open
  279. }
  280. // Shutdown performs a clean shutdown of the application
  281. func (app *App) Shutdown() {
  282. // TODO: cleanup?
  283. }