editor.go 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549
  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. m.textarea.ReplaceRange(len(value)-1, len(value), "")
  267. m.textarea.InsertString("\n")
  268. return m, nil
  269. }
  270. var cmds []tea.Cmd
  271. attachments := m.textarea.GetAttachments()
  272. fileParts := make([]opencode.FilePartParam, 0)
  273. for _, attachment := range attachments {
  274. fileParts = append(fileParts, opencode.FilePartParam{
  275. Type: opencode.F(opencode.FilePartTypeFile),
  276. Mime: opencode.F(attachment.MediaType),
  277. URL: opencode.F(attachment.URL),
  278. Filename: opencode.F(attachment.Filename),
  279. })
  280. }
  281. updated, cmd := m.Clear()
  282. m = updated.(*editorComponent)
  283. cmds = append(cmds, cmd)
  284. cmds = append(cmds, util.CmdHandler(app.SendMsg{Text: value, Attachments: fileParts}))
  285. return m, tea.Batch(cmds...)
  286. }
  287. func (m *editorComponent) Clear() (tea.Model, tea.Cmd) {
  288. m.textarea.Reset()
  289. return m, nil
  290. }
  291. func (m *editorComponent) Paste() (tea.Model, tea.Cmd) {
  292. imageBytes := clipboard.Read(clipboard.FmtImage)
  293. if imageBytes != nil {
  294. attachmentCount := len(m.textarea.GetAttachments())
  295. attachmentIndex := attachmentCount + 1
  296. base64EncodedFile := base64.StdEncoding.EncodeToString(imageBytes)
  297. attachment := &textarea.Attachment{
  298. ID: uuid.NewString(),
  299. MediaType: "image/png",
  300. Display: fmt.Sprintf("[Image #%d]", attachmentIndex),
  301. Filename: fmt.Sprintf("image-%d.png", attachmentIndex),
  302. URL: fmt.Sprintf("data:image/png;base64,%s", base64EncodedFile),
  303. }
  304. m.textarea.InsertAttachment(attachment)
  305. m.textarea.InsertString(" ")
  306. return m, nil
  307. }
  308. textBytes := clipboard.Read(clipboard.FmtText)
  309. if textBytes != nil {
  310. m.textarea.InsertRunesFromUserInput([]rune(string(textBytes)))
  311. return m, nil
  312. }
  313. // fallback to reading the clipboard using OSC52
  314. return m, tea.ReadClipboard
  315. }
  316. func (m *editorComponent) Newline() (tea.Model, tea.Cmd) {
  317. m.textarea.Newline()
  318. return m, nil
  319. }
  320. func (m *editorComponent) SetInterruptKeyInDebounce(inDebounce bool) {
  321. m.interruptKeyInDebounce = inDebounce
  322. }
  323. func (m *editorComponent) SetValue(value string) {
  324. m.textarea.SetValue(value)
  325. }
  326. func (m *editorComponent) SetValueWithAttachments(value string) {
  327. m.textarea.Reset()
  328. i := 0
  329. for i < len(value) {
  330. // Check if filepath and add attachment
  331. if value[i] == '@' {
  332. start := i + 1
  333. end := start
  334. for end < len(value) && value[end] != ' ' && value[end] != '\t' && value[end] != '\n' && value[end] != '\r' {
  335. end++
  336. }
  337. if end > start {
  338. filePath := value[start:end]
  339. if _, err := os.Stat(filePath); err == nil {
  340. attachment := m.createAttachmentFromFile(filePath)
  341. if attachment != nil {
  342. m.textarea.InsertAttachment(attachment)
  343. i = end
  344. continue
  345. }
  346. }
  347. }
  348. }
  349. // Not a valid file path, insert the character normally
  350. m.textarea.InsertRune(rune(value[i]))
  351. i++
  352. }
  353. }
  354. func (m *editorComponent) SetExitKeyInDebounce(inDebounce bool) {
  355. m.exitKeyInDebounce = inDebounce
  356. }
  357. func (m *editorComponent) getInterruptKeyText() string {
  358. return m.app.Commands[commands.SessionInterruptCommand].Keys()[0]
  359. }
  360. func (m *editorComponent) getSubmitKeyText() string {
  361. return m.app.Commands[commands.InputSubmitCommand].Keys()[0]
  362. }
  363. func (m *editorComponent) getExitKeyText() string {
  364. return m.app.Commands[commands.AppExitCommand].Keys()[0]
  365. }
  366. func updateTextareaStyles(ta textarea.Model) textarea.Model {
  367. t := theme.CurrentTheme()
  368. bgColor := t.BackgroundElement()
  369. textColor := t.Text()
  370. textMutedColor := t.TextMuted()
  371. ta.Styles.Blurred.Base = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
  372. ta.Styles.Blurred.CursorLine = styles.NewStyle().Background(bgColor).Lipgloss()
  373. ta.Styles.Blurred.Placeholder = styles.NewStyle().
  374. Foreground(textMutedColor).
  375. Background(bgColor).
  376. Lipgloss()
  377. ta.Styles.Blurred.Text = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
  378. ta.Styles.Focused.Base = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
  379. ta.Styles.Focused.CursorLine = styles.NewStyle().Background(bgColor).Lipgloss()
  380. ta.Styles.Focused.Placeholder = styles.NewStyle().
  381. Foreground(textMutedColor).
  382. Background(bgColor).
  383. Lipgloss()
  384. ta.Styles.Focused.Text = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
  385. ta.Styles.Attachment = styles.NewStyle().
  386. Foreground(t.Secondary()).
  387. Background(bgColor).
  388. Lipgloss()
  389. ta.Styles.SelectedAttachment = styles.NewStyle().
  390. Foreground(t.Text()).
  391. Background(t.Secondary()).
  392. Lipgloss()
  393. ta.Styles.Cursor.Color = t.Primary()
  394. return ta
  395. }
  396. func createSpinner() spinner.Model {
  397. t := theme.CurrentTheme()
  398. return spinner.New(
  399. spinner.WithSpinner(spinner.Ellipsis),
  400. spinner.WithStyle(
  401. styles.NewStyle().
  402. Background(t.Background()).
  403. Foreground(t.TextMuted()).
  404. Width(3).
  405. Lipgloss(),
  406. ),
  407. )
  408. }
  409. func NewEditorComponent(app *app.App) EditorComponent {
  410. s := createSpinner()
  411. ta := textarea.New()
  412. ta.Prompt = " "
  413. ta.ShowLineNumbers = false
  414. ta.CharLimit = -1
  415. ta = updateTextareaStyles(ta)
  416. m := &editorComponent{
  417. app: app,
  418. textarea: ta,
  419. spinner: s,
  420. interruptKeyInDebounce: false,
  421. }
  422. return m
  423. }
  424. func getMediaTypeFromExtension(ext string) string {
  425. switch strings.ToLower(ext) {
  426. case ".jpg":
  427. return "image/jpeg"
  428. case ".png", ".jpeg", ".gif", ".webp":
  429. return "image/" + ext[1:]
  430. case ".pdf":
  431. return "application/pdf"
  432. default:
  433. return "text/plain"
  434. }
  435. }
  436. func (m *editorComponent) createAttachmentFromFile(filePath string) *textarea.Attachment {
  437. ext := strings.ToLower(filepath.Ext(filePath))
  438. mediaType := getMediaTypeFromExtension(ext)
  439. // For text files, create a simple file reference
  440. if mediaType == "text/plain" {
  441. return &textarea.Attachment{
  442. ID: uuid.NewString(),
  443. Display: "@" + filePath,
  444. URL: fmt.Sprintf("file://./%s", filePath),
  445. Filename: filePath,
  446. MediaType: mediaType,
  447. }
  448. }
  449. // For binary files (images, PDFs), read and encode
  450. fileBytes, err := os.ReadFile(filePath)
  451. if err != nil {
  452. slog.Error("Failed to read file", "error", err)
  453. return nil
  454. }
  455. base64EncodedFile := base64.StdEncoding.EncodeToString(fileBytes)
  456. url := fmt.Sprintf("data:%s;base64,%s", mediaType, base64EncodedFile)
  457. attachmentCount := len(m.textarea.GetAttachments())
  458. attachmentIndex := attachmentCount + 1
  459. label := "File"
  460. if strings.HasPrefix(mediaType, "image/") {
  461. label = "Image"
  462. }
  463. return &textarea.Attachment{
  464. ID: uuid.NewString(),
  465. MediaType: mediaType,
  466. Display: fmt.Sprintf("[%s #%d]", label, attachmentIndex),
  467. URL: url,
  468. Filename: filePath,
  469. }
  470. }
  471. func (m *editorComponent) createAttachmentFromPath(filePath string) *textarea.Attachment {
  472. extension := filepath.Ext(filePath)
  473. mediaType := getMediaTypeFromExtension(extension)
  474. return &textarea.Attachment{
  475. ID: uuid.NewString(),
  476. Display: "@" + filePath,
  477. URL: fmt.Sprintf("file://./%s", url.PathEscape(filePath)),
  478. Filename: filePath,
  479. MediaType: mediaType,
  480. }
  481. }