editor.go 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366
  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. attachment := &textarea.Attachment{
  88. ID: uuid.NewString(),
  89. Display: "@" + fileName,
  90. URL: fmt.Sprintf("file://%s", filePath),
  91. Filename: fileName,
  92. MediaType: "text/plain",
  93. }
  94. m.textarea.InsertAttachment(attachment)
  95. m.textarea.InsertString(" ")
  96. return m, nil
  97. default:
  98. existingValue := m.textarea.Value()
  99. lastSpaceIndex := strings.LastIndex(existingValue, " ")
  100. if lastSpaceIndex == -1 {
  101. m.textarea.SetValue(msg.CompletionValue + " ")
  102. } else {
  103. modifiedValue := existingValue[:lastSpaceIndex+1] + msg.CompletionValue
  104. m.textarea.SetValue(modifiedValue + " ")
  105. }
  106. return m, nil
  107. }
  108. }
  109. m.spinner, cmd = m.spinner.Update(msg)
  110. cmds = append(cmds, cmd)
  111. m.textarea, cmd = m.textarea.Update(msg)
  112. cmds = append(cmds, cmd)
  113. return m, tea.Batch(cmds...)
  114. }
  115. func (m *editorComponent) Content(width int) string {
  116. t := theme.CurrentTheme()
  117. base := styles.NewStyle().Foreground(t.Text()).Background(t.Background()).Render
  118. muted := styles.NewStyle().Foreground(t.TextMuted()).Background(t.Background()).Render
  119. promptStyle := styles.NewStyle().Foreground(t.Primary()).
  120. Padding(0, 0, 0, 1).
  121. Bold(true)
  122. prompt := promptStyle.Render(">")
  123. m.textarea.SetWidth(width - 6)
  124. textarea := lipgloss.JoinHorizontal(
  125. lipgloss.Top,
  126. prompt,
  127. m.textarea.View(),
  128. )
  129. textarea = styles.NewStyle().
  130. Background(t.BackgroundElement()).
  131. Width(width).
  132. PaddingTop(1).
  133. PaddingBottom(1).
  134. BorderStyle(lipgloss.ThickBorder()).
  135. BorderForeground(t.Border()).
  136. BorderBackground(t.Background()).
  137. BorderLeft(true).
  138. BorderRight(true).
  139. Render(textarea)
  140. hint := base(m.getSubmitKeyText()) + muted(" send ")
  141. if m.app.IsBusy() {
  142. keyText := m.getInterruptKeyText()
  143. if m.interruptKeyInDebounce {
  144. hint = muted(
  145. "working",
  146. ) + m.spinner.View() + muted(
  147. " ",
  148. ) + base(
  149. keyText+" again",
  150. ) + muted(
  151. " interrupt",
  152. )
  153. } else {
  154. hint = muted("working") + m.spinner.View() + muted(" ") + base(keyText) + muted(" interrupt")
  155. }
  156. }
  157. model := ""
  158. if m.app.Model != nil {
  159. model = muted(m.app.Provider.Name) + base(" "+m.app.Model.Name)
  160. }
  161. space := width - 2 - lipgloss.Width(model) - lipgloss.Width(hint)
  162. spacer := styles.NewStyle().Background(t.Background()).Width(space).Render("")
  163. info := hint + spacer + model
  164. info = styles.NewStyle().Background(t.Background()).Padding(0, 1).Render(info)
  165. content := strings.Join([]string{"", textarea, info}, "\n")
  166. return content
  167. }
  168. func (m *editorComponent) View(width int) string {
  169. if m.Lines() > 1 {
  170. return lipgloss.Place(
  171. width,
  172. 5,
  173. lipgloss.Center,
  174. lipgloss.Center,
  175. "",
  176. styles.WhitespaceStyle(theme.CurrentTheme().Background()),
  177. )
  178. }
  179. return m.Content(width)
  180. }
  181. func (m *editorComponent) Focused() bool {
  182. return m.textarea.Focused()
  183. }
  184. func (m *editorComponent) Focus() (tea.Model, tea.Cmd) {
  185. return m, m.textarea.Focus()
  186. }
  187. func (m *editorComponent) Blur() {
  188. m.textarea.Blur()
  189. }
  190. func (m *editorComponent) Lines() int {
  191. return m.textarea.LineCount()
  192. }
  193. func (m *editorComponent) Value() string {
  194. return m.textarea.Value()
  195. }
  196. func (m *editorComponent) Submit() (tea.Model, tea.Cmd) {
  197. value := strings.TrimSpace(m.Value())
  198. if value == "" {
  199. return m, nil
  200. }
  201. if len(value) > 0 && value[len(value)-1] == '\\' {
  202. // If the last character is a backslash, remove it and add a newline
  203. m.textarea.SetValue(value[:len(value)-1] + "\n")
  204. return m, nil
  205. }
  206. var cmds []tea.Cmd
  207. attachments := m.textarea.GetAttachments()
  208. fileParts := make([]opencode.FilePartParam, 0)
  209. for _, attachment := range attachments {
  210. fileParts = append(fileParts, opencode.FilePartParam{
  211. Type: opencode.F(opencode.FilePartTypeFile),
  212. MediaType: opencode.F(attachment.MediaType),
  213. URL: opencode.F(attachment.URL),
  214. Filename: opencode.F(attachment.Filename),
  215. })
  216. }
  217. updated, cmd := m.Clear()
  218. m = updated.(*editorComponent)
  219. cmds = append(cmds, cmd)
  220. cmds = append(cmds, util.CmdHandler(app.SendMsg{Text: value, Attachments: fileParts}))
  221. return m, tea.Batch(cmds...)
  222. }
  223. func (m *editorComponent) Clear() (tea.Model, tea.Cmd) {
  224. m.textarea.Reset()
  225. return m, nil
  226. }
  227. func (m *editorComponent) Paste() (tea.Model, tea.Cmd) {
  228. _, text, err := image.GetImageFromClipboard()
  229. if err != nil {
  230. slog.Error(err.Error())
  231. return m, nil
  232. }
  233. // if len(imageBytes) != 0 {
  234. // attachmentName := fmt.Sprintf("clipboard-image-%d", len(m.attachments))
  235. // attachment := app.Attachment{
  236. // FilePath: attachmentName,
  237. // FileName: attachmentName,
  238. // Content: imageBytes,
  239. // MimeType: "image/png",
  240. // }
  241. // m.attachments = append(m.attachments, attachment)
  242. // } else {
  243. m.textarea.SetValue(m.textarea.Value() + text)
  244. // }
  245. return m, nil
  246. }
  247. func (m *editorComponent) Newline() (tea.Model, tea.Cmd) {
  248. m.textarea.Newline()
  249. return m, nil
  250. }
  251. func (m *editorComponent) SetInterruptKeyInDebounce(inDebounce bool) {
  252. m.interruptKeyInDebounce = inDebounce
  253. }
  254. func (m *editorComponent) getInterruptKeyText() string {
  255. return m.app.Commands[commands.SessionInterruptCommand].Keys()[0]
  256. }
  257. func (m *editorComponent) getSubmitKeyText() string {
  258. return m.app.Commands[commands.InputSubmitCommand].Keys()[0]
  259. }
  260. func createTextArea(existing *textarea.Model) textarea.Model {
  261. t := theme.CurrentTheme()
  262. bgColor := t.BackgroundElement()
  263. textColor := t.Text()
  264. textMutedColor := t.TextMuted()
  265. ta := textarea.New()
  266. ta.Styles.Blurred.Base = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
  267. ta.Styles.Blurred.CursorLine = styles.NewStyle().Background(bgColor).Lipgloss()
  268. ta.Styles.Blurred.Placeholder = styles.NewStyle().
  269. Foreground(textMutedColor).
  270. Background(bgColor).
  271. Lipgloss()
  272. ta.Styles.Blurred.Text = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
  273. ta.Styles.Focused.Base = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
  274. ta.Styles.Focused.CursorLine = styles.NewStyle().Background(bgColor).Lipgloss()
  275. ta.Styles.Focused.Placeholder = styles.NewStyle().
  276. Foreground(textMutedColor).
  277. Background(bgColor).
  278. Lipgloss()
  279. ta.Styles.Focused.Text = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
  280. ta.Styles.Attachment = styles.NewStyle().
  281. Foreground(t.Secondary()).
  282. Background(bgColor).
  283. Lipgloss()
  284. ta.Styles.SelectedAttachment = styles.NewStyle().
  285. Foreground(t.Text()).
  286. Background(t.Secondary()).
  287. Lipgloss()
  288. ta.Styles.Cursor.Color = t.Primary()
  289. ta.Prompt = " "
  290. ta.ShowLineNumbers = false
  291. ta.CharLimit = -1
  292. if existing != nil {
  293. ta.SetValue(existing.Value())
  294. // ta.SetWidth(existing.Width())
  295. ta.SetHeight(existing.Height())
  296. }
  297. return ta
  298. }
  299. func createSpinner() spinner.Model {
  300. t := theme.CurrentTheme()
  301. return spinner.New(
  302. spinner.WithSpinner(spinner.Ellipsis),
  303. spinner.WithStyle(
  304. styles.NewStyle().
  305. Background(t.Background()).
  306. Foreground(t.TextMuted()).
  307. Width(3).
  308. Lipgloss(),
  309. ),
  310. )
  311. }
  312. func NewEditorComponent(app *app.App) EditorComponent {
  313. s := createSpinner()
  314. ta := createTextArea(nil)
  315. return &editorComponent{
  316. app: app,
  317. textarea: ta,
  318. spinner: s,
  319. interruptKeyInDebounce: false,
  320. }
  321. }