editor.go 12 KB

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