editor.go 8.4 KB

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