editor.go 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433
  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. attachment := &textarea.Attachment{
  98. ID: uuid.NewString(),
  99. Display: fmt.Sprintf("<%s>", filePath),
  100. URL: url,
  101. Filename: filePath,
  102. MediaType: mediaType,
  103. }
  104. m.textarea.InsertAttachment(attachment)
  105. m.textarea.InsertString(" ")
  106. case tea.ClipboardMsg:
  107. text := string(msg)
  108. m.textarea.InsertRunesFromUserInput([]rune(text))
  109. case dialog.ThemeSelectedMsg:
  110. m.textarea = m.resetTextareaStyles()
  111. m.spinner = createSpinner()
  112. return m, tea.Batch(m.spinner.Tick, m.textarea.Focus())
  113. case dialog.CompletionSelectedMsg:
  114. switch msg.ProviderID {
  115. case "commands":
  116. commandName := strings.TrimPrefix(msg.CompletionValue, "/")
  117. updated, cmd := m.Clear()
  118. m = updated.(*editorComponent)
  119. cmds = append(cmds, cmd)
  120. cmds = append(cmds, util.CmdHandler(commands.ExecuteCommandMsg(m.app.Commands[commands.CommandName(commandName)])))
  121. return m, tea.Batch(cmds...)
  122. case "files":
  123. atIndex := m.textarea.LastRuneIndex('@')
  124. if atIndex == -1 {
  125. // Should not happen, but as a fallback, just insert.
  126. m.textarea.InsertString(msg.CompletionValue + " ")
  127. return m, nil
  128. }
  129. // The range to replace is from the '@' up to the current cursor position.
  130. // Replace the search term (e.g., "@search") with an empty string first.
  131. cursorCol := m.textarea.CursorColumn()
  132. m.textarea.ReplaceRange(atIndex, cursorCol, "")
  133. // Now, insert the attachment at the position where the '@' was.
  134. // The cursor is now at `atIndex` after the replacement.
  135. filePath := msg.CompletionValue
  136. extension := filepath.Ext(filePath)
  137. mediaType := ""
  138. switch extension {
  139. case ".jpg":
  140. mediaType = "image/jpeg"
  141. case ".png", ".jpeg", ".gif", ".webp":
  142. mediaType = "image/" + extension[1:]
  143. case ".pdf":
  144. mediaType = "application/pdf"
  145. default:
  146. mediaType = "text/plain"
  147. }
  148. attachment := &textarea.Attachment{
  149. ID: uuid.NewString(),
  150. Display: "@" + filePath,
  151. URL: fmt.Sprintf("file://./%s", filePath),
  152. Filename: filePath,
  153. MediaType: mediaType,
  154. }
  155. m.textarea.InsertAttachment(attachment)
  156. m.textarea.InsertString(" ")
  157. return m, nil
  158. default:
  159. existingValue := m.textarea.Value()
  160. lastSpaceIndex := strings.LastIndex(existingValue, " ")
  161. if lastSpaceIndex == -1 {
  162. m.textarea.SetValue(msg.CompletionValue + " ")
  163. } else {
  164. modifiedValue := existingValue[:lastSpaceIndex+1] + msg.CompletionValue
  165. m.textarea.SetValue(modifiedValue + " ")
  166. }
  167. return m, nil
  168. }
  169. }
  170. m.spinner, cmd = m.spinner.Update(msg)
  171. cmds = append(cmds, cmd)
  172. m.textarea, cmd = m.textarea.Update(msg)
  173. cmds = append(cmds, cmd)
  174. return m, tea.Batch(cmds...)
  175. }
  176. func (m *editorComponent) Content(width int) string {
  177. t := theme.CurrentTheme()
  178. base := styles.NewStyle().Foreground(t.Text()).Background(t.Background()).Render
  179. muted := styles.NewStyle().Foreground(t.TextMuted()).Background(t.Background()).Render
  180. promptStyle := styles.NewStyle().Foreground(t.Primary()).
  181. Padding(0, 0, 0, 1).
  182. Bold(true)
  183. prompt := promptStyle.Render(">")
  184. m.textarea.SetWidth(width - 6)
  185. textarea := lipgloss.JoinHorizontal(
  186. lipgloss.Top,
  187. prompt,
  188. m.textarea.View(),
  189. )
  190. textarea = styles.NewStyle().
  191. Background(t.BackgroundElement()).
  192. Width(width).
  193. PaddingTop(1).
  194. PaddingBottom(1).
  195. BorderStyle(lipgloss.ThickBorder()).
  196. BorderForeground(t.Border()).
  197. BorderBackground(t.Background()).
  198. BorderLeft(true).
  199. BorderRight(true).
  200. Render(textarea)
  201. hint := base(m.getSubmitKeyText()) + muted(" send ")
  202. if m.app.IsBusy() {
  203. keyText := m.getInterruptKeyText()
  204. if m.interruptKeyInDebounce {
  205. hint = muted(
  206. "working",
  207. ) + m.spinner.View() + muted(
  208. " ",
  209. ) + base(
  210. keyText+" again",
  211. ) + muted(
  212. " interrupt",
  213. )
  214. } else {
  215. hint = muted("working") + m.spinner.View() + muted(" ") + base(keyText) + muted(" interrupt")
  216. }
  217. }
  218. model := ""
  219. if m.app.Model != nil {
  220. model = muted(m.app.Provider.Name) + base(" "+m.app.Model.Name)
  221. }
  222. space := width - 2 - lipgloss.Width(model) - lipgloss.Width(hint)
  223. spacer := styles.NewStyle().Background(t.Background()).Width(space).Render("")
  224. info := hint + spacer + model
  225. info = styles.NewStyle().Background(t.Background()).Padding(0, 1).Render(info)
  226. content := strings.Join([]string{"", textarea, info}, "\n")
  227. return content
  228. }
  229. func (m *editorComponent) View(width int) string {
  230. if m.Lines() > 1 {
  231. return lipgloss.Place(
  232. width,
  233. 5,
  234. lipgloss.Center,
  235. lipgloss.Center,
  236. "",
  237. styles.WhitespaceStyle(theme.CurrentTheme().Background()),
  238. )
  239. }
  240. return m.Content(width)
  241. }
  242. func (m *editorComponent) Focused() bool {
  243. return m.textarea.Focused()
  244. }
  245. func (m *editorComponent) Focus() (tea.Model, tea.Cmd) {
  246. return m, m.textarea.Focus()
  247. }
  248. func (m *editorComponent) Blur() {
  249. m.textarea.Blur()
  250. }
  251. func (m *editorComponent) Lines() int {
  252. return m.textarea.LineCount()
  253. }
  254. func (m *editorComponent) Value() string {
  255. return m.textarea.Value()
  256. }
  257. func (m *editorComponent) Submit() (tea.Model, tea.Cmd) {
  258. value := strings.TrimSpace(m.Value())
  259. if value == "" {
  260. return m, nil
  261. }
  262. if len(value) > 0 && value[len(value)-1] == '\\' {
  263. // If the last character is a backslash, remove it and add a newline
  264. m.textarea.ReplaceRange(len(value)-1, len(value), "")
  265. m.textarea.InsertString("\n")
  266. return m, nil
  267. }
  268. var cmds []tea.Cmd
  269. attachments := m.textarea.GetAttachments()
  270. fileParts := make([]opencode.FilePartParam, 0)
  271. for _, attachment := range attachments {
  272. fileParts = append(fileParts, opencode.FilePartParam{
  273. Type: opencode.F(opencode.FilePartTypeFile),
  274. Mime: opencode.F(attachment.MediaType),
  275. URL: opencode.F(attachment.URL),
  276. Filename: opencode.F(attachment.Filename),
  277. })
  278. }
  279. updated, cmd := m.Clear()
  280. m = updated.(*editorComponent)
  281. cmds = append(cmds, cmd)
  282. cmds = append(cmds, util.CmdHandler(app.SendMsg{Text: value, Attachments: fileParts}))
  283. return m, tea.Batch(cmds...)
  284. }
  285. func (m *editorComponent) Clear() (tea.Model, tea.Cmd) {
  286. m.textarea.Reset()
  287. return m, nil
  288. }
  289. func (m *editorComponent) Paste() (tea.Model, tea.Cmd) {
  290. imageBytes := clipboard.Read(clipboard.FmtImage)
  291. if imageBytes != nil {
  292. base64EncodedFile := base64.StdEncoding.EncodeToString(imageBytes)
  293. attachment := &textarea.Attachment{
  294. ID: uuid.NewString(),
  295. Display: "<clipboard-image>",
  296. Filename: "clipboard-image",
  297. MediaType: "image/png",
  298. URL: fmt.Sprintf("data:image/png;base64,%s", base64EncodedFile),
  299. }
  300. m.textarea.InsertAttachment(attachment)
  301. m.textarea.InsertString(" ")
  302. return m, nil
  303. }
  304. textBytes := clipboard.Read(clipboard.FmtText)
  305. if textBytes != nil {
  306. m.textarea.InsertRunesFromUserInput([]rune(string(textBytes)))
  307. return m, nil
  308. }
  309. // fallback to reading the clipboard using OSC52
  310. return m, tea.ReadClipboard
  311. }
  312. func (m *editorComponent) Newline() (tea.Model, tea.Cmd) {
  313. m.textarea.Newline()
  314. return m, nil
  315. }
  316. func (m *editorComponent) SetInterruptKeyInDebounce(inDebounce bool) {
  317. m.interruptKeyInDebounce = inDebounce
  318. }
  319. func (m *editorComponent) getInterruptKeyText() string {
  320. return m.app.Commands[commands.SessionInterruptCommand].Keys()[0]
  321. }
  322. func (m *editorComponent) getSubmitKeyText() string {
  323. return m.app.Commands[commands.InputSubmitCommand].Keys()[0]
  324. }
  325. func (m *editorComponent) resetTextareaStyles() textarea.Model {
  326. t := theme.CurrentTheme()
  327. bgColor := t.BackgroundElement()
  328. textColor := t.Text()
  329. textMutedColor := t.TextMuted()
  330. ta := m.textarea
  331. ta.Styles.Blurred.Base = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
  332. ta.Styles.Blurred.CursorLine = styles.NewStyle().Background(bgColor).Lipgloss()
  333. ta.Styles.Blurred.Placeholder = styles.NewStyle().
  334. Foreground(textMutedColor).
  335. Background(bgColor).
  336. Lipgloss()
  337. ta.Styles.Blurred.Text = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
  338. ta.Styles.Focused.Base = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
  339. ta.Styles.Focused.CursorLine = styles.NewStyle().Background(bgColor).Lipgloss()
  340. ta.Styles.Focused.Placeholder = styles.NewStyle().
  341. Foreground(textMutedColor).
  342. Background(bgColor).
  343. Lipgloss()
  344. ta.Styles.Focused.Text = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
  345. ta.Styles.Attachment = styles.NewStyle().
  346. Foreground(t.Secondary()).
  347. Background(bgColor).
  348. Lipgloss()
  349. ta.Styles.SelectedAttachment = styles.NewStyle().
  350. Foreground(t.Text()).
  351. Background(t.Secondary()).
  352. Lipgloss()
  353. ta.Styles.Cursor.Color = t.Primary()
  354. return ta
  355. }
  356. func createSpinner() spinner.Model {
  357. t := theme.CurrentTheme()
  358. return spinner.New(
  359. spinner.WithSpinner(spinner.Ellipsis),
  360. spinner.WithStyle(
  361. styles.NewStyle().
  362. Background(t.Background()).
  363. Foreground(t.TextMuted()).
  364. Width(3).
  365. Lipgloss(),
  366. ),
  367. )
  368. }
  369. func NewEditorComponent(app *app.App) EditorComponent {
  370. s := createSpinner()
  371. ta := textarea.New()
  372. ta.Prompt = " "
  373. ta.ShowLineNumbers = false
  374. ta.CharLimit = -1
  375. m := &editorComponent{
  376. app: app,
  377. textarea: ta,
  378. spinner: s,
  379. interruptKeyInDebounce: false,
  380. }
  381. m.resetTextareaStyles()
  382. return m
  383. }