editor.go 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379
  1. package chat
  2. import (
  3. "fmt"
  4. "log/slog"
  5. "path/filepath"
  6. "strings"
  7. "github.com/charmbracelet/bubbles/v2/spinner"
  8. tea "github.com/charmbracelet/bubbletea/v2"
  9. "github.com/charmbracelet/lipgloss/v2"
  10. "github.com/google/uuid"
  11. "github.com/sst/opencode-sdk-go"
  12. "github.com/sst/opencode/internal/app"
  13. "github.com/sst/opencode/internal/commands"
  14. "github.com/sst/opencode/internal/components/dialog"
  15. "github.com/sst/opencode/internal/components/textarea"
  16. "github.com/sst/opencode/internal/image"
  17. "github.com/sst/opencode/internal/styles"
  18. "github.com/sst/opencode/internal/theme"
  19. "github.com/sst/opencode/internal/util"
  20. )
  21. type EditorComponent interface {
  22. tea.Model
  23. View(width int) string
  24. Content(width int) string
  25. Lines() int
  26. Value() string
  27. Focused() bool
  28. Focus() (tea.Model, tea.Cmd)
  29. Blur()
  30. Submit() (tea.Model, tea.Cmd)
  31. Clear() (tea.Model, tea.Cmd)
  32. Paste() (tea.Model, tea.Cmd)
  33. Newline() (tea.Model, tea.Cmd)
  34. SetInterruptKeyInDebounce(inDebounce bool)
  35. }
  36. type editorComponent struct {
  37. app *app.App
  38. textarea textarea.Model
  39. spinner spinner.Model
  40. interruptKeyInDebounce bool
  41. }
  42. func (m *editorComponent) Init() tea.Cmd {
  43. return tea.Batch(m.textarea.Focus(), m.spinner.Tick, tea.EnableReportFocus)
  44. }
  45. func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
  46. var cmds []tea.Cmd
  47. var cmd tea.Cmd
  48. switch msg := msg.(type) {
  49. case spinner.TickMsg:
  50. m.spinner, cmd = m.spinner.Update(msg)
  51. return m, cmd
  52. case tea.KeyPressMsg:
  53. // Maximize editor responsiveness for printable characters
  54. if msg.Text != "" {
  55. m.textarea, cmd = m.textarea.Update(msg)
  56. cmds = append(cmds, cmd)
  57. return m, tea.Batch(cmds...)
  58. }
  59. case dialog.ThemeSelectedMsg:
  60. m.textarea = createTextArea(&m.textarea)
  61. m.spinner = createSpinner()
  62. return m, tea.Batch(m.spinner.Tick, m.textarea.Focus())
  63. case dialog.CompletionSelectedMsg:
  64. switch msg.ProviderID {
  65. case "commands":
  66. commandName := strings.TrimPrefix(msg.CompletionValue, "/")
  67. updated, cmd := m.Clear()
  68. m = updated.(*editorComponent)
  69. cmds = append(cmds, cmd)
  70. cmds = append(cmds, util.CmdHandler(commands.ExecuteCommandMsg(m.app.Commands[commands.CommandName(commandName)])))
  71. return m, tea.Batch(cmds...)
  72. case "files":
  73. atIndex := m.textarea.LastRuneIndex('@')
  74. if atIndex == -1 {
  75. // Should not happen, but as a fallback, just insert.
  76. m.textarea.InsertString(msg.CompletionValue + " ")
  77. return m, nil
  78. }
  79. // The range to replace is from the '@' up to the current cursor position.
  80. // Replace the search term (e.g., "@search") with an empty string first.
  81. cursorCol := m.textarea.CursorColumn()
  82. m.textarea.ReplaceRange(atIndex, cursorCol, "")
  83. // Now, insert the attachment at the position where the '@' was.
  84. // The cursor is now at `atIndex` after the replacement.
  85. filePath := msg.CompletionValue
  86. fileName := filepath.Base(filePath)
  87. extension := filepath.Ext(filePath)
  88. mediaType := ""
  89. switch extension {
  90. case ".jpg":
  91. mediaType = "image/jpeg"
  92. case ".png", ".jpeg", ".gif", ".webp":
  93. mediaType = "image/" + extension[1:]
  94. case ".pdf":
  95. mediaType = "application/pdf"
  96. default:
  97. mediaType = "text/plain"
  98. }
  99. attachment := &textarea.Attachment{
  100. ID: uuid.NewString(),
  101. Display: "@" + fileName,
  102. URL: fmt.Sprintf("file://./%s", filePath),
  103. Filename: filePath,
  104. MediaType: mediaType,
  105. }
  106. m.textarea.InsertAttachment(attachment)
  107. m.textarea.InsertString(" ")
  108. return m, nil
  109. default:
  110. existingValue := m.textarea.Value()
  111. lastSpaceIndex := strings.LastIndex(existingValue, " ")
  112. if lastSpaceIndex == -1 {
  113. m.textarea.SetValue(msg.CompletionValue + " ")
  114. } else {
  115. modifiedValue := existingValue[:lastSpaceIndex+1] + msg.CompletionValue
  116. m.textarea.SetValue(modifiedValue + " ")
  117. }
  118. return m, nil
  119. }
  120. }
  121. m.spinner, cmd = m.spinner.Update(msg)
  122. cmds = append(cmds, cmd)
  123. m.textarea, cmd = m.textarea.Update(msg)
  124. cmds = append(cmds, cmd)
  125. return m, tea.Batch(cmds...)
  126. }
  127. func (m *editorComponent) Content(width int) string {
  128. t := theme.CurrentTheme()
  129. base := styles.NewStyle().Foreground(t.Text()).Background(t.Background()).Render
  130. muted := styles.NewStyle().Foreground(t.TextMuted()).Background(t.Background()).Render
  131. promptStyle := styles.NewStyle().Foreground(t.Primary()).
  132. Padding(0, 0, 0, 1).
  133. Bold(true)
  134. prompt := promptStyle.Render(">")
  135. m.textarea.SetWidth(width - 6)
  136. textarea := lipgloss.JoinHorizontal(
  137. lipgloss.Top,
  138. prompt,
  139. m.textarea.View(),
  140. )
  141. textarea = styles.NewStyle().
  142. Background(t.BackgroundElement()).
  143. Width(width).
  144. PaddingTop(1).
  145. PaddingBottom(1).
  146. BorderStyle(lipgloss.ThickBorder()).
  147. BorderForeground(t.Border()).
  148. BorderBackground(t.Background()).
  149. BorderLeft(true).
  150. BorderRight(true).
  151. Render(textarea)
  152. hint := base(m.getSubmitKeyText()) + muted(" send ")
  153. if m.app.IsBusy() {
  154. keyText := m.getInterruptKeyText()
  155. if m.interruptKeyInDebounce {
  156. hint = muted(
  157. "working",
  158. ) + m.spinner.View() + muted(
  159. " ",
  160. ) + base(
  161. keyText+" again",
  162. ) + muted(
  163. " interrupt",
  164. )
  165. } else {
  166. hint = muted("working") + m.spinner.View() + muted(" ") + base(keyText) + muted(" interrupt")
  167. }
  168. }
  169. model := ""
  170. if m.app.Model != nil {
  171. model = muted(m.app.Provider.Name) + base(" "+m.app.Model.Name)
  172. }
  173. space := width - 2 - lipgloss.Width(model) - lipgloss.Width(hint)
  174. spacer := styles.NewStyle().Background(t.Background()).Width(space).Render("")
  175. info := hint + spacer + model
  176. info = styles.NewStyle().Background(t.Background()).Padding(0, 1).Render(info)
  177. content := strings.Join([]string{"", textarea, info}, "\n")
  178. return content
  179. }
  180. func (m *editorComponent) View(width int) string {
  181. if m.Lines() > 1 {
  182. return lipgloss.Place(
  183. width,
  184. 5,
  185. lipgloss.Center,
  186. lipgloss.Center,
  187. "",
  188. styles.WhitespaceStyle(theme.CurrentTheme().Background()),
  189. )
  190. }
  191. return m.Content(width)
  192. }
  193. func (m *editorComponent) Focused() bool {
  194. return m.textarea.Focused()
  195. }
  196. func (m *editorComponent) Focus() (tea.Model, tea.Cmd) {
  197. return m, m.textarea.Focus()
  198. }
  199. func (m *editorComponent) Blur() {
  200. m.textarea.Blur()
  201. }
  202. func (m *editorComponent) Lines() int {
  203. return m.textarea.LineCount()
  204. }
  205. func (m *editorComponent) Value() string {
  206. return m.textarea.Value()
  207. }
  208. func (m *editorComponent) Submit() (tea.Model, tea.Cmd) {
  209. value := strings.TrimSpace(m.Value())
  210. if value == "" {
  211. return m, nil
  212. }
  213. if len(value) > 0 && value[len(value)-1] == '\\' {
  214. // If the last character is a backslash, remove it and add a newline
  215. m.textarea.ReplaceRange(len(value)-1, len(value), "")
  216. m.textarea.InsertString("\n")
  217. return m, nil
  218. }
  219. var cmds []tea.Cmd
  220. attachments := m.textarea.GetAttachments()
  221. fileParts := make([]opencode.FilePartParam, 0)
  222. for _, attachment := range attachments {
  223. fileParts = append(fileParts, opencode.FilePartParam{
  224. Type: opencode.F(opencode.FilePartTypeFile),
  225. MediaType: opencode.F(attachment.MediaType),
  226. URL: opencode.F(attachment.URL),
  227. Filename: opencode.F(attachment.Filename),
  228. })
  229. }
  230. updated, cmd := m.Clear()
  231. m = updated.(*editorComponent)
  232. cmds = append(cmds, cmd)
  233. cmds = append(cmds, util.CmdHandler(app.SendMsg{Text: value, Attachments: fileParts}))
  234. return m, tea.Batch(cmds...)
  235. }
  236. func (m *editorComponent) Clear() (tea.Model, tea.Cmd) {
  237. m.textarea.Reset()
  238. return m, nil
  239. }
  240. func (m *editorComponent) Paste() (tea.Model, tea.Cmd) {
  241. _, text, err := image.GetImageFromClipboard()
  242. if err != nil {
  243. slog.Error(err.Error())
  244. return m, nil
  245. }
  246. // if len(imageBytes) != 0 {
  247. // attachmentName := fmt.Sprintf("clipboard-image-%d", len(m.attachments))
  248. // attachment := app.Attachment{
  249. // FilePath: attachmentName,
  250. // FileName: attachmentName,
  251. // Content: imageBytes,
  252. // MimeType: "image/png",
  253. // }
  254. // m.attachments = append(m.attachments, attachment)
  255. // } else {
  256. m.textarea.InsertString(text)
  257. // }
  258. return m, nil
  259. }
  260. func (m *editorComponent) Newline() (tea.Model, tea.Cmd) {
  261. m.textarea.Newline()
  262. return m, nil
  263. }
  264. func (m *editorComponent) SetInterruptKeyInDebounce(inDebounce bool) {
  265. m.interruptKeyInDebounce = inDebounce
  266. }
  267. func (m *editorComponent) getInterruptKeyText() string {
  268. return m.app.Commands[commands.SessionInterruptCommand].Keys()[0]
  269. }
  270. func (m *editorComponent) getSubmitKeyText() string {
  271. return m.app.Commands[commands.InputSubmitCommand].Keys()[0]
  272. }
  273. func createTextArea(existing *textarea.Model) textarea.Model {
  274. t := theme.CurrentTheme()
  275. bgColor := t.BackgroundElement()
  276. textColor := t.Text()
  277. textMutedColor := t.TextMuted()
  278. ta := textarea.New()
  279. ta.Styles.Blurred.Base = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
  280. ta.Styles.Blurred.CursorLine = styles.NewStyle().Background(bgColor).Lipgloss()
  281. ta.Styles.Blurred.Placeholder = styles.NewStyle().
  282. Foreground(textMutedColor).
  283. Background(bgColor).
  284. Lipgloss()
  285. ta.Styles.Blurred.Text = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
  286. ta.Styles.Focused.Base = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
  287. ta.Styles.Focused.CursorLine = styles.NewStyle().Background(bgColor).Lipgloss()
  288. ta.Styles.Focused.Placeholder = styles.NewStyle().
  289. Foreground(textMutedColor).
  290. Background(bgColor).
  291. Lipgloss()
  292. ta.Styles.Focused.Text = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
  293. ta.Styles.Attachment = styles.NewStyle().
  294. Foreground(t.Secondary()).
  295. Background(bgColor).
  296. Lipgloss()
  297. ta.Styles.SelectedAttachment = styles.NewStyle().
  298. Foreground(t.Text()).
  299. Background(t.Secondary()).
  300. Lipgloss()
  301. ta.Styles.Cursor.Color = t.Primary()
  302. ta.Prompt = " "
  303. ta.ShowLineNumbers = false
  304. ta.CharLimit = -1
  305. if existing != nil {
  306. ta.SetValue(existing.Value())
  307. // ta.SetWidth(existing.Width())
  308. ta.SetHeight(existing.Height())
  309. }
  310. return ta
  311. }
  312. func createSpinner() spinner.Model {
  313. t := theme.CurrentTheme()
  314. return spinner.New(
  315. spinner.WithSpinner(spinner.Ellipsis),
  316. spinner.WithStyle(
  317. styles.NewStyle().
  318. Background(t.Background()).
  319. Foreground(t.TextMuted()).
  320. Width(3).
  321. Lipgloss(),
  322. ),
  323. )
  324. }
  325. func NewEditorComponent(app *app.App) EditorComponent {
  326. s := createSpinner()
  327. ta := createTextArea(nil)
  328. return &editorComponent{
  329. app: app,
  330. textarea: ta,
  331. spinner: s,
  332. interruptKeyInDebounce: false,
  333. }
  334. }