app.go 15 KB

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