editor.go 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302
  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/styles"
  15. "github.com/sst/opencode/internal/theme"
  16. "github.com/sst/opencode/internal/util"
  17. )
  18. type EditorComponent interface {
  19. tea.Model
  20. View(width int) string
  21. Content(width int) string
  22. Lines() int
  23. Value() string
  24. Focused() bool
  25. Focus() (tea.Model, tea.Cmd)
  26. Blur()
  27. Submit() (tea.Model, tea.Cmd)
  28. Clear() (tea.Model, tea.Cmd)
  29. Paste() (tea.Model, tea.Cmd)
  30. Newline() (tea.Model, tea.Cmd)
  31. SetInterruptKeyInDebounce(inDebounce bool)
  32. }
  33. type editorComponent struct {
  34. app *app.App
  35. textarea textarea.Model
  36. attachments []app.Attachment
  37. spinner spinner.Model
  38. interruptKeyInDebounce bool
  39. }
  40. func (m *editorComponent) Init() tea.Cmd {
  41. return tea.Batch(m.textarea.Focus(), m.spinner.Tick, tea.EnableReportFocus)
  42. }
  43. func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
  44. var cmds []tea.Cmd
  45. var cmd tea.Cmd
  46. switch msg := msg.(type) {
  47. case spinner.TickMsg:
  48. m.spinner, cmd = m.spinner.Update(msg)
  49. return m, cmd
  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, m.textarea.Focus())
  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. // Replace the current token (after last space)
  72. lastSpaceIndex := strings.LastIndex(existingValue, " ")
  73. if lastSpaceIndex == -1 {
  74. m.textarea.SetValue(msg.CompletionValue + " ")
  75. } else {
  76. modifiedValue := existingValue[:lastSpaceIndex+1] + msg.CompletionValue
  77. m.textarea.SetValue(modifiedValue + " ")
  78. }
  79. return m, nil
  80. }
  81. }
  82. m.spinner, cmd = m.spinner.Update(msg)
  83. cmds = append(cmds, cmd)
  84. m.textarea, cmd = m.textarea.Update(msg)
  85. cmds = append(cmds, cmd)
  86. return m, tea.Batch(cmds...)
  87. }
  88. func (m *editorComponent) Content(width int) string {
  89. t := theme.CurrentTheme()
  90. base := styles.NewStyle().Foreground(t.Text()).Background(t.Background()).Render
  91. muted := styles.NewStyle().Foreground(t.TextMuted()).Background(t.Background()).Render
  92. promptStyle := styles.NewStyle().Foreground(t.Primary()).
  93. Padding(0, 0, 0, 1).
  94. Bold(true)
  95. prompt := promptStyle.Render(">")
  96. m.textarea.SetWidth(width - 6)
  97. textarea := lipgloss.JoinHorizontal(
  98. lipgloss.Top,
  99. prompt,
  100. m.textarea.View(),
  101. )
  102. textarea = styles.NewStyle().
  103. Background(t.BackgroundElement()).
  104. Width(width).
  105. PaddingTop(1).
  106. PaddingBottom(1).
  107. BorderStyle(lipgloss.ThickBorder()).
  108. BorderForeground(t.Border()).
  109. BorderBackground(t.Background()).
  110. BorderLeft(true).
  111. BorderRight(true).
  112. Render(textarea)
  113. hint := base(m.getSubmitKeyText()) + muted(" send ")
  114. if m.app.IsBusy() {
  115. keyText := m.getInterruptKeyText()
  116. if m.interruptKeyInDebounce {
  117. hint = muted("working") + m.spinner.View() + muted(" ") + base(keyText+" again") + muted(" interrupt")
  118. } else {
  119. hint = muted("working") + m.spinner.View() + muted(" ") + base(keyText) + muted(" interrupt")
  120. }
  121. }
  122. model := ""
  123. if m.app.Model != nil {
  124. model = muted(m.app.Provider.Name) + base(" "+m.app.Model.Name)
  125. }
  126. space := width - 2 - lipgloss.Width(model) - lipgloss.Width(hint)
  127. spacer := styles.NewStyle().Background(t.Background()).Width(space).Render("")
  128. info := hint + spacer + model
  129. info = styles.NewStyle().Background(t.Background()).Padding(0, 1).Render(info)
  130. content := strings.Join([]string{"", textarea, info}, "\n")
  131. return content
  132. }
  133. func (m *editorComponent) View(width int) string {
  134. if m.Lines() > 1 {
  135. return lipgloss.Place(
  136. width,
  137. 5,
  138. lipgloss.Center,
  139. lipgloss.Center,
  140. "",
  141. styles.WhitespaceStyle(theme.CurrentTheme().Background()),
  142. )
  143. }
  144. return m.Content(width)
  145. }
  146. func (m *editorComponent) Focused() bool {
  147. return m.textarea.Focused()
  148. }
  149. func (m *editorComponent) Focus() (tea.Model, tea.Cmd) {
  150. return m, m.textarea.Focus()
  151. }
  152. func (m *editorComponent) Blur() {
  153. m.textarea.Blur()
  154. }
  155. func (m *editorComponent) Lines() int {
  156. return m.textarea.LineCount()
  157. }
  158. func (m *editorComponent) Value() string {
  159. return m.textarea.Value()
  160. }
  161. func (m *editorComponent) Submit() (tea.Model, tea.Cmd) {
  162. value := strings.TrimSpace(m.Value())
  163. if value == "" {
  164. return m, nil
  165. }
  166. if len(value) > 0 && value[len(value)-1] == '\\' {
  167. // If the last character is a backslash, remove it and add a newline
  168. m.textarea.SetValue(value[:len(value)-1] + "\n")
  169. return m, nil
  170. }
  171. var cmds []tea.Cmd
  172. updated, cmd := m.Clear()
  173. m = updated.(*editorComponent)
  174. cmds = append(cmds, cmd)
  175. attachments := m.attachments
  176. m.attachments = nil
  177. cmds = append(cmds, util.CmdHandler(app.SendMsg{Text: value, Attachments: attachments}))
  178. return m, tea.Batch(cmds...)
  179. }
  180. func (m *editorComponent) Clear() (tea.Model, tea.Cmd) {
  181. m.textarea.Reset()
  182. return m, nil
  183. }
  184. func (m *editorComponent) Paste() (tea.Model, tea.Cmd) {
  185. imageBytes, text, err := image.GetImageFromClipboard()
  186. if err != nil {
  187. slog.Error(err.Error())
  188. return m, nil
  189. }
  190. if len(imageBytes) != 0 {
  191. attachmentName := fmt.Sprintf("clipboard-image-%d", len(m.attachments))
  192. attachment := app.Attachment{FilePath: attachmentName, FileName: attachmentName, Content: imageBytes, MimeType: "image/png"}
  193. m.attachments = append(m.attachments, attachment)
  194. } else {
  195. m.textarea.SetValue(m.textarea.Value() + text)
  196. }
  197. return m, nil
  198. }
  199. func (m *editorComponent) Newline() (tea.Model, tea.Cmd) {
  200. m.textarea.Newline()
  201. return m, nil
  202. }
  203. func (m *editorComponent) SetInterruptKeyInDebounce(inDebounce bool) {
  204. m.interruptKeyInDebounce = inDebounce
  205. }
  206. func (m *editorComponent) getInterruptKeyText() string {
  207. return m.app.Commands[commands.SessionInterruptCommand].Keys()[0]
  208. }
  209. func (m *editorComponent) getSubmitKeyText() string {
  210. return m.app.Commands[commands.InputSubmitCommand].Keys()[0]
  211. }
  212. func createTextArea(existing *textarea.Model) textarea.Model {
  213. t := theme.CurrentTheme()
  214. bgColor := t.BackgroundElement()
  215. textColor := t.Text()
  216. textMutedColor := t.TextMuted()
  217. ta := textarea.New()
  218. ta.Styles.Blurred.Base = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
  219. ta.Styles.Blurred.CursorLine = styles.NewStyle().Background(bgColor).Lipgloss()
  220. ta.Styles.Blurred.Placeholder = styles.NewStyle().Foreground(textMutedColor).Background(bgColor).Lipgloss()
  221. ta.Styles.Blurred.Text = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
  222. ta.Styles.Focused.Base = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
  223. ta.Styles.Focused.CursorLine = styles.NewStyle().Background(bgColor).Lipgloss()
  224. ta.Styles.Focused.Placeholder = styles.NewStyle().Foreground(textMutedColor).Background(bgColor).Lipgloss()
  225. ta.Styles.Focused.Text = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
  226. ta.Styles.Cursor.Color = t.Primary()
  227. ta.Prompt = " "
  228. ta.ShowLineNumbers = false
  229. ta.CharLimit = -1
  230. if existing != nil {
  231. ta.SetValue(existing.Value())
  232. // ta.SetWidth(existing.Width())
  233. ta.SetHeight(existing.Height())
  234. }
  235. return ta
  236. }
  237. func createSpinner() spinner.Model {
  238. t := theme.CurrentTheme()
  239. return spinner.New(
  240. spinner.WithSpinner(spinner.Ellipsis),
  241. spinner.WithStyle(
  242. styles.NewStyle().
  243. Background(t.Background()).
  244. Foreground(t.TextMuted()).
  245. Width(3).
  246. Lipgloss(),
  247. ),
  248. )
  249. }
  250. func NewEditorComponent(app *app.App) EditorComponent {
  251. s := createSpinner()
  252. ta := createTextArea(nil)
  253. return &editorComponent{
  254. app: app,
  255. textarea: ta,
  256. spinner: s,
  257. interruptKeyInDebounce: false,
  258. }
  259. }