editor.go 14 KB

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