editor.go 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378
  1. package chat
  2. import (
  3. "fmt"
  4. "log/slog"
  5. "path/filepath"
  6. "strings"
  7. "github.com/charmbracelet/bubbles/v2/spinner"
  8. tea "github.com/charmbracelet/bubbletea/v2"
  9. "github.com/charmbracelet/lipgloss/v2"
  10. "github.com/google/uuid"
  11. "github.com/sst/opencode-sdk-go"
  12. "github.com/sst/opencode/internal/app"
  13. "github.com/sst/opencode/internal/commands"
  14. "github.com/sst/opencode/internal/components/dialog"
  15. "github.com/sst/opencode/internal/components/textarea"
  16. "github.com/sst/opencode/internal/image"
  17. "github.com/sst/opencode/internal/styles"
  18. "github.com/sst/opencode/internal/theme"
  19. "github.com/sst/opencode/internal/util"
  20. )
  21. type EditorComponent interface {
  22. tea.Model
  23. View(width int) string
  24. Content(width int) string
  25. Lines() int
  26. Value() string
  27. Focused() bool
  28. Focus() (tea.Model, tea.Cmd)
  29. Blur()
  30. Submit() (tea.Model, tea.Cmd)
  31. Clear() (tea.Model, tea.Cmd)
  32. Paste() (tea.Model, tea.Cmd)
  33. Newline() (tea.Model, tea.Cmd)
  34. SetInterruptKeyInDebounce(inDebounce bool)
  35. }
  36. type editorComponent struct {
  37. app *app.App
  38. textarea textarea.Model
  39. spinner spinner.Model
  40. interruptKeyInDebounce bool
  41. }
  42. func (m *editorComponent) Init() tea.Cmd {
  43. return tea.Batch(m.textarea.Focus(), m.spinner.Tick, tea.EnableReportFocus)
  44. }
  45. func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
  46. var cmds []tea.Cmd
  47. var cmd tea.Cmd
  48. switch msg := msg.(type) {
  49. case spinner.TickMsg:
  50. m.spinner, cmd = m.spinner.Update(msg)
  51. return m, cmd
  52. case tea.KeyPressMsg:
  53. // Maximize editor responsiveness for printable characters
  54. if msg.Text != "" {
  55. m.textarea, cmd = m.textarea.Update(msg)
  56. cmds = append(cmds, cmd)
  57. return m, tea.Batch(cmds...)
  58. }
  59. case dialog.ThemeSelectedMsg:
  60. m.textarea = createTextArea(&m.textarea)
  61. m.spinner = createSpinner()
  62. return m, tea.Batch(m.spinner.Tick, m.textarea.Focus())
  63. case dialog.CompletionSelectedMsg:
  64. switch msg.ProviderID {
  65. case "commands":
  66. commandName := strings.TrimPrefix(msg.CompletionValue, "/")
  67. updated, cmd := m.Clear()
  68. m = updated.(*editorComponent)
  69. cmds = append(cmds, cmd)
  70. cmds = append(cmds, util.CmdHandler(commands.ExecuteCommandMsg(m.app.Commands[commands.CommandName(commandName)])))
  71. return m, tea.Batch(cmds...)
  72. case "files":
  73. atIndex := m.textarea.LastRuneIndex('@')
  74. if atIndex == -1 {
  75. // Should not happen, but as a fallback, just insert.
  76. m.textarea.InsertString(msg.CompletionValue + " ")
  77. return m, nil
  78. }
  79. // The range to replace is from the '@' up to the current cursor position.
  80. // Replace the search term (e.g., "@search") with an empty string first.
  81. cursorCol := m.textarea.CursorColumn()
  82. m.textarea.ReplaceRange(atIndex, cursorCol, "")
  83. // Now, insert the attachment at the position where the '@' was.
  84. // The cursor is now at `atIndex` after the replacement.
  85. filePath := msg.CompletionValue
  86. extension := filepath.Ext(filePath)
  87. mediaType := ""
  88. switch extension {
  89. case ".jpg":
  90. mediaType = "image/jpeg"
  91. case ".png", ".jpeg", ".gif", ".webp":
  92. mediaType = "image/" + extension[1:]
  93. case ".pdf":
  94. mediaType = "application/pdf"
  95. default:
  96. mediaType = "text/plain"
  97. }
  98. attachment := &textarea.Attachment{
  99. ID: uuid.NewString(),
  100. Display: "@" + filePath,
  101. URL: fmt.Sprintf("file://./%s", filePath),
  102. Filename: filePath,
  103. MediaType: mediaType,
  104. }
  105. m.textarea.InsertAttachment(attachment)
  106. m.textarea.InsertString(" ")
  107. return m, nil
  108. default:
  109. existingValue := m.textarea.Value()
  110. lastSpaceIndex := strings.LastIndex(existingValue, " ")
  111. if lastSpaceIndex == -1 {
  112. m.textarea.SetValue(msg.CompletionValue + " ")
  113. } else {
  114. modifiedValue := existingValue[:lastSpaceIndex+1] + msg.CompletionValue
  115. m.textarea.SetValue(modifiedValue + " ")
  116. }
  117. return m, nil
  118. }
  119. }
  120. m.spinner, cmd = m.spinner.Update(msg)
  121. cmds = append(cmds, cmd)
  122. m.textarea, cmd = m.textarea.Update(msg)
  123. cmds = append(cmds, cmd)
  124. return m, tea.Batch(cmds...)
  125. }
  126. func (m *editorComponent) Content(width int) string {
  127. t := theme.CurrentTheme()
  128. base := styles.NewStyle().Foreground(t.Text()).Background(t.Background()).Render
  129. muted := styles.NewStyle().Foreground(t.TextMuted()).Background(t.Background()).Render
  130. promptStyle := styles.NewStyle().Foreground(t.Primary()).
  131. Padding(0, 0, 0, 1).
  132. Bold(true)
  133. prompt := promptStyle.Render(">")
  134. m.textarea.SetWidth(width - 6)
  135. textarea := lipgloss.JoinHorizontal(
  136. lipgloss.Top,
  137. prompt,
  138. m.textarea.View(),
  139. )
  140. textarea = styles.NewStyle().
  141. Background(t.BackgroundElement()).
  142. Width(width).
  143. PaddingTop(1).
  144. PaddingBottom(1).
  145. BorderStyle(lipgloss.ThickBorder()).
  146. BorderForeground(t.Border()).
  147. BorderBackground(t.Background()).
  148. BorderLeft(true).
  149. BorderRight(true).
  150. Render(textarea)
  151. hint := base(m.getSubmitKeyText()) + muted(" send ")
  152. if m.app.IsBusy() {
  153. keyText := m.getInterruptKeyText()
  154. if m.interruptKeyInDebounce {
  155. hint = muted(
  156. "working",
  157. ) + m.spinner.View() + muted(
  158. " ",
  159. ) + base(
  160. keyText+" again",
  161. ) + muted(
  162. " interrupt",
  163. )
  164. } else {
  165. hint = muted("working") + m.spinner.View() + muted(" ") + base(keyText) + muted(" interrupt")
  166. }
  167. }
  168. model := ""
  169. if m.app.Model != nil {
  170. model = muted(m.app.Provider.Name) + base(" "+m.app.Model.Name)
  171. }
  172. space := width - 2 - lipgloss.Width(model) - lipgloss.Width(hint)
  173. spacer := styles.NewStyle().Background(t.Background()).Width(space).Render("")
  174. info := hint + spacer + model
  175. info = styles.NewStyle().Background(t.Background()).Padding(0, 1).Render(info)
  176. content := strings.Join([]string{"", textarea, info}, "\n")
  177. return content
  178. }
  179. func (m *editorComponent) View(width int) string {
  180. if m.Lines() > 1 {
  181. return lipgloss.Place(
  182. width,
  183. 5,
  184. lipgloss.Center,
  185. lipgloss.Center,
  186. "",
  187. styles.WhitespaceStyle(theme.CurrentTheme().Background()),
  188. )
  189. }
  190. return m.Content(width)
  191. }
  192. func (m *editorComponent) Focused() bool {
  193. return m.textarea.Focused()
  194. }
  195. func (m *editorComponent) Focus() (tea.Model, tea.Cmd) {
  196. return m, m.textarea.Focus()
  197. }
  198. func (m *editorComponent) Blur() {
  199. m.textarea.Blur()
  200. }
  201. func (m *editorComponent) Lines() int {
  202. return m.textarea.LineCount()
  203. }
  204. func (m *editorComponent) Value() string {
  205. return m.textarea.Value()
  206. }
  207. func (m *editorComponent) Submit() (tea.Model, tea.Cmd) {
  208. value := strings.TrimSpace(m.Value())
  209. if value == "" {
  210. return m, nil
  211. }
  212. if len(value) > 0 && value[len(value)-1] == '\\' {
  213. // If the last character is a backslash, remove it and add a newline
  214. m.textarea.ReplaceRange(len(value)-1, len(value), "")
  215. m.textarea.InsertString("\n")
  216. return m, nil
  217. }
  218. var cmds []tea.Cmd
  219. attachments := m.textarea.GetAttachments()
  220. fileParts := make([]opencode.FilePartParam, 0)
  221. for _, attachment := range attachments {
  222. fileParts = append(fileParts, opencode.FilePartParam{
  223. Type: opencode.F(opencode.FilePartTypeFile),
  224. Mime: opencode.F(attachment.MediaType),
  225. URL: opencode.F(attachment.URL),
  226. Filename: opencode.F(attachment.Filename),
  227. })
  228. }
  229. updated, cmd := m.Clear()
  230. m = updated.(*editorComponent)
  231. cmds = append(cmds, cmd)
  232. cmds = append(cmds, util.CmdHandler(app.SendMsg{Text: value, Attachments: fileParts}))
  233. return m, tea.Batch(cmds...)
  234. }
  235. func (m *editorComponent) Clear() (tea.Model, tea.Cmd) {
  236. m.textarea.Reset()
  237. return m, nil
  238. }
  239. func (m *editorComponent) Paste() (tea.Model, tea.Cmd) {
  240. _, text, err := image.GetImageFromClipboard()
  241. if err != nil {
  242. slog.Error(err.Error())
  243. return m, nil
  244. }
  245. // if len(imageBytes) != 0 {
  246. // attachmentName := fmt.Sprintf("clipboard-image-%d", len(m.attachments))
  247. // attachment := app.Attachment{
  248. // FilePath: attachmentName,
  249. // FileName: attachmentName,
  250. // Content: imageBytes,
  251. // MimeType: "image/png",
  252. // }
  253. // m.attachments = append(m.attachments, attachment)
  254. // } else {
  255. m.textarea.InsertString(text)
  256. // }
  257. return m, nil
  258. }
  259. func (m *editorComponent) Newline() (tea.Model, tea.Cmd) {
  260. m.textarea.Newline()
  261. return m, nil
  262. }
  263. func (m *editorComponent) SetInterruptKeyInDebounce(inDebounce bool) {
  264. m.interruptKeyInDebounce = inDebounce
  265. }
  266. func (m *editorComponent) getInterruptKeyText() string {
  267. return m.app.Commands[commands.SessionInterruptCommand].Keys()[0]
  268. }
  269. func (m *editorComponent) getSubmitKeyText() string {
  270. return m.app.Commands[commands.InputSubmitCommand].Keys()[0]
  271. }
  272. func createTextArea(existing *textarea.Model) textarea.Model {
  273. t := theme.CurrentTheme()
  274. bgColor := t.BackgroundElement()
  275. textColor := t.Text()
  276. textMutedColor := t.TextMuted()
  277. ta := textarea.New()
  278. ta.Styles.Blurred.Base = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
  279. ta.Styles.Blurred.CursorLine = styles.NewStyle().Background(bgColor).Lipgloss()
  280. ta.Styles.Blurred.Placeholder = styles.NewStyle().
  281. Foreground(textMutedColor).
  282. Background(bgColor).
  283. Lipgloss()
  284. ta.Styles.Blurred.Text = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
  285. ta.Styles.Focused.Base = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
  286. ta.Styles.Focused.CursorLine = styles.NewStyle().Background(bgColor).Lipgloss()
  287. ta.Styles.Focused.Placeholder = styles.NewStyle().
  288. Foreground(textMutedColor).
  289. Background(bgColor).
  290. Lipgloss()
  291. ta.Styles.Focused.Text = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
  292. ta.Styles.Attachment = styles.NewStyle().
  293. Foreground(t.Secondary()).
  294. Background(bgColor).
  295. Lipgloss()
  296. ta.Styles.SelectedAttachment = styles.NewStyle().
  297. Foreground(t.Text()).
  298. Background(t.Secondary()).
  299. Lipgloss()
  300. ta.Styles.Cursor.Color = t.Primary()
  301. ta.Prompt = " "
  302. ta.ShowLineNumbers = false
  303. ta.CharLimit = -1
  304. if existing != nil {
  305. ta.SetValue(existing.Value())
  306. // ta.SetWidth(existing.Width())
  307. ta.SetHeight(existing.Height())
  308. }
  309. return ta
  310. }
  311. func createSpinner() spinner.Model {
  312. t := theme.CurrentTheme()
  313. return spinner.New(
  314. spinner.WithSpinner(spinner.Ellipsis),
  315. spinner.WithStyle(
  316. styles.NewStyle().
  317. Background(t.Background()).
  318. Foreground(t.TextMuted()).
  319. Width(3).
  320. Lipgloss(),
  321. ),
  322. )
  323. }
  324. func NewEditorComponent(app *app.App) EditorComponent {
  325. s := createSpinner()
  326. ta := createTextArea(nil)
  327. return &editorComponent{
  328. app: app,
  329. textarea: ta,
  330. spinner: s,
  331. interruptKeyInDebounce: false,
  332. }
  333. }