editor.go 8.4 KB

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