editor.go 12 KB

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