app.go 17 KB

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