app.go 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620
  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) SwitchMode() (*App, tea.Cmd) {
  187. a.ModeIndex++
  188. if a.ModeIndex >= len(a.Modes) {
  189. a.ModeIndex = 0
  190. }
  191. a.Mode = &a.Modes[a.ModeIndex]
  192. modelID := a.Mode.Model.ModelID
  193. providerID := a.Mode.Model.ProviderID
  194. if modelID == "" {
  195. if model, ok := a.State.ModeModel[a.Mode.Name]; ok {
  196. modelID = model.ModelID
  197. providerID = model.ProviderID
  198. }
  199. }
  200. if modelID != "" {
  201. for _, provider := range a.Providers {
  202. if provider.ID == providerID {
  203. a.Provider = &provider
  204. for _, model := range provider.Models {
  205. if model.ID == modelID {
  206. a.Model = &model
  207. break
  208. }
  209. }
  210. break
  211. }
  212. }
  213. }
  214. a.State.Mode = a.Mode.Name
  215. return a, func() tea.Msg {
  216. a.SaveState()
  217. return nil
  218. }
  219. }
  220. func (a *App) InitializeProvider() tea.Cmd {
  221. providersResponse, err := a.Client.Config.Providers(context.Background())
  222. if err != nil {
  223. slog.Error("Failed to list providers", "error", err)
  224. // TODO: notify user
  225. return nil
  226. }
  227. providers := providersResponse.Providers
  228. var defaultProvider *opencode.Provider
  229. var defaultModel *opencode.Model
  230. var anthropic *opencode.Provider
  231. for _, provider := range providers {
  232. if provider.ID == "anthropic" {
  233. anthropic = &provider
  234. }
  235. }
  236. // default to anthropic if available
  237. if anthropic != nil {
  238. defaultProvider = anthropic
  239. defaultModel = getDefaultModel(providersResponse, *anthropic)
  240. }
  241. for _, provider := range providers {
  242. if defaultProvider == nil || defaultModel == nil {
  243. defaultProvider = &provider
  244. defaultModel = getDefaultModel(providersResponse, provider)
  245. }
  246. providers = append(providers, provider)
  247. }
  248. if len(providers) == 0 {
  249. slog.Error("No providers configured")
  250. return nil
  251. }
  252. a.Providers = providers
  253. // retains backwards compatibility with old state format
  254. if model, ok := a.State.ModeModel[a.State.Mode]; ok {
  255. a.State.Provider = model.ProviderID
  256. a.State.Model = model.ModelID
  257. }
  258. var currentProvider *opencode.Provider
  259. var currentModel *opencode.Model
  260. for _, provider := range providers {
  261. if provider.ID == a.State.Provider {
  262. currentProvider = &provider
  263. for _, model := range provider.Models {
  264. if model.ID == a.State.Model {
  265. currentModel = &model
  266. }
  267. }
  268. }
  269. }
  270. if currentProvider == nil || currentModel == nil {
  271. currentProvider = defaultProvider
  272. currentModel = defaultModel
  273. }
  274. var initialProvider *opencode.Provider
  275. var initialModel *opencode.Model
  276. if a.InitialModel != nil && *a.InitialModel != "" {
  277. splits := strings.Split(*a.InitialModel, "/")
  278. for _, provider := range providers {
  279. if provider.ID == splits[0] {
  280. initialProvider = &provider
  281. for _, model := range provider.Models {
  282. modelID := strings.Join(splits[1:], "/")
  283. if model.ID == modelID {
  284. initialModel = &model
  285. }
  286. }
  287. }
  288. }
  289. }
  290. if initialProvider != nil && initialModel != nil {
  291. currentProvider = initialProvider
  292. currentModel = initialModel
  293. }
  294. var cmds []tea.Cmd
  295. cmds = append(cmds, util.CmdHandler(ModelSelectedMsg{
  296. Provider: *currentProvider,
  297. Model: *currentModel,
  298. }))
  299. if a.InitialPrompt != nil && *a.InitialPrompt != "" {
  300. cmds = append(cmds, util.CmdHandler(SendMsg{Text: *a.InitialPrompt}))
  301. }
  302. return tea.Sequence(cmds...)
  303. }
  304. func getDefaultModel(
  305. response *opencode.ConfigProvidersResponse,
  306. provider opencode.Provider,
  307. ) *opencode.Model {
  308. if match, ok := response.Default[provider.ID]; ok {
  309. model := provider.Models[match]
  310. return &model
  311. } else {
  312. for _, model := range provider.Models {
  313. return &model
  314. }
  315. }
  316. return nil
  317. }
  318. func (a *App) IsBusy() bool {
  319. if len(a.Messages) == 0 {
  320. return false
  321. }
  322. lastMessage := a.Messages[len(a.Messages)-1]
  323. if casted, ok := lastMessage.Info.(opencode.AssistantMessage); ok {
  324. return casted.Time.Completed == 0
  325. }
  326. return false
  327. }
  328. func (a *App) SaveState() {
  329. err := config.SaveState(a.StatePath, a.State)
  330. if err != nil {
  331. slog.Error("Failed to save state", "error", err)
  332. }
  333. }
  334. func (a *App) InitializeProject(ctx context.Context) tea.Cmd {
  335. cmds := []tea.Cmd{}
  336. session, err := a.CreateSession(ctx)
  337. if err != nil {
  338. // status.Error(err.Error())
  339. return nil
  340. }
  341. a.Session = session
  342. cmds = append(cmds, util.CmdHandler(SessionCreatedMsg{Session: session}))
  343. go func() {
  344. _, err := a.Client.Session.Init(ctx, a.Session.ID, opencode.SessionInitParams{
  345. MessageID: opencode.F(id.Ascending(id.Message)),
  346. ProviderID: opencode.F(a.Provider.ID),
  347. ModelID: opencode.F(a.Model.ID),
  348. })
  349. if err != nil {
  350. slog.Error("Failed to initialize project", "error", err)
  351. // status.Error(err.Error())
  352. }
  353. }()
  354. return tea.Batch(cmds...)
  355. }
  356. func (a *App) CompactSession(ctx context.Context) tea.Cmd {
  357. if a.compactCancel != nil {
  358. a.compactCancel()
  359. }
  360. compactCtx, cancel := context.WithCancel(ctx)
  361. a.compactCancel = cancel
  362. go func() {
  363. defer func() {
  364. a.compactCancel = nil
  365. }()
  366. _, err := a.Client.Session.Summarize(
  367. compactCtx,
  368. a.Session.ID,
  369. opencode.SessionSummarizeParams{
  370. ProviderID: opencode.F(a.Provider.ID),
  371. ModelID: opencode.F(a.Model.ID),
  372. },
  373. )
  374. if err != nil {
  375. if compactCtx.Err() != context.Canceled {
  376. slog.Error("Failed to compact session", "error", err)
  377. }
  378. }
  379. }()
  380. return nil
  381. }
  382. func (a *App) MarkProjectInitialized(ctx context.Context) error {
  383. _, err := a.Client.App.Init(ctx)
  384. if err != nil {
  385. slog.Error("Failed to mark project as initialized", "error", err)
  386. return err
  387. }
  388. return nil
  389. }
  390. func (a *App) CreateSession(ctx context.Context) (*opencode.Session, error) {
  391. session, err := a.Client.Session.New(ctx)
  392. if err != nil {
  393. return nil, err
  394. }
  395. return session, nil
  396. }
  397. func (a *App) SendChatMessage(
  398. ctx context.Context,
  399. text string,
  400. attachments []opencode.FilePartParam,
  401. ) (*App, tea.Cmd) {
  402. var cmds []tea.Cmd
  403. if a.Session.ID == "" {
  404. session, err := a.CreateSession(ctx)
  405. if err != nil {
  406. return a, toast.NewErrorToast(err.Error())
  407. }
  408. a.Session = session
  409. cmds = append(cmds, util.CmdHandler(SessionCreatedMsg{Session: session}))
  410. }
  411. message := opencode.UserMessage{
  412. ID: id.Ascending(id.Message),
  413. SessionID: a.Session.ID,
  414. Role: opencode.UserMessageRoleUser,
  415. Time: opencode.UserMessageTime{
  416. Created: float64(time.Now().UnixMilli()),
  417. },
  418. }
  419. parts := []opencode.PartUnion{opencode.TextPart{
  420. ID: id.Ascending(id.Part),
  421. MessageID: message.ID,
  422. SessionID: a.Session.ID,
  423. Type: opencode.TextPartTypeText,
  424. Text: text,
  425. }}
  426. if len(attachments) > 0 {
  427. for _, attachment := range attachments {
  428. parts = append(parts, opencode.FilePart{
  429. ID: id.Ascending(id.Part),
  430. MessageID: message.ID,
  431. SessionID: a.Session.ID,
  432. Type: opencode.FilePartTypeFile,
  433. Filename: attachment.Filename.Value,
  434. Mime: attachment.Mime.Value,
  435. URL: attachment.URL.Value,
  436. })
  437. }
  438. }
  439. a.Messages = append(a.Messages, Message{Info: message, Parts: parts})
  440. cmds = append(cmds, util.CmdHandler(OptimisticMessageAddedMsg{Message: message}))
  441. cmds = append(cmds, func() tea.Msg {
  442. partsParam := []opencode.SessionChatParamsPartUnion{}
  443. for _, part := range parts {
  444. switch casted := part.(type) {
  445. case opencode.TextPart:
  446. partsParam = append(partsParam, opencode.TextPartParam{
  447. ID: opencode.F(casted.ID),
  448. MessageID: opencode.F(casted.MessageID),
  449. SessionID: opencode.F(casted.SessionID),
  450. Type: opencode.F(casted.Type),
  451. Text: opencode.F(casted.Text),
  452. })
  453. case opencode.FilePart:
  454. partsParam = append(partsParam, opencode.FilePartParam{
  455. ID: opencode.F(casted.ID),
  456. Mime: opencode.F(casted.Mime),
  457. MessageID: opencode.F(casted.MessageID),
  458. SessionID: opencode.F(casted.SessionID),
  459. Type: opencode.F(casted.Type),
  460. URL: opencode.F(casted.URL),
  461. Filename: opencode.F(casted.Filename),
  462. })
  463. }
  464. }
  465. _, err := a.Client.Session.Chat(ctx, a.Session.ID, opencode.SessionChatParams{
  466. Parts: opencode.F(partsParam),
  467. MessageID: opencode.F(message.ID),
  468. ProviderID: opencode.F(a.Provider.ID),
  469. ModelID: opencode.F(a.Model.ID),
  470. Mode: opencode.F(a.Mode.Name),
  471. })
  472. if err != nil {
  473. errormsg := fmt.Sprintf("failed to send message: %v", err)
  474. slog.Error(errormsg)
  475. return toast.NewErrorToast(errormsg)()
  476. }
  477. return nil
  478. })
  479. // The actual response will come through SSE
  480. // For now, just return success
  481. return a, tea.Batch(cmds...)
  482. }
  483. func (a *App) Cancel(ctx context.Context, sessionID string) error {
  484. // Cancel any running compact operation
  485. if a.compactCancel != nil {
  486. a.compactCancel()
  487. a.compactCancel = nil
  488. }
  489. _, err := a.Client.Session.Abort(ctx, sessionID)
  490. if err != nil {
  491. slog.Error("Failed to cancel session", "error", err)
  492. // status.Error(err.Error())
  493. return err
  494. }
  495. return nil
  496. }
  497. func (a *App) ListSessions(ctx context.Context) ([]opencode.Session, error) {
  498. response, err := a.Client.Session.List(ctx)
  499. if err != nil {
  500. return nil, err
  501. }
  502. if response == nil {
  503. return []opencode.Session{}, nil
  504. }
  505. sessions := *response
  506. sort.Slice(sessions, func(i, j int) bool {
  507. return sessions[i].Time.Created-sessions[j].Time.Created > 0
  508. })
  509. return sessions, nil
  510. }
  511. func (a *App) DeleteSession(ctx context.Context, sessionID string) error {
  512. _, err := a.Client.Session.Delete(ctx, sessionID)
  513. if err != nil {
  514. slog.Error("Failed to delete session", "error", err)
  515. return err
  516. }
  517. return nil
  518. }
  519. func (a *App) ListMessages(ctx context.Context, sessionId string) ([]Message, error) {
  520. response, err := a.Client.Session.Messages(ctx, sessionId)
  521. if err != nil {
  522. return nil, err
  523. }
  524. if response == nil {
  525. return []Message{}, nil
  526. }
  527. messages := []Message{}
  528. for _, message := range *response {
  529. msg := Message{
  530. Info: message.Info.AsUnion(),
  531. Parts: []opencode.PartUnion{},
  532. }
  533. for _, part := range message.Parts {
  534. msg.Parts = append(msg.Parts, part.AsUnion())
  535. }
  536. messages = append(messages, msg)
  537. }
  538. return messages, nil
  539. }
  540. func (a *App) ListProviders(ctx context.Context) ([]opencode.Provider, error) {
  541. response, err := a.Client.Config.Providers(ctx)
  542. if err != nil {
  543. return nil, err
  544. }
  545. if response == nil {
  546. return []opencode.Provider{}, nil
  547. }
  548. providers := *response
  549. return providers.Providers, nil
  550. }
  551. // func (a *App) loadCustomKeybinds() {
  552. //
  553. // }