editor.go 15 KB

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