app.go 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445
  1. package app
  2. import (
  3. "context"
  4. "fmt"
  5. "path/filepath"
  6. "sort"
  7. "strings"
  8. "time"
  9. "log/slog"
  10. tea "github.com/charmbracelet/bubbletea/v2"
  11. "github.com/sst/opencode/internal/commands"
  12. "github.com/sst/opencode/internal/components/toast"
  13. "github.com/sst/opencode/internal/config"
  14. "github.com/sst/opencode/internal/theme"
  15. "github.com/sst/opencode/internal/util"
  16. "github.com/sst/opencode/pkg/client"
  17. )
  18. var RootPath string
  19. type App struct {
  20. Info client.AppInfo
  21. Version string
  22. StatePath string
  23. Config *client.ConfigInfo
  24. Client *client.ClientWithResponses
  25. State *config.State
  26. Provider *client.ProviderInfo
  27. Model *client.ModelInfo
  28. Session *client.SessionInfo
  29. Messages []client.MessageInfo
  30. Commands commands.CommandRegistry
  31. }
  32. type SessionSelectedMsg = *client.SessionInfo
  33. type ModelSelectedMsg struct {
  34. Provider client.ProviderInfo
  35. Model client.ModelInfo
  36. }
  37. type SessionClearedMsg struct{}
  38. type CompactSessionMsg struct{}
  39. type SendMsg struct {
  40. Text string
  41. Attachments []Attachment
  42. }
  43. type CompletionDialogTriggeredMsg struct {
  44. InitialValue string
  45. }
  46. type OptimisticMessageAddedMsg struct {
  47. Message client.MessageInfo
  48. }
  49. func New(
  50. ctx context.Context,
  51. version string,
  52. appInfo client.AppInfo,
  53. httpClient *client.ClientWithResponses,
  54. ) (*App, error) {
  55. RootPath = appInfo.Path.Root
  56. configResponse, err := httpClient.PostConfigGetWithResponse(ctx)
  57. if err != nil {
  58. return nil, err
  59. }
  60. if configResponse.StatusCode() != 200 || configResponse.JSON200 == nil {
  61. return nil, fmt.Errorf("failed to get config: %d", configResponse.StatusCode())
  62. }
  63. configInfo := configResponse.JSON200
  64. if configInfo.Keybinds == nil {
  65. leader := "ctrl+x"
  66. keybinds := client.ConfigKeybinds{
  67. Leader: &leader,
  68. }
  69. configInfo.Keybinds = &keybinds
  70. }
  71. appStatePath := filepath.Join(appInfo.Path.State, "tui")
  72. appState, err := config.LoadState(appStatePath)
  73. if err != nil {
  74. appState = config.NewState()
  75. config.SaveState(appStatePath, appState)
  76. }
  77. if configInfo.Theme != nil {
  78. appState.Theme = *configInfo.Theme
  79. }
  80. if configInfo.Model != nil {
  81. splits := strings.Split(*configInfo.Model, "/")
  82. appState.Provider = splits[0]
  83. appState.Model = strings.Join(splits[1:], "/")
  84. }
  85. // Load themes from all directories
  86. if err := theme.LoadThemesFromDirectories(
  87. appInfo.Path.Config,
  88. appInfo.Path.Root,
  89. appInfo.Path.Cwd,
  90. ); err != nil {
  91. slog.Warn("Failed to load themes from directories", "error", err)
  92. }
  93. if appState.Theme != "" {
  94. theme.SetTheme(appState.Theme)
  95. }
  96. slog.Debug("Loaded config", "config", configInfo)
  97. app := &App{
  98. Info: appInfo,
  99. Version: version,
  100. StatePath: appStatePath,
  101. Config: configInfo,
  102. State: appState,
  103. Client: httpClient,
  104. Session: &client.SessionInfo{},
  105. Messages: []client.MessageInfo{},
  106. Commands: commands.LoadFromConfig(configInfo),
  107. }
  108. return app, nil
  109. }
  110. func (a *App) InitializeProvider() tea.Cmd {
  111. return func() tea.Msg {
  112. providersResponse, err := a.Client.PostProviderListWithResponse(context.Background())
  113. if err != nil {
  114. slog.Error("Failed to list providers", "error", err)
  115. // TODO: notify user
  116. return nil
  117. }
  118. if providersResponse != nil && providersResponse.StatusCode() != 200 {
  119. slog.Error("failed to retrieve providers", "status", providersResponse.StatusCode(), "message", string(providersResponse.Body))
  120. return nil
  121. }
  122. providers := []client.ProviderInfo{}
  123. var defaultProvider *client.ProviderInfo
  124. var defaultModel *client.ModelInfo
  125. var anthropic *client.ProviderInfo
  126. for _, provider := range providersResponse.JSON200.Providers {
  127. if provider.Id == "anthropic" {
  128. anthropic = &provider
  129. }
  130. }
  131. // default to anthropic if available
  132. if anthropic != nil {
  133. defaultProvider = anthropic
  134. defaultModel = getDefaultModel(providersResponse, *anthropic)
  135. }
  136. for _, provider := range providersResponse.JSON200.Providers {
  137. if defaultProvider == nil || defaultModel == nil {
  138. defaultProvider = &provider
  139. defaultModel = getDefaultModel(providersResponse, provider)
  140. }
  141. providers = append(providers, provider)
  142. }
  143. if len(providers) == 0 {
  144. slog.Error("No providers configured")
  145. return nil
  146. }
  147. var currentProvider *client.ProviderInfo
  148. var currentModel *client.ModelInfo
  149. for _, provider := range providers {
  150. if provider.Id == a.State.Provider {
  151. currentProvider = &provider
  152. for _, model := range provider.Models {
  153. if model.Id == a.State.Model {
  154. currentModel = &model
  155. }
  156. }
  157. }
  158. }
  159. if currentProvider == nil || currentModel == nil {
  160. currentProvider = defaultProvider
  161. currentModel = defaultModel
  162. }
  163. // TODO: handle no provider or model setup, yet
  164. return ModelSelectedMsg{
  165. Provider: *currentProvider,
  166. Model: *currentModel,
  167. }
  168. }
  169. }
  170. func getDefaultModel(response *client.PostProviderListResponse, provider client.ProviderInfo) *client.ModelInfo {
  171. if match, ok := response.JSON200.Default[provider.Id]; ok {
  172. model := provider.Models[match]
  173. return &model
  174. } else {
  175. for _, model := range provider.Models {
  176. return &model
  177. }
  178. }
  179. return nil
  180. }
  181. type Attachment struct {
  182. FilePath string
  183. FileName string
  184. MimeType string
  185. Content []byte
  186. }
  187. func (a *App) IsBusy() bool {
  188. if len(a.Messages) == 0 {
  189. return false
  190. }
  191. lastMessage := a.Messages[len(a.Messages)-1]
  192. return lastMessage.Metadata.Time.Completed == nil
  193. }
  194. func (a *App) SaveState() {
  195. err := config.SaveState(a.StatePath, a.State)
  196. if err != nil {
  197. slog.Error("Failed to save state", "error", err)
  198. }
  199. }
  200. func (a *App) InitializeProject(ctx context.Context) tea.Cmd {
  201. cmds := []tea.Cmd{}
  202. session, err := a.CreateSession(ctx)
  203. if err != nil {
  204. // status.Error(err.Error())
  205. return nil
  206. }
  207. a.Session = session
  208. cmds = append(cmds, util.CmdHandler(SessionSelectedMsg(session)))
  209. go func() {
  210. response, err := a.Client.PostSessionInitialize(ctx, client.PostSessionInitializeJSONRequestBody{
  211. SessionID: a.Session.Id,
  212. ProviderID: a.Provider.Id,
  213. ModelID: a.Model.Id,
  214. })
  215. if err != nil {
  216. slog.Error("Failed to initialize project", "error", err)
  217. // status.Error(err.Error())
  218. }
  219. if response != nil && response.StatusCode != 200 {
  220. slog.Error("Failed to initialize project", "error", response.StatusCode)
  221. // status.Error(fmt.Sprintf("failed to initialize project: %d", response.StatusCode))
  222. }
  223. }()
  224. return tea.Batch(cmds...)
  225. }
  226. func (a *App) CompactSession(ctx context.Context) tea.Cmd {
  227. go func() {
  228. response, err := a.Client.PostSessionSummarizeWithResponse(ctx, client.PostSessionSummarizeJSONRequestBody{
  229. SessionID: a.Session.Id,
  230. ProviderID: a.Provider.Id,
  231. ModelID: a.Model.Id,
  232. })
  233. if err != nil {
  234. slog.Error("Failed to compact session", "error", err)
  235. }
  236. if response != nil && response.StatusCode() != 200 {
  237. slog.Error("Failed to compact session", "error", response.StatusCode)
  238. }
  239. }()
  240. return nil
  241. }
  242. func (a *App) MarkProjectInitialized(ctx context.Context) error {
  243. response, err := a.Client.PostAppInitialize(ctx)
  244. if err != nil {
  245. slog.Error("Failed to mark project as initialized", "error", err)
  246. return err
  247. }
  248. if response != nil && response.StatusCode != 200 {
  249. return fmt.Errorf("failed to initialize project: %d", response.StatusCode)
  250. }
  251. return nil
  252. }
  253. func (a *App) CreateSession(ctx context.Context) (*client.SessionInfo, error) {
  254. resp, err := a.Client.PostSessionCreateWithResponse(ctx)
  255. if err != nil {
  256. return nil, err
  257. }
  258. if resp != nil && resp.StatusCode() != 200 {
  259. return nil, fmt.Errorf("failed to create session: %d", resp.StatusCode())
  260. }
  261. session := resp.JSON200
  262. return session, nil
  263. }
  264. func (a *App) SendChatMessage(ctx context.Context, text string, attachments []Attachment) tea.Cmd {
  265. var cmds []tea.Cmd
  266. if a.Session.Id == "" {
  267. session, err := a.CreateSession(ctx)
  268. if err != nil {
  269. return toast.NewErrorToast(err.Error())
  270. }
  271. a.Session = session
  272. cmds = append(cmds, util.CmdHandler(SessionSelectedMsg(session)))
  273. }
  274. part := client.MessagePart{}
  275. part.FromMessagePartText(client.MessagePartText{
  276. Type: "text",
  277. Text: text,
  278. })
  279. parts := []client.MessagePart{part}
  280. optimisticMessage := client.MessageInfo{
  281. Id: fmt.Sprintf("optimistic-%d", time.Now().UnixNano()),
  282. Role: client.User,
  283. Parts: parts,
  284. Metadata: client.MessageMetadata{
  285. SessionID: a.Session.Id,
  286. Time: struct {
  287. Completed *float32 `json:"completed,omitempty"`
  288. Created float32 `json:"created"`
  289. }{
  290. Created: float32(time.Now().Unix()),
  291. },
  292. Tool: make(map[string]client.MessageMetadata_Tool_AdditionalProperties),
  293. },
  294. }
  295. a.Messages = append(a.Messages, optimisticMessage)
  296. cmds = append(cmds, util.CmdHandler(OptimisticMessageAddedMsg{Message: optimisticMessage}))
  297. cmds = append(cmds, func() tea.Msg {
  298. response, err := a.Client.PostSessionChat(ctx, client.PostSessionChatJSONRequestBody{
  299. SessionID: a.Session.Id,
  300. Parts: parts,
  301. ProviderID: a.Provider.Id,
  302. ModelID: a.Model.Id,
  303. })
  304. if err != nil {
  305. errormsg := fmt.Sprintf("failed to send message: %v", err)
  306. slog.Error(errormsg)
  307. return toast.NewErrorToast(errormsg)()
  308. }
  309. if response != nil && response.StatusCode != 200 {
  310. errormsg := fmt.Sprintf("failed to send message: %d", response.StatusCode)
  311. slog.Error(errormsg)
  312. return toast.NewErrorToast(errormsg)()
  313. }
  314. return nil
  315. })
  316. // The actual response will come through SSE
  317. // For now, just return success
  318. return tea.Batch(cmds...)
  319. }
  320. func (a *App) Cancel(ctx context.Context, sessionID string) error {
  321. response, err := a.Client.PostSessionAbort(ctx, client.PostSessionAbortJSONRequestBody{
  322. SessionID: sessionID,
  323. })
  324. if err != nil {
  325. slog.Error("Failed to cancel session", "error", err)
  326. // status.Error(err.Error())
  327. return err
  328. }
  329. if response != nil && response.StatusCode != 200 {
  330. slog.Error("Failed to cancel session", "error", fmt.Sprintf("failed to cancel session: %d", response.StatusCode))
  331. // status.Error(fmt.Sprintf("failed to cancel session: %d", response.StatusCode))
  332. return fmt.Errorf("failed to cancel session: %d", response.StatusCode)
  333. }
  334. return nil
  335. }
  336. func (a *App) ListSessions(ctx context.Context) ([]client.SessionInfo, error) {
  337. resp, err := a.Client.PostSessionListWithResponse(ctx)
  338. if err != nil {
  339. return nil, err
  340. }
  341. if resp.StatusCode() != 200 {
  342. return nil, fmt.Errorf("failed to list sessions: %d", resp.StatusCode())
  343. }
  344. if resp.JSON200 == nil {
  345. return []client.SessionInfo{}, nil
  346. }
  347. sessions := *resp.JSON200
  348. sort.Slice(sessions, func(i, j int) bool {
  349. return sessions[i].Time.Created-sessions[j].Time.Created > 0
  350. })
  351. return sessions, nil
  352. }
  353. func (a *App) DeleteSession(ctx context.Context, sessionID string) error {
  354. resp, err := a.Client.PostSessionDeleteWithResponse(ctx, client.PostSessionDeleteJSONRequestBody{
  355. SessionID: sessionID,
  356. })
  357. if err != nil {
  358. return err
  359. }
  360. if resp.StatusCode() != 200 {
  361. return fmt.Errorf("failed to delete session: %d", resp.StatusCode())
  362. }
  363. return nil
  364. }
  365. func (a *App) ListMessages(ctx context.Context, sessionId string) ([]client.MessageInfo, error) {
  366. resp, err := a.Client.PostSessionMessagesWithResponse(ctx, client.PostSessionMessagesJSONRequestBody{SessionID: sessionId})
  367. if err != nil {
  368. return nil, err
  369. }
  370. if resp.StatusCode() != 200 {
  371. return nil, fmt.Errorf("failed to list messages: %d", resp.StatusCode())
  372. }
  373. if resp.JSON200 == nil {
  374. return []client.MessageInfo{}, nil
  375. }
  376. messages := *resp.JSON200
  377. return messages, nil
  378. }
  379. func (a *App) ListProviders(ctx context.Context) ([]client.ProviderInfo, error) {
  380. resp, err := a.Client.PostProviderListWithResponse(ctx)
  381. if err != nil {
  382. return nil, err
  383. }
  384. if resp.StatusCode() != 200 {
  385. return nil, fmt.Errorf("failed to list sessions: %d", resp.StatusCode())
  386. }
  387. if resp.JSON200 == nil {
  388. return []client.ProviderInfo{}, nil
  389. }
  390. providers := *resp.JSON200
  391. return providers.Providers, nil
  392. }
  393. // func (a *App) loadCustomKeybinds() {
  394. //
  395. // }