chat.go 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263
  1. package page
  2. import (
  3. "context"
  4. "fmt"
  5. "strings"
  6. "github.com/charmbracelet/bubbles/key"
  7. tea "github.com/charmbracelet/bubbletea"
  8. "github.com/charmbracelet/lipgloss"
  9. "github.com/sst/opencode/internal/app"
  10. "github.com/sst/opencode/internal/completions"
  11. "github.com/sst/opencode/internal/message"
  12. "github.com/sst/opencode/internal/session"
  13. "github.com/sst/opencode/internal/status"
  14. "github.com/sst/opencode/internal/tui/components/chat"
  15. "github.com/sst/opencode/internal/tui/components/dialog"
  16. "github.com/sst/opencode/internal/tui/layout"
  17. "github.com/sst/opencode/internal/tui/state"
  18. "github.com/sst/opencode/internal/tui/util"
  19. )
  20. var ChatPage PageID = "chat"
  21. type chatPage struct {
  22. app *app.App
  23. editor layout.Container
  24. messages layout.Container
  25. layout layout.SplitPaneLayout
  26. completionDialog dialog.CompletionDialog
  27. showCompletionDialog bool
  28. }
  29. type ChatKeyMap struct {
  30. NewSession key.Binding
  31. Cancel key.Binding
  32. ToggleTools key.Binding
  33. ShowCompletionDialog key.Binding
  34. }
  35. var keyMap = ChatKeyMap{
  36. NewSession: key.NewBinding(
  37. key.WithKeys("ctrl+n"),
  38. key.WithHelp("ctrl+n", "new session"),
  39. ),
  40. Cancel: key.NewBinding(
  41. key.WithKeys("esc"),
  42. key.WithHelp("esc", "cancel"),
  43. ),
  44. ToggleTools: key.NewBinding(
  45. key.WithKeys("ctrl+h"),
  46. key.WithHelp("ctrl+h", "toggle tools"),
  47. ),
  48. ShowCompletionDialog: key.NewBinding(
  49. key.WithKeys("/"),
  50. key.WithHelp("/", "Complete"),
  51. ),
  52. }
  53. func (p *chatPage) Init() tea.Cmd {
  54. cmds := []tea.Cmd{
  55. p.layout.Init(),
  56. }
  57. cmds = append(cmds, p.completionDialog.Init())
  58. return tea.Batch(cmds...)
  59. }
  60. func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
  61. var cmds []tea.Cmd
  62. switch msg := msg.(type) {
  63. case tea.WindowSizeMsg:
  64. cmd := p.layout.SetSize(msg.Width, msg.Height)
  65. cmds = append(cmds, cmd)
  66. case chat.SendMsg:
  67. cmd := p.sendMessage(msg.Text, msg.Attachments)
  68. if cmd != nil {
  69. return p, cmd
  70. }
  71. case dialog.CommandRunCustomMsg:
  72. // Check if the agent is busy before executing custom commands
  73. if p.app.PrimaryAgent.IsBusy() {
  74. status.Warn("Agent is busy, please wait before executing a command...")
  75. return p, nil
  76. }
  77. // Process the command content with arguments if any
  78. content := msg.Content
  79. if msg.Args != nil {
  80. // Replace all named arguments with their values
  81. for name, value := range msg.Args {
  82. placeholder := "$" + name
  83. content = strings.ReplaceAll(content, placeholder, value)
  84. }
  85. }
  86. // Handle custom command execution
  87. cmd := p.sendMessage(content, nil)
  88. if cmd != nil {
  89. return p, cmd
  90. }
  91. case state.SessionSelectedMsg:
  92. cmd := p.setSidebar()
  93. cmds = append(cmds, cmd)
  94. case state.SessionClearedMsg:
  95. cmd := p.setSidebar()
  96. cmds = append(cmds, cmd)
  97. case state.CompactSessionMsg:
  98. if p.app.CurrentSession.ID == "" {
  99. status.Warn("No active session to compact.")
  100. return p, nil
  101. }
  102. // Run compaction in background
  103. go func(sessionID string) {
  104. err := p.app.PrimaryAgent.CompactSession(context.Background(), sessionID, false)
  105. if err != nil {
  106. status.Error(fmt.Sprintf("Compaction failed: %v", err))
  107. } else {
  108. status.Info("Conversation compacted successfully.")
  109. }
  110. }(p.app.CurrentSession.ID)
  111. return p, nil
  112. case dialog.CompletionDialogCloseMsg:
  113. p.showCompletionDialog = false
  114. case tea.KeyMsg:
  115. switch {
  116. case key.Matches(msg, keyMap.ShowCompletionDialog):
  117. p.showCompletionDialog = true
  118. // Continue sending keys to layout->chat
  119. case key.Matches(msg, keyMap.NewSession):
  120. p.app.CurrentSession = &session.Session{}
  121. return p, tea.Batch(
  122. p.clearSidebar(),
  123. util.CmdHandler(state.SessionClearedMsg{}),
  124. )
  125. case key.Matches(msg, keyMap.Cancel):
  126. if p.app.CurrentSession.ID != "" {
  127. // Cancel the current session's generation process
  128. // This allows users to interrupt long-running operations
  129. p.app.PrimaryAgent.Cancel(p.app.CurrentSession.ID)
  130. return p, nil
  131. }
  132. case key.Matches(msg, keyMap.ToggleTools):
  133. return p, util.CmdHandler(chat.ToggleToolMessagesMsg{})
  134. }
  135. }
  136. if p.showCompletionDialog {
  137. context, contextCmd := p.completionDialog.Update(msg)
  138. p.completionDialog = context.(dialog.CompletionDialog)
  139. cmds = append(cmds, contextCmd)
  140. // Doesn't forward event if enter key is pressed
  141. if keyMsg, ok := msg.(tea.KeyMsg); ok {
  142. if keyMsg.String() == "enter" {
  143. return p, tea.Batch(cmds...)
  144. }
  145. }
  146. }
  147. u, cmd := p.layout.Update(msg)
  148. cmds = append(cmds, cmd)
  149. p.layout = u.(layout.SplitPaneLayout)
  150. return p, tea.Batch(cmds...)
  151. }
  152. func (p *chatPage) setSidebar() tea.Cmd {
  153. sidebarContainer := layout.NewContainer(
  154. chat.NewSidebarCmp(p.app),
  155. layout.WithPadding(1, 1, 1, 1),
  156. )
  157. return tea.Batch(p.layout.SetRightPanel(sidebarContainer), sidebarContainer.Init())
  158. }
  159. func (p *chatPage) clearSidebar() tea.Cmd {
  160. return p.layout.ClearRightPanel()
  161. }
  162. func (p *chatPage) sendMessage(text string, attachments []message.Attachment) tea.Cmd {
  163. var cmds []tea.Cmd
  164. if p.app.CurrentSession.ID == "" {
  165. newSession, err := p.app.Sessions.Create(context.Background(), "New Session")
  166. if err != nil {
  167. status.Error(err.Error())
  168. return nil
  169. }
  170. p.app.CurrentSession = &newSession
  171. cmd := p.setSidebar()
  172. if cmd != nil {
  173. cmds = append(cmds, cmd)
  174. }
  175. cmds = append(cmds, util.CmdHandler(state.SessionSelectedMsg(&newSession)))
  176. }
  177. _, err := p.app.PrimaryAgent.Run(context.Background(), p.app.CurrentSession.ID, text, attachments...)
  178. if err != nil {
  179. status.Error(err.Error())
  180. return nil
  181. }
  182. return tea.Batch(cmds...)
  183. }
  184. func (p *chatPage) SetSize(width, height int) tea.Cmd {
  185. return p.layout.SetSize(width, height)
  186. }
  187. func (p *chatPage) GetSize() (int, int) {
  188. return p.layout.GetSize()
  189. }
  190. func (p *chatPage) View() string {
  191. layoutView := p.layout.View()
  192. if p.showCompletionDialog {
  193. _, layoutHeight := p.layout.GetSize()
  194. editorWidth, editorHeight := p.editor.GetSize()
  195. p.completionDialog.SetWidth(editorWidth)
  196. overlay := p.completionDialog.View()
  197. layoutView = layout.PlaceOverlay(
  198. 0,
  199. layoutHeight-editorHeight-lipgloss.Height(overlay),
  200. overlay,
  201. layoutView,
  202. false,
  203. )
  204. }
  205. return layoutView
  206. }
  207. func (p *chatPage) BindingKeys() []key.Binding {
  208. bindings := layout.KeyMapToSlice(keyMap)
  209. bindings = append(bindings, p.messages.BindingKeys()...)
  210. bindings = append(bindings, p.editor.BindingKeys()...)
  211. return bindings
  212. }
  213. func NewChatPage(app *app.App) tea.Model {
  214. cg := completions.NewFileAndFolderContextGroup()
  215. completionDialog := dialog.NewCompletionDialogCmp(cg)
  216. messagesContainer := layout.NewContainer(
  217. chat.NewMessagesCmp(app),
  218. layout.WithPadding(1, 1, 0, 1),
  219. )
  220. editorContainer := layout.NewContainer(
  221. chat.NewEditorCmp(app),
  222. layout.WithBorder(true, false, false, false),
  223. )
  224. return &chatPage{
  225. app: app,
  226. editor: editorContainer,
  227. messages: messagesContainer,
  228. completionDialog: completionDialog,
  229. layout: layout.NewSplitPane(
  230. layout.WithLeftPanel(messagesContainer),
  231. layout.WithBottomPanel(editorContainer),
  232. ),
  233. }
  234. }