app.go 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501
  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-sdk-go"
  12. "github.com/sst/opencode/internal/clipboard"
  13. "github.com/sst/opencode/internal/commands"
  14. "github.com/sst/opencode/internal/components/toast"
  15. "github.com/sst/opencode/internal/config"
  16. "github.com/sst/opencode/internal/styles"
  17. "github.com/sst/opencode/internal/theme"
  18. "github.com/sst/opencode/internal/util"
  19. )
  20. type App struct {
  21. Info opencode.App
  22. Version string
  23. StatePath string
  24. Config *opencode.Config
  25. Client *opencode.Client
  26. State *config.State
  27. Provider *opencode.Provider
  28. Model *opencode.Model
  29. Session *opencode.Session
  30. Messages []opencode.MessageUnion
  31. Commands commands.CommandRegistry
  32. InitialModel *string
  33. InitialPrompt *string
  34. compactCancel context.CancelFunc
  35. }
  36. type SessionSelectedMsg = *opencode.Session
  37. type SessionLoadedMsg struct{}
  38. type ModelSelectedMsg struct {
  39. Provider opencode.Provider
  40. Model opencode.Model
  41. }
  42. type SessionClearedMsg struct{}
  43. type CompactSessionMsg struct{}
  44. type SendMsg struct {
  45. Text string
  46. Attachments []opencode.FilePartParam
  47. }
  48. type SetEditorContentMsg struct {
  49. Text string
  50. }
  51. type OptimisticMessageAddedMsg struct {
  52. Message opencode.MessageUnion
  53. }
  54. type FileRenderedMsg struct {
  55. FilePath string
  56. }
  57. func New(
  58. ctx context.Context,
  59. version string,
  60. appInfo opencode.App,
  61. httpClient *opencode.Client,
  62. model *string,
  63. prompt *string,
  64. ) (*App, error) {
  65. util.RootPath = appInfo.Path.Root
  66. util.CwdPath = appInfo.Path.Cwd
  67. configInfo, err := httpClient.Config.Get(ctx)
  68. if err != nil {
  69. return nil, err
  70. }
  71. if configInfo.Keybinds.Leader == "" {
  72. configInfo.Keybinds.Leader = "ctrl+x"
  73. }
  74. appStatePath := filepath.Join(appInfo.Path.State, "tui")
  75. appState, err := config.LoadState(appStatePath)
  76. if err != nil {
  77. appState = config.NewState()
  78. config.SaveState(appStatePath, appState)
  79. }
  80. if configInfo.Theme != "" {
  81. appState.Theme = configInfo.Theme
  82. }
  83. if configInfo.Model != "" {
  84. splits := strings.Split(configInfo.Model, "/")
  85. appState.Provider = splits[0]
  86. appState.Model = strings.Join(splits[1:], "/")
  87. }
  88. if err := theme.LoadThemesFromDirectories(
  89. appInfo.Path.Config,
  90. appInfo.Path.Root,
  91. appInfo.Path.Cwd,
  92. ); err != nil {
  93. slog.Warn("Failed to load themes from directories", "error", err)
  94. }
  95. if appState.Theme != "" {
  96. if appState.Theme == "system" && styles.Terminal != nil {
  97. theme.UpdateSystemTheme(
  98. styles.Terminal.Background,
  99. styles.Terminal.BackgroundIsDark,
  100. )
  101. }
  102. theme.SetTheme(appState.Theme)
  103. }
  104. slog.Debug("Loaded config", "config", configInfo)
  105. app := &App{
  106. Info: appInfo,
  107. Version: version,
  108. StatePath: appStatePath,
  109. Config: configInfo,
  110. State: appState,
  111. Client: httpClient,
  112. Session: &opencode.Session{},
  113. Messages: []opencode.MessageUnion{},
  114. Commands: commands.LoadFromConfig(configInfo),
  115. InitialModel: model,
  116. InitialPrompt: prompt,
  117. }
  118. return app, nil
  119. }
  120. func (a *App) Key(commandName commands.CommandName) string {
  121. t := theme.CurrentTheme()
  122. base := styles.NewStyle().Background(t.Background()).Foreground(t.Text()).Bold(true).Render
  123. muted := styles.NewStyle().
  124. Background(t.Background()).
  125. Foreground(t.TextMuted()).
  126. Faint(true).
  127. Render
  128. command := a.Commands[commandName]
  129. kb := command.Keybindings[0]
  130. key := kb.Key
  131. if kb.RequiresLeader {
  132. key = a.Config.Keybinds.Leader + " " + kb.Key
  133. }
  134. return base(key) + muted(" "+command.Description)
  135. }
  136. func (a *App) SetClipboard(text string) tea.Cmd {
  137. var cmds []tea.Cmd
  138. cmds = append(cmds, func() tea.Msg {
  139. clipboard.Write(clipboard.FmtText, []byte(text))
  140. return nil
  141. })
  142. // try to set the clipboard using OSC52 for terminals that support it
  143. cmds = append(cmds, tea.SetClipboard(text))
  144. return tea.Sequence(cmds...)
  145. }
  146. func (a *App) InitializeProvider() tea.Cmd {
  147. providersResponse, err := a.Client.Config.Providers(context.Background())
  148. if err != nil {
  149. slog.Error("Failed to list providers", "error", err)
  150. // TODO: notify user
  151. return nil
  152. }
  153. providers := providersResponse.Providers
  154. var defaultProvider *opencode.Provider
  155. var defaultModel *opencode.Model
  156. var anthropic *opencode.Provider
  157. for _, provider := range providers {
  158. if provider.ID == "anthropic" {
  159. anthropic = &provider
  160. }
  161. }
  162. // default to anthropic if available
  163. if anthropic != nil {
  164. defaultProvider = anthropic
  165. defaultModel = getDefaultModel(providersResponse, *anthropic)
  166. }
  167. for _, provider := range providers {
  168. if defaultProvider == nil || defaultModel == nil {
  169. defaultProvider = &provider
  170. defaultModel = getDefaultModel(providersResponse, provider)
  171. }
  172. providers = append(providers, provider)
  173. }
  174. if len(providers) == 0 {
  175. slog.Error("No providers configured")
  176. return nil
  177. }
  178. var currentProvider *opencode.Provider
  179. var currentModel *opencode.Model
  180. for _, provider := range providers {
  181. if provider.ID == a.State.Provider {
  182. currentProvider = &provider
  183. for _, model := range provider.Models {
  184. if model.ID == a.State.Model {
  185. currentModel = &model
  186. }
  187. }
  188. }
  189. }
  190. if currentProvider == nil || currentModel == nil {
  191. currentProvider = defaultProvider
  192. currentModel = defaultModel
  193. }
  194. var initialProvider *opencode.Provider
  195. var initialModel *opencode.Model
  196. if a.InitialModel != nil && *a.InitialModel != "" {
  197. splits := strings.Split(*a.InitialModel, "/")
  198. for _, provider := range providers {
  199. if provider.ID == splits[0] {
  200. initialProvider = &provider
  201. for _, model := range provider.Models {
  202. modelID := strings.Join(splits[1:], "/")
  203. if model.ID == modelID {
  204. initialModel = &model
  205. }
  206. }
  207. }
  208. }
  209. }
  210. if initialProvider != nil && initialModel != nil {
  211. currentProvider = initialProvider
  212. currentModel = initialModel
  213. }
  214. var cmds []tea.Cmd
  215. cmds = append(cmds, util.CmdHandler(ModelSelectedMsg{
  216. Provider: *currentProvider,
  217. Model: *currentModel,
  218. }))
  219. if a.InitialPrompt != nil && *a.InitialPrompt != "" {
  220. cmds = append(cmds, util.CmdHandler(SendMsg{Text: *a.InitialPrompt}))
  221. }
  222. return tea.Sequence(cmds...)
  223. }
  224. func getDefaultModel(
  225. response *opencode.ConfigProvidersResponse,
  226. provider opencode.Provider,
  227. ) *opencode.Model {
  228. if match, ok := response.Default[provider.ID]; ok {
  229. model := provider.Models[match]
  230. return &model
  231. } else {
  232. for _, model := range provider.Models {
  233. return &model
  234. }
  235. }
  236. return nil
  237. }
  238. func (a *App) IsBusy() bool {
  239. if len(a.Messages) == 0 {
  240. return false
  241. }
  242. lastMessage := a.Messages[len(a.Messages)-1]
  243. if casted, ok := lastMessage.(opencode.AssistantMessage); ok {
  244. return casted.Time.Completed == 0
  245. }
  246. return false
  247. }
  248. func (a *App) SaveState() {
  249. err := config.SaveState(a.StatePath, a.State)
  250. if err != nil {
  251. slog.Error("Failed to save state", "error", err)
  252. }
  253. }
  254. func (a *App) InitializeProject(ctx context.Context) tea.Cmd {
  255. cmds := []tea.Cmd{}
  256. session, err := a.CreateSession(ctx)
  257. if err != nil {
  258. // status.Error(err.Error())
  259. return nil
  260. }
  261. a.Session = session
  262. cmds = append(cmds, util.CmdHandler(SessionSelectedMsg(session)))
  263. go func() {
  264. _, err := a.Client.Session.Init(ctx, a.Session.ID, opencode.SessionInitParams{
  265. ProviderID: opencode.F(a.Provider.ID),
  266. ModelID: opencode.F(a.Model.ID),
  267. })
  268. if err != nil {
  269. slog.Error("Failed to initialize project", "error", err)
  270. // status.Error(err.Error())
  271. }
  272. }()
  273. return tea.Batch(cmds...)
  274. }
  275. func (a *App) CompactSession(ctx context.Context) tea.Cmd {
  276. if a.compactCancel != nil {
  277. a.compactCancel()
  278. }
  279. compactCtx, cancel := context.WithCancel(ctx)
  280. a.compactCancel = cancel
  281. go func() {
  282. defer func() {
  283. a.compactCancel = nil
  284. }()
  285. _, err := a.Client.Session.Summarize(compactCtx, a.Session.ID, opencode.SessionSummarizeParams{
  286. ProviderID: opencode.F(a.Provider.ID),
  287. ModelID: opencode.F(a.Model.ID),
  288. })
  289. if err != nil {
  290. if compactCtx.Err() != context.Canceled {
  291. slog.Error("Failed to compact session", "error", err)
  292. }
  293. }
  294. }()
  295. return nil
  296. }
  297. func (a *App) MarkProjectInitialized(ctx context.Context) error {
  298. _, err := a.Client.App.Init(ctx)
  299. if err != nil {
  300. slog.Error("Failed to mark project as initialized", "error", err)
  301. return err
  302. }
  303. return nil
  304. }
  305. func (a *App) CreateSession(ctx context.Context) (*opencode.Session, error) {
  306. session, err := a.Client.Session.New(ctx)
  307. if err != nil {
  308. return nil, err
  309. }
  310. return session, nil
  311. }
  312. func (a *App) SendChatMessage(
  313. ctx context.Context,
  314. text string,
  315. attachments []opencode.FilePartParam,
  316. ) (*App, tea.Cmd) {
  317. var cmds []tea.Cmd
  318. if a.Session.ID == "" {
  319. session, err := a.CreateSession(ctx)
  320. if err != nil {
  321. return a, toast.NewErrorToast(err.Error())
  322. }
  323. a.Session = session
  324. cmds = append(cmds, util.CmdHandler(SessionSelectedMsg(session)))
  325. }
  326. optimisticParts := []opencode.UserMessagePart{{
  327. Type: opencode.UserMessagePartTypeText,
  328. Text: text,
  329. }}
  330. if len(attachments) > 0 {
  331. for _, attachment := range attachments {
  332. optimisticParts = append(optimisticParts, opencode.UserMessagePart{
  333. Type: opencode.UserMessagePartTypeFile,
  334. Filename: attachment.Filename.Value,
  335. Mime: attachment.Mime.Value,
  336. URL: attachment.URL.Value,
  337. })
  338. }
  339. }
  340. optimisticMessage := opencode.UserMessage{
  341. ID: fmt.Sprintf("optimistic-%d", time.Now().UnixNano()),
  342. Role: opencode.UserMessageRoleUser,
  343. Parts: optimisticParts,
  344. SessionID: a.Session.ID,
  345. Time: opencode.UserMessageTime{
  346. Created: float64(time.Now().Unix()),
  347. },
  348. }
  349. a.Messages = append(a.Messages, optimisticMessage)
  350. cmds = append(cmds, util.CmdHandler(OptimisticMessageAddedMsg{Message: optimisticMessage}))
  351. cmds = append(cmds, func() tea.Msg {
  352. parts := []opencode.UserMessagePartUnionParam{
  353. opencode.TextPartParam{
  354. Type: opencode.F(opencode.TextPartTypeText),
  355. Text: opencode.F(text),
  356. },
  357. }
  358. if len(attachments) > 0 {
  359. for _, attachment := range attachments {
  360. parts = append(parts, opencode.FilePartParam{
  361. Mime: attachment.Mime,
  362. Type: attachment.Type,
  363. URL: attachment.URL,
  364. Filename: attachment.Filename,
  365. })
  366. }
  367. }
  368. _, err := a.Client.Session.Chat(ctx, a.Session.ID, opencode.SessionChatParams{
  369. Parts: opencode.F(parts),
  370. ProviderID: opencode.F(a.Provider.ID),
  371. ModelID: opencode.F(a.Model.ID),
  372. })
  373. if err != nil {
  374. errormsg := fmt.Sprintf("failed to send message: %v", err)
  375. slog.Error(errormsg)
  376. return toast.NewErrorToast(errormsg)()
  377. }
  378. return nil
  379. })
  380. // The actual response will come through SSE
  381. // For now, just return success
  382. return a, tea.Batch(cmds...)
  383. }
  384. func (a *App) Cancel(ctx context.Context, sessionID string) error {
  385. // Cancel any running compact operation
  386. if a.compactCancel != nil {
  387. a.compactCancel()
  388. a.compactCancel = nil
  389. }
  390. _, err := a.Client.Session.Abort(ctx, sessionID)
  391. if err != nil {
  392. slog.Error("Failed to cancel session", "error", err)
  393. // status.Error(err.Error())
  394. return err
  395. }
  396. return nil
  397. }
  398. func (a *App) ListSessions(ctx context.Context) ([]opencode.Session, error) {
  399. response, err := a.Client.Session.List(ctx)
  400. if err != nil {
  401. return nil, err
  402. }
  403. if response == nil {
  404. return []opencode.Session{}, nil
  405. }
  406. sessions := *response
  407. sort.Slice(sessions, func(i, j int) bool {
  408. return sessions[i].Time.Created-sessions[j].Time.Created > 0
  409. })
  410. return sessions, nil
  411. }
  412. func (a *App) DeleteSession(ctx context.Context, sessionID string) error {
  413. _, err := a.Client.Session.Delete(ctx, sessionID)
  414. if err != nil {
  415. slog.Error("Failed to delete session", "error", err)
  416. return err
  417. }
  418. return nil
  419. }
  420. func (a *App) ListMessages(ctx context.Context, sessionId string) ([]opencode.Message, error) {
  421. response, err := a.Client.Session.Messages(ctx, sessionId)
  422. if err != nil {
  423. return nil, err
  424. }
  425. if response == nil {
  426. return []opencode.Message{}, nil
  427. }
  428. messages := *response
  429. return messages, nil
  430. }
  431. func (a *App) ListProviders(ctx context.Context) ([]opencode.Provider, error) {
  432. response, err := a.Client.Config.Providers(ctx)
  433. if err != nil {
  434. return nil, err
  435. }
  436. if response == nil {
  437. return []opencode.Provider{}, nil
  438. }
  439. providers := *response
  440. return providers.Providers, nil
  441. }
  442. // func (a *App) loadCustomKeybinds() {
  443. //
  444. // }