editor.go 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321
  1. package chat
  2. import (
  3. "fmt"
  4. "log/slog"
  5. "strings"
  6. "github.com/charmbracelet/bubbles/v2/spinner"
  7. tea "github.com/charmbracelet/bubbletea/v2"
  8. "github.com/charmbracelet/lipgloss/v2"
  9. "github.com/sst/opencode/internal/app"
  10. "github.com/sst/opencode/internal/commands"
  11. "github.com/sst/opencode/internal/components/dialog"
  12. "github.com/sst/opencode/internal/components/textarea"
  13. "github.com/sst/opencode/internal/image"
  14. "github.com/sst/opencode/internal/layout"
  15. "github.com/sst/opencode/internal/styles"
  16. "github.com/sst/opencode/internal/theme"
  17. "github.com/sst/opencode/internal/util"
  18. )
  19. type EditorComponent interface {
  20. tea.Model
  21. tea.ViewModel
  22. layout.Sizeable
  23. Content() string
  24. Lines() int
  25. Value() string
  26. Submit() (tea.Model, tea.Cmd)
  27. Clear() (tea.Model, tea.Cmd)
  28. Paste() (tea.Model, tea.Cmd)
  29. Newline() (tea.Model, tea.Cmd)
  30. Previous() (tea.Model, tea.Cmd)
  31. Next() (tea.Model, tea.Cmd)
  32. }
  33. type editorComponent struct {
  34. app *app.App
  35. width, height int
  36. textarea textarea.Model
  37. attachments []app.Attachment
  38. history []string
  39. historyIndex int
  40. currentMessage string
  41. spinner spinner.Model
  42. }
  43. func (m *editorComponent) Init() tea.Cmd {
  44. return tea.Batch(textarea.Blink, m.spinner.Tick, tea.EnableReportFocus)
  45. }
  46. func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
  47. var cmds []tea.Cmd
  48. var cmd tea.Cmd
  49. switch msg := msg.(type) {
  50. case spinner.TickMsg:
  51. m.spinner, cmd = m.spinner.Update(msg)
  52. return m, cmd
  53. case tea.KeyPressMsg:
  54. // Maximize editor responsiveness for printable characters
  55. if msg.Text != "" {
  56. m.textarea, cmd = m.textarea.Update(msg)
  57. cmds = append(cmds, cmd)
  58. return m, tea.Batch(cmds...)
  59. }
  60. case dialog.ThemeSelectedMsg:
  61. m.textarea = createTextArea(&m.textarea)
  62. m.spinner = createSpinner()
  63. return m, tea.Batch(m.spinner.Tick, textarea.Blink)
  64. case dialog.CompletionSelectedMsg:
  65. if msg.IsCommand {
  66. commandName := strings.TrimPrefix(msg.CompletionValue, "/")
  67. updated, cmd := m.Clear()
  68. m = updated.(*editorComponent)
  69. cmds = append(cmds, cmd)
  70. cmds = append(cmds, util.CmdHandler(commands.ExecuteCommandMsg(m.app.Commands[commands.CommandName(commandName)])))
  71. return m, tea.Batch(cmds...)
  72. } else {
  73. existingValue := m.textarea.Value()
  74. modifiedValue := strings.Replace(existingValue, msg.SearchString, msg.CompletionValue, 1)
  75. m.textarea.SetValue(modifiedValue + " ")
  76. return m, nil
  77. }
  78. }
  79. m.spinner, cmd = m.spinner.Update(msg)
  80. cmds = append(cmds, cmd)
  81. m.textarea, cmd = m.textarea.Update(msg)
  82. cmds = append(cmds, cmd)
  83. return m, tea.Batch(cmds...)
  84. }
  85. func (m *editorComponent) Content() string {
  86. t := theme.CurrentTheme()
  87. base := styles.BaseStyle().Background(t.Background()).Render
  88. muted := styles.Muted().Background(t.Background()).Render
  89. promptStyle := lipgloss.NewStyle().
  90. Padding(0, 0, 0, 1).
  91. Bold(true).
  92. Foreground(t.Primary())
  93. prompt := promptStyle.Render(">")
  94. textarea := lipgloss.JoinHorizontal(
  95. lipgloss.Top,
  96. prompt,
  97. m.textarea.View(),
  98. )
  99. textarea = styles.BaseStyle().
  100. Width(m.width).
  101. PaddingTop(1).
  102. PaddingBottom(1).
  103. Background(t.BackgroundElement()).
  104. Render(textarea)
  105. hint := base("enter") + muted(" send ")
  106. if m.app.IsBusy() {
  107. hint = muted("working") + m.spinner.View() + muted(" ") + base("esc") + muted(" interrupt")
  108. }
  109. model := ""
  110. if m.app.Model != nil {
  111. model = muted(m.app.Provider.Name) + base(" "+m.app.Model.Name)
  112. }
  113. space := m.width - 2 - lipgloss.Width(model) - lipgloss.Width(hint)
  114. spacer := lipgloss.NewStyle().Background(t.Background()).Width(space).Render("")
  115. info := hint + spacer + model
  116. info = styles.Padded().Background(t.Background()).Render(info)
  117. content := strings.Join([]string{"", textarea, info}, "\n")
  118. return content
  119. }
  120. func (m *editorComponent) View() string {
  121. if m.Lines() > 1 {
  122. return ""
  123. }
  124. return m.Content()
  125. }
  126. func (m *editorComponent) GetSize() (width, height int) {
  127. return m.width, m.height
  128. }
  129. func (m *editorComponent) SetSize(width, height int) tea.Cmd {
  130. m.width = width
  131. m.height = height
  132. m.textarea.SetWidth(width - 5) // account for the prompt and padding right
  133. // m.textarea.SetHeight(height - 4)
  134. return nil
  135. }
  136. func (m *editorComponent) Lines() int {
  137. return m.textarea.LineCount()
  138. }
  139. func (m *editorComponent) Value() string {
  140. return m.textarea.Value()
  141. }
  142. func (m *editorComponent) Submit() (tea.Model, tea.Cmd) {
  143. value := strings.TrimSpace(m.Value())
  144. if value == "" {
  145. return m, nil
  146. }
  147. if len(value) > 0 && value[len(value)-1] == '\\' {
  148. // If the last character is a backslash, remove it and add a newline
  149. m.textarea.SetValue(value[:len(value)-1] + "\n")
  150. return m, nil
  151. }
  152. var cmds []tea.Cmd
  153. updated, cmd := m.Clear()
  154. m = updated.(*editorComponent)
  155. cmds = append(cmds, cmd)
  156. attachments := m.attachments
  157. // Save to history if not empty and not a duplicate of the last entry
  158. if value != "" {
  159. if len(m.history) == 0 || m.history[len(m.history)-1] != value {
  160. m.history = append(m.history, value)
  161. }
  162. m.historyIndex = len(m.history)
  163. m.currentMessage = ""
  164. }
  165. m.attachments = nil
  166. cmds = append(cmds, util.CmdHandler(app.SendMsg{Text: value, Attachments: attachments}))
  167. return m, tea.Batch(cmds...)
  168. }
  169. func (m *editorComponent) Clear() (tea.Model, tea.Cmd) {
  170. m.textarea.Reset()
  171. return m, nil
  172. }
  173. func (m *editorComponent) Paste() (tea.Model, tea.Cmd) {
  174. imageBytes, text, err := image.GetImageFromClipboard()
  175. if err != nil {
  176. slog.Error(err.Error())
  177. return m, nil
  178. }
  179. if len(imageBytes) != 0 {
  180. attachmentName := fmt.Sprintf("clipboard-image-%d", len(m.attachments))
  181. attachment := app.Attachment{FilePath: attachmentName, FileName: attachmentName, Content: imageBytes, MimeType: "image/png"}
  182. m.attachments = append(m.attachments, attachment)
  183. } else {
  184. m.textarea.SetValue(m.textarea.Value() + text)
  185. }
  186. return m, nil
  187. }
  188. func (m *editorComponent) Newline() (tea.Model, tea.Cmd) {
  189. m.textarea.Newline()
  190. return m, nil
  191. }
  192. func (m *editorComponent) Previous() (tea.Model, tea.Cmd) {
  193. currentLine := m.textarea.Line()
  194. // Only navigate history if we're at the first line
  195. if currentLine == 0 && len(m.history) > 0 {
  196. // Save current message if we're just starting to navigate
  197. if m.historyIndex == len(m.history) {
  198. m.currentMessage = m.textarea.Value()
  199. }
  200. // Go to previous message in history
  201. if m.historyIndex > 0 {
  202. m.historyIndex--
  203. m.textarea.SetValue(m.history[m.historyIndex])
  204. }
  205. return m, nil
  206. }
  207. return m, nil
  208. }
  209. func (m *editorComponent) Next() (tea.Model, tea.Cmd) {
  210. currentLine := m.textarea.Line()
  211. value := m.textarea.Value()
  212. lines := strings.Split(value, "\n")
  213. totalLines := len(lines)
  214. // Only navigate history if we're at the last line
  215. if currentLine == totalLines-1 {
  216. if m.historyIndex < len(m.history)-1 {
  217. // Go to next message in history
  218. m.historyIndex++
  219. m.textarea.SetValue(m.history[m.historyIndex])
  220. } else if m.historyIndex == len(m.history)-1 {
  221. // Return to the current message being composed
  222. m.historyIndex = len(m.history)
  223. m.textarea.SetValue(m.currentMessage)
  224. }
  225. return m, nil
  226. }
  227. return m, nil
  228. }
  229. func createTextArea(existing *textarea.Model) textarea.Model {
  230. t := theme.CurrentTheme()
  231. bgColor := t.BackgroundElement()
  232. textColor := t.Text()
  233. textMutedColor := t.TextMuted()
  234. ta := textarea.New()
  235. ta.Styles.Blurred.Base = lipgloss.NewStyle().Background(bgColor).Foreground(textColor)
  236. ta.Styles.Blurred.CursorLine = lipgloss.NewStyle().Background(bgColor)
  237. ta.Styles.Blurred.Placeholder = lipgloss.NewStyle().Background(bgColor).Foreground(textMutedColor)
  238. ta.Styles.Blurred.Text = lipgloss.NewStyle().Background(bgColor).Foreground(textColor)
  239. ta.Styles.Focused.Base = lipgloss.NewStyle().Background(bgColor).Foreground(textColor)
  240. ta.Styles.Focused.CursorLine = lipgloss.NewStyle().Background(bgColor)
  241. ta.Styles.Focused.Placeholder = lipgloss.NewStyle().Background(bgColor).Foreground(textMutedColor)
  242. ta.Styles.Focused.Text = lipgloss.NewStyle().Background(bgColor).Foreground(textColor)
  243. ta.Styles.Cursor.Color = t.Primary()
  244. ta.Prompt = " "
  245. ta.ShowLineNumbers = false
  246. ta.CharLimit = -1
  247. if existing != nil {
  248. ta.SetValue(existing.Value())
  249. ta.SetWidth(existing.Width())
  250. ta.SetHeight(existing.Height())
  251. }
  252. ta.Focus()
  253. return ta
  254. }
  255. func createSpinner() spinner.Model {
  256. return spinner.New(
  257. spinner.WithSpinner(spinner.Ellipsis),
  258. spinner.WithStyle(
  259. styles.
  260. Muted().
  261. Background(theme.CurrentTheme().Background()).
  262. Width(3)),
  263. )
  264. }
  265. func NewEditorComponent(app *app.App) EditorComponent {
  266. s := createSpinner()
  267. ta := createTextArea(nil)
  268. return &editorComponent{
  269. app: app,
  270. textarea: ta,
  271. history: []string{},
  272. historyIndex: 0,
  273. currentMessage: "",
  274. spinner: s,
  275. }
  276. }