editor.go 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493
  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. borderForeground := t.Border()
  226. if m.app.IsLeaderSequence {
  227. borderForeground = t.Accent()
  228. }
  229. textarea = styles.NewStyle().
  230. Background(t.BackgroundElement()).
  231. Width(width).
  232. PaddingTop(1).
  233. PaddingBottom(1).
  234. BorderStyle(lipgloss.ThickBorder()).
  235. BorderForeground(borderForeground).
  236. BorderBackground(t.Background()).
  237. BorderLeft(true).
  238. BorderRight(true).
  239. Render(textarea)
  240. hint := base(m.getSubmitKeyText()) + muted(" send ")
  241. if m.exitKeyInDebounce {
  242. keyText := m.getExitKeyText()
  243. hint = base(keyText+" again") + muted(" to exit")
  244. } else if m.app.IsBusy() {
  245. keyText := m.getInterruptKeyText()
  246. if m.interruptKeyInDebounce {
  247. hint = muted(
  248. "working",
  249. ) + m.spinner.View() + muted(
  250. " ",
  251. ) + base(
  252. keyText+" again",
  253. ) + muted(
  254. " interrupt",
  255. )
  256. } else {
  257. hint = muted("working") + m.spinner.View() + muted(" ") + base(keyText) + muted(" interrupt")
  258. }
  259. }
  260. model := ""
  261. if m.app.Model != nil {
  262. model = muted(m.app.Provider.Name) + base(" "+m.app.Model.Name)
  263. }
  264. space := width - 2 - lipgloss.Width(model) - lipgloss.Width(hint)
  265. spacer := styles.NewStyle().Background(t.Background()).Width(space).Render("")
  266. info := hint + spacer + model
  267. info = styles.NewStyle().Background(t.Background()).Padding(0, 1).Render(info)
  268. content := strings.Join([]string{"", textarea, info}, "\n")
  269. return content
  270. }
  271. func (m *editorComponent) View(width int) string {
  272. if m.Lines() > 1 {
  273. return lipgloss.Place(
  274. width,
  275. 5,
  276. lipgloss.Center,
  277. lipgloss.Center,
  278. "",
  279. styles.WhitespaceStyle(theme.CurrentTheme().Background()),
  280. )
  281. }
  282. return m.Content(width)
  283. }
  284. func (m *editorComponent) Focused() bool {
  285. return m.textarea.Focused()
  286. }
  287. func (m *editorComponent) Focus() (tea.Model, tea.Cmd) {
  288. return m, m.textarea.Focus()
  289. }
  290. func (m *editorComponent) Blur() {
  291. m.textarea.Blur()
  292. }
  293. func (m *editorComponent) Lines() int {
  294. return m.textarea.LineCount()
  295. }
  296. func (m *editorComponent) Value() string {
  297. return m.textarea.Value()
  298. }
  299. func (m *editorComponent) Length() int {
  300. return m.textarea.Length()
  301. }
  302. func (m *editorComponent) Submit() (tea.Model, tea.Cmd) {
  303. value := strings.TrimSpace(m.Value())
  304. if value == "" {
  305. return m, nil
  306. }
  307. if len(value) > 0 && value[len(value)-1] == '\\' {
  308. // If the last character is a backslash, remove it and add a newline
  309. m.textarea.ReplaceRange(len(value)-1, len(value), "")
  310. m.textarea.InsertString("\n")
  311. return m, nil
  312. }
  313. var cmds []tea.Cmd
  314. attachments := m.textarea.GetAttachments()
  315. fileParts := make([]opencode.FilePartParam, 0)
  316. for _, attachment := range attachments {
  317. fileParts = append(fileParts, opencode.FilePartParam{
  318. Type: opencode.F(opencode.FilePartTypeFile),
  319. Mime: opencode.F(attachment.MediaType),
  320. URL: opencode.F(attachment.URL),
  321. Filename: opencode.F(attachment.Filename),
  322. })
  323. }
  324. updated, cmd := m.Clear()
  325. m = updated.(*editorComponent)
  326. cmds = append(cmds, cmd)
  327. cmds = append(cmds, util.CmdHandler(app.SendMsg{Text: value, Attachments: fileParts}))
  328. return m, tea.Batch(cmds...)
  329. }
  330. func (m *editorComponent) Clear() (tea.Model, tea.Cmd) {
  331. m.textarea.Reset()
  332. return m, nil
  333. }
  334. func (m *editorComponent) Paste() (tea.Model, tea.Cmd) {
  335. imageBytes := clipboard.Read(clipboard.FmtImage)
  336. if imageBytes != nil {
  337. attachmentCount := len(m.textarea.GetAttachments())
  338. attachmentIndex := attachmentCount + 1
  339. base64EncodedFile := base64.StdEncoding.EncodeToString(imageBytes)
  340. attachment := &textarea.Attachment{
  341. ID: uuid.NewString(),
  342. MediaType: "image/png",
  343. Display: fmt.Sprintf("[Image #%d]", attachmentIndex),
  344. Filename: fmt.Sprintf("image-%d.png", attachmentIndex),
  345. URL: fmt.Sprintf("data:image/png;base64,%s", base64EncodedFile),
  346. }
  347. m.textarea.InsertAttachment(attachment)
  348. m.textarea.InsertString(" ")
  349. return m, nil
  350. }
  351. textBytes := clipboard.Read(clipboard.FmtText)
  352. if textBytes != nil {
  353. m.textarea.InsertRunesFromUserInput([]rune(string(textBytes)))
  354. return m, nil
  355. }
  356. // fallback to reading the clipboard using OSC52
  357. return m, tea.ReadClipboard
  358. }
  359. func (m *editorComponent) Newline() (tea.Model, tea.Cmd) {
  360. m.textarea.Newline()
  361. return m, nil
  362. }
  363. func (m *editorComponent) SetInterruptKeyInDebounce(inDebounce bool) {
  364. m.interruptKeyInDebounce = inDebounce
  365. }
  366. func (m *editorComponent) SetValue(value string) {
  367. m.textarea.SetValue(value)
  368. }
  369. func (m *editorComponent) SetExitKeyInDebounce(inDebounce bool) {
  370. m.exitKeyInDebounce = inDebounce
  371. }
  372. func (m *editorComponent) getInterruptKeyText() string {
  373. return m.app.Commands[commands.SessionInterruptCommand].Keys()[0]
  374. }
  375. func (m *editorComponent) getSubmitKeyText() string {
  376. return m.app.Commands[commands.InputSubmitCommand].Keys()[0]
  377. }
  378. func (m *editorComponent) getExitKeyText() string {
  379. return m.app.Commands[commands.AppExitCommand].Keys()[0]
  380. }
  381. func updateTextareaStyles(ta textarea.Model) textarea.Model {
  382. t := theme.CurrentTheme()
  383. bgColor := t.BackgroundElement()
  384. textColor := t.Text()
  385. textMutedColor := t.TextMuted()
  386. ta.Styles.Blurred.Base = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
  387. ta.Styles.Blurred.CursorLine = styles.NewStyle().Background(bgColor).Lipgloss()
  388. ta.Styles.Blurred.Placeholder = styles.NewStyle().
  389. Foreground(textMutedColor).
  390. Background(bgColor).
  391. Lipgloss()
  392. ta.Styles.Blurred.Text = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
  393. ta.Styles.Focused.Base = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
  394. ta.Styles.Focused.CursorLine = styles.NewStyle().Background(bgColor).Lipgloss()
  395. ta.Styles.Focused.Placeholder = styles.NewStyle().
  396. Foreground(textMutedColor).
  397. Background(bgColor).
  398. Lipgloss()
  399. ta.Styles.Focused.Text = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
  400. ta.Styles.Attachment = styles.NewStyle().
  401. Foreground(t.Secondary()).
  402. Background(bgColor).
  403. Lipgloss()
  404. ta.Styles.SelectedAttachment = styles.NewStyle().
  405. Foreground(t.Text()).
  406. Background(t.Secondary()).
  407. Lipgloss()
  408. ta.Styles.Cursor.Color = t.Primary()
  409. return ta
  410. }
  411. func createSpinner() spinner.Model {
  412. t := theme.CurrentTheme()
  413. return spinner.New(
  414. spinner.WithSpinner(spinner.Ellipsis),
  415. spinner.WithStyle(
  416. styles.NewStyle().
  417. Background(t.Background()).
  418. Foreground(t.TextMuted()).
  419. Width(3).
  420. Lipgloss(),
  421. ),
  422. )
  423. }
  424. func NewEditorComponent(app *app.App) EditorComponent {
  425. s := createSpinner()
  426. ta := textarea.New()
  427. ta.Prompt = " "
  428. ta.ShowLineNumbers = false
  429. ta.CharLimit = -1
  430. ta = updateTextareaStyles(ta)
  431. m := &editorComponent{
  432. app: app,
  433. textarea: ta,
  434. spinner: s,
  435. interruptKeyInDebounce: false,
  436. }
  437. return m
  438. }