editor.go 15 KB

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