editor.go 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322
  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. Border(lipgloss.ThickBorder(), false, true).
  102. BorderForeground(t.BackgroundElement()).
  103. BorderBackground(t.Background()).
  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. value := m.textarea.Value()
  190. m.textarea.SetValue(value + "\n")
  191. return m, nil
  192. }
  193. func (m *editorComponent) Previous() (tea.Model, tea.Cmd) {
  194. currentLine := m.textarea.Line()
  195. // Only navigate history if we're at the first line
  196. if currentLine == 0 && len(m.history) > 0 {
  197. // Save current message if we're just starting to navigate
  198. if m.historyIndex == len(m.history) {
  199. m.currentMessage = m.textarea.Value()
  200. }
  201. // Go to previous message in history
  202. if m.historyIndex > 0 {
  203. m.historyIndex--
  204. m.textarea.SetValue(m.history[m.historyIndex])
  205. }
  206. return m, nil
  207. }
  208. return m, nil
  209. }
  210. func (m *editorComponent) Next() (tea.Model, tea.Cmd) {
  211. currentLine := m.textarea.Line()
  212. value := m.textarea.Value()
  213. lines := strings.Split(value, "\n")
  214. totalLines := len(lines)
  215. // Only navigate history if we're at the last line
  216. if currentLine == totalLines-1 {
  217. if m.historyIndex < len(m.history)-1 {
  218. // Go to next message in history
  219. m.historyIndex++
  220. m.textarea.SetValue(m.history[m.historyIndex])
  221. } else if m.historyIndex == len(m.history)-1 {
  222. // Return to the current message being composed
  223. m.historyIndex = len(m.history)
  224. m.textarea.SetValue(m.currentMessage)
  225. }
  226. return m, nil
  227. }
  228. return m, nil
  229. }
  230. func createTextArea(existing *textarea.Model) textarea.Model {
  231. t := theme.CurrentTheme()
  232. bgColor := t.BackgroundElement()
  233. textColor := t.Text()
  234. textMutedColor := t.TextMuted()
  235. ta := textarea.New()
  236. ta.Styles.Blurred.Base = lipgloss.NewStyle().Background(bgColor).Foreground(textColor)
  237. ta.Styles.Blurred.CursorLine = lipgloss.NewStyle().Background(bgColor)
  238. ta.Styles.Blurred.Placeholder = lipgloss.NewStyle().Background(bgColor).Foreground(textMutedColor)
  239. ta.Styles.Blurred.Text = lipgloss.NewStyle().Background(bgColor).Foreground(textColor)
  240. ta.Styles.Focused.Base = lipgloss.NewStyle().Background(bgColor).Foreground(textColor)
  241. ta.Styles.Focused.CursorLine = lipgloss.NewStyle().Background(bgColor)
  242. ta.Styles.Focused.Placeholder = lipgloss.NewStyle().Background(bgColor).Foreground(textMutedColor)
  243. ta.Styles.Focused.Text = lipgloss.NewStyle().Background(bgColor).Foreground(textColor)
  244. ta.Styles.Cursor.Color = t.Primary()
  245. ta.Prompt = " "
  246. ta.ShowLineNumbers = false
  247. ta.CharLimit = -1
  248. if existing != nil {
  249. ta.SetValue(existing.Value())
  250. ta.SetWidth(existing.Width())
  251. ta.SetHeight(existing.Height())
  252. }
  253. ta.Focus()
  254. return ta
  255. }
  256. func createSpinner() spinner.Model {
  257. return spinner.New(
  258. spinner.WithSpinner(spinner.Ellipsis),
  259. spinner.WithStyle(
  260. styles.
  261. Muted().
  262. Background(theme.CurrentTheme().Background()).
  263. Width(3)),
  264. )
  265. }
  266. func NewEditorComponent(app *app.App) EditorComponent {
  267. s := createSpinner()
  268. ta := createTextArea(nil)
  269. return &editorComponent{
  270. app: app,
  271. textarea: ta,
  272. history: []string{},
  273. historyIndex: 0,
  274. currentMessage: "",
  275. spinner: s,
  276. }
  277. }