app.go 12 KB

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