editor.go 12 KB

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