editor.go 13 KB

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