editor.go 8.5 KB

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