app.go 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635
  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/id"
  17. "github.com/sst/opencode/internal/styles"
  18. "github.com/sst/opencode/internal/theme"
  19. "github.com/sst/opencode/internal/util"
  20. )
  21. type Message struct {
  22. Info opencode.MessageUnion
  23. Parts []opencode.PartUnion
  24. }
  25. type App struct {
  26. Info opencode.App
  27. Modes []opencode.Mode
  28. Providers []opencode.Provider
  29. Version string
  30. StatePath string
  31. Config *opencode.Config
  32. Client *opencode.Client
  33. State *config.State
  34. ModeIndex int
  35. Mode *opencode.Mode
  36. Provider *opencode.Provider
  37. Model *opencode.Model
  38. Session *opencode.Session
  39. Messages []Message
  40. Commands commands.CommandRegistry
  41. InitialModel *string
  42. InitialPrompt *string
  43. IntitialMode *string
  44. compactCancel context.CancelFunc
  45. IsLeaderSequence bool
  46. }
  47. type SessionCreatedMsg = struct {
  48. Session *opencode.Session
  49. }
  50. type SessionSelectedMsg = *opencode.Session
  51. type SessionLoadedMsg struct{}
  52. type ModelSelectedMsg struct {
  53. Provider opencode.Provider
  54. Model opencode.Model
  55. }
  56. type SessionClearedMsg struct{}
  57. type CompactSessionMsg struct{}
  58. type SendMsg struct {
  59. Text string
  60. Attachments []opencode.FilePartParam
  61. }
  62. type SetEditorContentMsg struct {
  63. Text string
  64. }
  65. type OptimisticMessageAddedMsg struct {
  66. Message opencode.MessageUnion
  67. }
  68. type FileRenderedMsg struct {
  69. FilePath string
  70. }
  71. func New(
  72. ctx context.Context,
  73. version string,
  74. appInfo opencode.App,
  75. modes []opencode.Mode,
  76. httpClient *opencode.Client,
  77. initialModel *string,
  78. initialPrompt *string,
  79. initialMode *string,
  80. ) (*App, error) {
  81. util.RootPath = appInfo.Path.Root
  82. util.CwdPath = appInfo.Path.Cwd
  83. configInfo, err := httpClient.Config.Get(ctx)
  84. if err != nil {
  85. return nil, err
  86. }
  87. if configInfo.Keybinds.Leader == "" {
  88. configInfo.Keybinds.Leader = "ctrl+x"
  89. }
  90. appStatePath := filepath.Join(appInfo.Path.State, "tui")
  91. appState, err := config.LoadState(appStatePath)
  92. if err != nil {
  93. appState = config.NewState()
  94. config.SaveState(appStatePath, appState)
  95. }
  96. if appState.ModeModel == nil {
  97. appState.ModeModel = make(map[string]config.ModeModel)
  98. }
  99. if configInfo.Theme != "" {
  100. appState.Theme = configInfo.Theme
  101. }
  102. var modeIndex int
  103. var mode *opencode.Mode
  104. modeName := "build"
  105. if appState.Mode != "" {
  106. modeName = appState.Mode
  107. }
  108. if initialMode != nil && *initialMode != "" {
  109. modeName = *initialMode
  110. }
  111. for i, m := range modes {
  112. if m.Name == modeName {
  113. modeIndex = i
  114. break
  115. }
  116. }
  117. mode = &modes[modeIndex]
  118. if mode.Model.ModelID != "" {
  119. appState.ModeModel[mode.Name] = config.ModeModel{
  120. ProviderID: mode.Model.ProviderID,
  121. ModelID: mode.Model.ModelID,
  122. }
  123. }
  124. if err := theme.LoadThemesFromDirectories(
  125. appInfo.Path.Config,
  126. appInfo.Path.Root,
  127. appInfo.Path.Cwd,
  128. ); err != nil {
  129. slog.Warn("Failed to load themes from directories", "error", err)
  130. }
  131. if appState.Theme != "" {
  132. if appState.Theme == "system" && styles.Terminal != nil {
  133. theme.UpdateSystemTheme(
  134. styles.Terminal.Background,
  135. styles.Terminal.BackgroundIsDark,
  136. )
  137. }
  138. theme.SetTheme(appState.Theme)
  139. }
  140. slog.Debug("Loaded config", "config", configInfo)
  141. app := &App{
  142. Info: appInfo,
  143. Modes: modes,
  144. Version: version,
  145. StatePath: appStatePath,
  146. Config: configInfo,
  147. State: appState,
  148. Client: httpClient,
  149. ModeIndex: modeIndex,
  150. Mode: mode,
  151. Session: &opencode.Session{},
  152. Messages: []Message{},
  153. Commands: commands.LoadFromConfig(configInfo),
  154. InitialModel: initialModel,
  155. InitialPrompt: initialPrompt,
  156. IntitialMode: initialMode,
  157. }
  158. return app, nil
  159. }
  160. func (a *App) Key(commandName commands.CommandName) string {
  161. t := theme.CurrentTheme()
  162. base := styles.NewStyle().Background(t.Background()).Foreground(t.Text()).Bold(true).Render
  163. muted := styles.NewStyle().
  164. Background(t.Background()).
  165. Foreground(t.TextMuted()).
  166. Faint(true).
  167. Render
  168. command := a.Commands[commandName]
  169. kb := command.Keybindings[0]
  170. key := kb.Key
  171. if kb.RequiresLeader {
  172. key = a.Config.Keybinds.Leader + " " + kb.Key
  173. }
  174. return base(key) + muted(" "+command.Description)
  175. }
  176. func (a *App) SetClipboard(text string) tea.Cmd {
  177. var cmds []tea.Cmd
  178. cmds = append(cmds, func() tea.Msg {
  179. clipboard.Write(clipboard.FmtText, []byte(text))
  180. return nil
  181. })
  182. // try to set the clipboard using OSC52 for terminals that support it
  183. cmds = append(cmds, tea.SetClipboard(text))
  184. return tea.Sequence(cmds...)
  185. }
  186. func (a *App) cycleMode(forward bool) (*App, tea.Cmd) {
  187. if forward {
  188. a.ModeIndex++
  189. if a.ModeIndex >= len(a.Modes) {
  190. a.ModeIndex = 0
  191. }
  192. } else {
  193. a.ModeIndex--
  194. if a.ModeIndex < 0 {
  195. a.ModeIndex = len(a.Modes) - 1
  196. }
  197. }
  198. a.Mode = &a.Modes[a.ModeIndex]
  199. modelID := a.Mode.Model.ModelID
  200. providerID := a.Mode.Model.ProviderID
  201. if modelID == "" {
  202. if model, ok := a.State.ModeModel[a.Mode.Name]; ok {
  203. modelID = model.ModelID
  204. providerID = model.ProviderID
  205. }
  206. }
  207. if modelID != "" {
  208. for _, provider := range a.Providers {
  209. if provider.ID == providerID {
  210. a.Provider = &provider
  211. for _, model := range provider.Models {
  212. if model.ID == modelID {
  213. a.Model = &model
  214. break
  215. }
  216. }
  217. break
  218. }
  219. }
  220. }
  221. a.State.Mode = a.Mode.Name
  222. return a, func() tea.Msg {
  223. a.SaveState()
  224. return nil
  225. }
  226. }
  227. func (a *App) SwitchMode() (*App, tea.Cmd) {
  228. return a.cycleMode(true)
  229. }
  230. func (a *App) SwitchModeReverse() (*App, tea.Cmd) {
  231. return a.cycleMode(false)
  232. }
  233. func (a *App) InitializeProvider() tea.Cmd {
  234. providersResponse, err := a.Client.App.Providers(context.Background())
  235. if err != nil {
  236. slog.Error("Failed to list providers", "error", err)
  237. // TODO: notify user
  238. return nil
  239. }
  240. providers := providersResponse.Providers
  241. var defaultProvider *opencode.Provider
  242. var defaultModel *opencode.Model
  243. var anthropic *opencode.Provider
  244. for _, provider := range providers {
  245. if provider.ID == "anthropic" {
  246. anthropic = &provider
  247. }
  248. }
  249. // default to anthropic if available
  250. if anthropic != nil {
  251. defaultProvider = anthropic
  252. defaultModel = getDefaultModel(providersResponse, *anthropic)
  253. }
  254. for _, provider := range providers {
  255. if defaultProvider == nil || defaultModel == nil {
  256. defaultProvider = &provider
  257. defaultModel = getDefaultModel(providersResponse, provider)
  258. }
  259. providers = append(providers, provider)
  260. }
  261. if len(providers) == 0 {
  262. slog.Error("No providers configured")
  263. return nil
  264. }
  265. a.Providers = providers
  266. // retains backwards compatibility with old state format
  267. if model, ok := a.State.ModeModel[a.State.Mode]; ok {
  268. a.State.Provider = model.ProviderID
  269. a.State.Model = model.ModelID
  270. }
  271. var currentProvider *opencode.Provider
  272. var currentModel *opencode.Model
  273. for _, provider := range providers {
  274. if provider.ID == a.State.Provider {
  275. currentProvider = &provider
  276. for _, model := range provider.Models {
  277. if model.ID == a.State.Model {
  278. currentModel = &model
  279. }
  280. }
  281. }
  282. }
  283. if currentProvider == nil || currentModel == nil {
  284. currentProvider = defaultProvider
  285. currentModel = defaultModel
  286. }
  287. var initialProvider *opencode.Provider
  288. var initialModel *opencode.Model
  289. if a.InitialModel != nil && *a.InitialModel != "" {
  290. splits := strings.Split(*a.InitialModel, "/")
  291. for _, provider := range providers {
  292. if provider.ID == splits[0] {
  293. initialProvider = &provider
  294. for _, model := range provider.Models {
  295. modelID := strings.Join(splits[1:], "/")
  296. if model.ID == modelID {
  297. initialModel = &model
  298. }
  299. }
  300. }
  301. }
  302. }
  303. if initialProvider != nil && initialModel != nil {
  304. currentProvider = initialProvider
  305. currentModel = initialModel
  306. }
  307. var cmds []tea.Cmd
  308. cmds = append(cmds, util.CmdHandler(ModelSelectedMsg{
  309. Provider: *currentProvider,
  310. Model: *currentModel,
  311. }))
  312. if a.InitialPrompt != nil && *a.InitialPrompt != "" {
  313. cmds = append(cmds, util.CmdHandler(SendMsg{Text: *a.InitialPrompt}))
  314. }
  315. return tea.Sequence(cmds...)
  316. }
  317. func getDefaultModel(
  318. response *opencode.AppProvidersResponse,
  319. provider opencode.Provider,
  320. ) *opencode.Model {
  321. if match, ok := response.Default[provider.ID]; ok {
  322. model := provider.Models[match]
  323. return &model
  324. } else {
  325. for _, model := range provider.Models {
  326. return &model
  327. }
  328. }
  329. return nil
  330. }
  331. func (a *App) IsBusy() bool {
  332. if len(a.Messages) == 0 {
  333. return false
  334. }
  335. lastMessage := a.Messages[len(a.Messages)-1]
  336. if casted, ok := lastMessage.Info.(opencode.AssistantMessage); ok {
  337. return casted.Time.Completed == 0
  338. }
  339. return false
  340. }
  341. func (a *App) SaveState() {
  342. err := config.SaveState(a.StatePath, a.State)
  343. if err != nil {
  344. slog.Error("Failed to save state", "error", err)
  345. }
  346. }
  347. func (a *App) InitializeProject(ctx context.Context) tea.Cmd {
  348. cmds := []tea.Cmd{}
  349. session, err := a.CreateSession(ctx)
  350. if err != nil {
  351. // status.Error(err.Error())
  352. return nil
  353. }
  354. a.Session = session
  355. cmds = append(cmds, util.CmdHandler(SessionCreatedMsg{Session: session}))
  356. go func() {
  357. _, err := a.Client.Session.Init(ctx, a.Session.ID, opencode.SessionInitParams{
  358. MessageID: opencode.F(id.Ascending(id.Message)),
  359. ProviderID: opencode.F(a.Provider.ID),
  360. ModelID: opencode.F(a.Model.ID),
  361. })
  362. if err != nil {
  363. slog.Error("Failed to initialize project", "error", err)
  364. // status.Error(err.Error())
  365. }
  366. }()
  367. return tea.Batch(cmds...)
  368. }
  369. func (a *App) CompactSession(ctx context.Context) tea.Cmd {
  370. if a.compactCancel != nil {
  371. a.compactCancel()
  372. }
  373. compactCtx, cancel := context.WithCancel(ctx)
  374. a.compactCancel = cancel
  375. go func() {
  376. defer func() {
  377. a.compactCancel = nil
  378. }()
  379. _, err := a.Client.Session.Summarize(
  380. compactCtx,
  381. a.Session.ID,
  382. opencode.SessionSummarizeParams{
  383. ProviderID: opencode.F(a.Provider.ID),
  384. ModelID: opencode.F(a.Model.ID),
  385. },
  386. )
  387. if err != nil {
  388. if compactCtx.Err() != context.Canceled {
  389. slog.Error("Failed to compact session", "error", err)
  390. }
  391. }
  392. }()
  393. return nil
  394. }
  395. func (a *App) MarkProjectInitialized(ctx context.Context) error {
  396. _, err := a.Client.App.Init(ctx)
  397. if err != nil {
  398. slog.Error("Failed to mark project as initialized", "error", err)
  399. return err
  400. }
  401. return nil
  402. }
  403. func (a *App) CreateSession(ctx context.Context) (*opencode.Session, error) {
  404. session, err := a.Client.Session.New(ctx)
  405. if err != nil {
  406. return nil, err
  407. }
  408. return session, nil
  409. }
  410. func (a *App) SendChatMessage(
  411. ctx context.Context,
  412. text string,
  413. attachments []opencode.FilePartParam,
  414. ) (*App, tea.Cmd) {
  415. var cmds []tea.Cmd
  416. if a.Session.ID == "" {
  417. session, err := a.CreateSession(ctx)
  418. if err != nil {
  419. return a, toast.NewErrorToast(err.Error())
  420. }
  421. a.Session = session
  422. cmds = append(cmds, util.CmdHandler(SessionCreatedMsg{Session: session}))
  423. }
  424. message := opencode.UserMessage{
  425. ID: id.Ascending(id.Message),
  426. SessionID: a.Session.ID,
  427. Role: opencode.UserMessageRoleUser,
  428. Time: opencode.UserMessageTime{
  429. Created: float64(time.Now().UnixMilli()),
  430. },
  431. }
  432. parts := []opencode.PartUnion{opencode.TextPart{
  433. ID: id.Ascending(id.Part),
  434. MessageID: message.ID,
  435. SessionID: a.Session.ID,
  436. Type: opencode.TextPartTypeText,
  437. Text: text,
  438. }}
  439. if len(attachments) > 0 {
  440. for _, attachment := range attachments {
  441. parts = append(parts, opencode.FilePart{
  442. ID: id.Ascending(id.Part),
  443. MessageID: message.ID,
  444. SessionID: a.Session.ID,
  445. Type: opencode.FilePartTypeFile,
  446. Filename: attachment.Filename.Value,
  447. Mime: attachment.Mime.Value,
  448. URL: attachment.URL.Value,
  449. })
  450. }
  451. }
  452. a.Messages = append(a.Messages, Message{Info: message, Parts: parts})
  453. cmds = append(cmds, util.CmdHandler(OptimisticMessageAddedMsg{Message: message}))
  454. cmds = append(cmds, func() tea.Msg {
  455. partsParam := []opencode.SessionChatParamsPartUnion{}
  456. for _, part := range parts {
  457. switch casted := part.(type) {
  458. case opencode.TextPart:
  459. partsParam = append(partsParam, opencode.TextPartParam{
  460. ID: opencode.F(casted.ID),
  461. MessageID: opencode.F(casted.MessageID),
  462. SessionID: opencode.F(casted.SessionID),
  463. Type: opencode.F(casted.Type),
  464. Text: opencode.F(casted.Text),
  465. })
  466. case opencode.FilePart:
  467. partsParam = append(partsParam, opencode.FilePartParam{
  468. ID: opencode.F(casted.ID),
  469. Mime: opencode.F(casted.Mime),
  470. MessageID: opencode.F(casted.MessageID),
  471. SessionID: opencode.F(casted.SessionID),
  472. Type: opencode.F(casted.Type),
  473. URL: opencode.F(casted.URL),
  474. Filename: opencode.F(casted.Filename),
  475. })
  476. }
  477. }
  478. _, err := a.Client.Session.Chat(ctx, a.Session.ID, opencode.SessionChatParams{
  479. Parts: opencode.F(partsParam),
  480. MessageID: opencode.F(message.ID),
  481. ProviderID: opencode.F(a.Provider.ID),
  482. ModelID: opencode.F(a.Model.ID),
  483. Mode: opencode.F(a.Mode.Name),
  484. })
  485. if err != nil {
  486. errormsg := fmt.Sprintf("failed to send message: %v", err)
  487. slog.Error(errormsg)
  488. return toast.NewErrorToast(errormsg)()
  489. }
  490. return nil
  491. })
  492. // The actual response will come through SSE
  493. // For now, just return success
  494. return a, tea.Batch(cmds...)
  495. }
  496. func (a *App) Cancel(ctx context.Context, sessionID string) error {
  497. // Cancel any running compact operation
  498. if a.compactCancel != nil {
  499. a.compactCancel()
  500. a.compactCancel = nil
  501. }
  502. _, err := a.Client.Session.Abort(ctx, sessionID)
  503. if err != nil {
  504. slog.Error("Failed to cancel session", "error", err)
  505. // status.Error(err.Error())
  506. return err
  507. }
  508. return nil
  509. }
  510. func (a *App) ListSessions(ctx context.Context) ([]opencode.Session, error) {
  511. response, err := a.Client.Session.List(ctx)
  512. if err != nil {
  513. return nil, err
  514. }
  515. if response == nil {
  516. return []opencode.Session{}, nil
  517. }
  518. sessions := *response
  519. sort.Slice(sessions, func(i, j int) bool {
  520. return sessions[i].Time.Created-sessions[j].Time.Created > 0
  521. })
  522. return sessions, nil
  523. }
  524. func (a *App) DeleteSession(ctx context.Context, sessionID string) error {
  525. _, err := a.Client.Session.Delete(ctx, sessionID)
  526. if err != nil {
  527. slog.Error("Failed to delete session", "error", err)
  528. return err
  529. }
  530. return nil
  531. }
  532. func (a *App) ListMessages(ctx context.Context, sessionId string) ([]Message, error) {
  533. response, err := a.Client.Session.Messages(ctx, sessionId)
  534. if err != nil {
  535. return nil, err
  536. }
  537. if response == nil {
  538. return []Message{}, nil
  539. }
  540. messages := []Message{}
  541. for _, message := range *response {
  542. msg := Message{
  543. Info: message.Info.AsUnion(),
  544. Parts: []opencode.PartUnion{},
  545. }
  546. for _, part := range message.Parts {
  547. msg.Parts = append(msg.Parts, part.AsUnion())
  548. }
  549. messages = append(messages, msg)
  550. }
  551. return messages, nil
  552. }
  553. func (a *App) ListProviders(ctx context.Context) ([]opencode.Provider, error) {
  554. response, err := a.Client.App.Providers(ctx)
  555. if err != nil {
  556. return nil, err
  557. }
  558. if response == nil {
  559. return []opencode.Provider{}, nil
  560. }
  561. providers := *response
  562. return providers.Providers, nil
  563. }
  564. // func (a *App) loadCustomKeybinds() {
  565. //
  566. // }