editor.go 13 KB

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