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