editor.go 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377
  1. package chat
  2. import (
  3. "fmt"
  4. "log/slog"
  5. "strings"
  6. "github.com/charmbracelet/bubbles/v2/spinner"
  7. tea "github.com/charmbracelet/bubbletea/v2"
  8. "github.com/charmbracelet/lipgloss/v2"
  9. "github.com/sst/opencode/internal/app"
  10. "github.com/sst/opencode/internal/commands"
  11. "github.com/sst/opencode/internal/components/dialog"
  12. "github.com/sst/opencode/internal/components/textarea"
  13. "github.com/sst/opencode/internal/image"
  14. "github.com/sst/opencode/internal/layout"
  15. "github.com/sst/opencode/internal/styles"
  16. "github.com/sst/opencode/internal/theme"
  17. "github.com/sst/opencode/internal/util"
  18. )
  19. type EditorComponent interface {
  20. tea.Model
  21. tea.ViewModel
  22. SetSize(width, height int) tea.Cmd
  23. Content() string
  24. Lines() int
  25. Value() string
  26. Focused() bool
  27. Focus() (tea.Model, tea.Cmd)
  28. Blur()
  29. Submit() (tea.Model, tea.Cmd)
  30. Clear() (tea.Model, tea.Cmd)
  31. Paste() (tea.Model, tea.Cmd)
  32. Newline() (tea.Model, tea.Cmd)
  33. Previous() (tea.Model, tea.Cmd)
  34. Next() (tea.Model, tea.Cmd)
  35. SetInterruptKeyInDebounce(inDebounce bool)
  36. }
  37. type editorComponent struct {
  38. app *app.App
  39. width, height int
  40. textarea textarea.Model
  41. attachments []app.Attachment
  42. history []string
  43. historyIndex int
  44. currentMessage string
  45. spinner spinner.Model
  46. interruptKeyInDebounce bool
  47. }
  48. func (m *editorComponent) Init() tea.Cmd {
  49. return tea.Batch(m.textarea.Focus(), m.spinner.Tick, tea.EnableReportFocus)
  50. }
  51. func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
  52. var cmds []tea.Cmd
  53. var cmd tea.Cmd
  54. switch msg := msg.(type) {
  55. case spinner.TickMsg:
  56. m.spinner, cmd = m.spinner.Update(msg)
  57. return m, cmd
  58. case tea.KeyPressMsg:
  59. // Maximize editor responsiveness for printable characters
  60. if msg.Text != "" {
  61. m.textarea, cmd = m.textarea.Update(msg)
  62. cmds = append(cmds, cmd)
  63. return m, tea.Batch(cmds...)
  64. }
  65. case dialog.ThemeSelectedMsg:
  66. m.textarea = createTextArea(&m.textarea)
  67. m.spinner = createSpinner()
  68. return m, tea.Batch(m.spinner.Tick, m.textarea.Focus())
  69. case dialog.CompletionSelectedMsg:
  70. if msg.IsCommand {
  71. commandName := strings.TrimPrefix(msg.CompletionValue, "/")
  72. updated, cmd := m.Clear()
  73. m = updated.(*editorComponent)
  74. cmds = append(cmds, cmd)
  75. cmds = append(cmds, util.CmdHandler(commands.ExecuteCommandMsg(m.app.Commands[commands.CommandName(commandName)])))
  76. return m, tea.Batch(cmds...)
  77. } else {
  78. existingValue := m.textarea.Value()
  79. // Replace the current token (after last space)
  80. lastSpaceIndex := strings.LastIndex(existingValue, " ")
  81. if lastSpaceIndex == -1 {
  82. m.textarea.SetValue(msg.CompletionValue + " ")
  83. } else {
  84. modifiedValue := existingValue[:lastSpaceIndex+1] + msg.CompletionValue
  85. m.textarea.SetValue(modifiedValue + " ")
  86. }
  87. return m, nil
  88. }
  89. }
  90. m.spinner, cmd = m.spinner.Update(msg)
  91. cmds = append(cmds, cmd)
  92. m.textarea, cmd = m.textarea.Update(msg)
  93. cmds = append(cmds, cmd)
  94. return m, tea.Batch(cmds...)
  95. }
  96. func (m *editorComponent) Content() string {
  97. t := theme.CurrentTheme()
  98. base := styles.NewStyle().Foreground(t.Text()).Background(t.Background()).Render
  99. muted := styles.NewStyle().Foreground(t.TextMuted()).Background(t.Background()).Render
  100. promptStyle := styles.NewStyle().Foreground(t.Primary()).
  101. Padding(0, 0, 0, 1).
  102. Bold(true)
  103. prompt := promptStyle.Render(">")
  104. textarea := lipgloss.JoinHorizontal(
  105. lipgloss.Top,
  106. prompt,
  107. m.textarea.View(),
  108. )
  109. textarea = styles.NewStyle().
  110. Background(t.BackgroundElement()).
  111. Width(m.width).
  112. PaddingTop(1).
  113. PaddingBottom(1).
  114. BorderStyle(lipgloss.ThickBorder()).
  115. BorderForeground(t.Border()).
  116. BorderBackground(t.Background()).
  117. BorderLeft(true).
  118. BorderRight(true).
  119. Render(textarea)
  120. hint := base(m.getSubmitKeyText()) + muted(" send ")
  121. if m.app.IsBusy() {
  122. keyText := m.getInterruptKeyText()
  123. if m.interruptKeyInDebounce {
  124. hint = muted("working") + m.spinner.View() + muted(" ") + base(keyText+" again") + muted(" interrupt")
  125. } else {
  126. hint = muted("working") + m.spinner.View() + muted(" ") + base(keyText) + muted(" interrupt")
  127. }
  128. }
  129. model := ""
  130. if m.app.Model != nil {
  131. model = muted(m.app.Provider.Name) + base(" "+m.app.Model.Name)
  132. }
  133. space := m.width - 2 - lipgloss.Width(model) - lipgloss.Width(hint)
  134. spacer := styles.NewStyle().Background(t.Background()).Width(space).Render("")
  135. info := hint + spacer + model
  136. info = styles.NewStyle().Background(t.Background()).Padding(0, 1).Render(info)
  137. content := strings.Join([]string{"", textarea, info}, "\n")
  138. return content
  139. }
  140. func (m *editorComponent) View() string {
  141. if m.Lines() > 1 {
  142. t := theme.CurrentTheme()
  143. return lipgloss.Place(
  144. m.width,
  145. m.height,
  146. lipgloss.Center,
  147. lipgloss.Center,
  148. "",
  149. styles.WhitespaceStyle(t.Background()),
  150. )
  151. }
  152. return m.Content()
  153. }
  154. func (m *editorComponent) Focused() bool {
  155. return m.textarea.Focused()
  156. }
  157. func (m *editorComponent) Focus() (tea.Model, tea.Cmd) {
  158. return m, m.textarea.Focus()
  159. }
  160. func (m *editorComponent) Blur() {
  161. m.textarea.Blur()
  162. }
  163. func (m *editorComponent) GetSize() (width, height int) {
  164. return m.width, m.height
  165. }
  166. func (m *editorComponent) SetSize(width, height int) tea.Cmd {
  167. m.width = width
  168. m.height = height
  169. return nil
  170. }
  171. func (m *editorComponent) Lines() int {
  172. return m.textarea.LineCount()
  173. }
  174. func (m *editorComponent) Value() string {
  175. return m.textarea.Value()
  176. }
  177. func (m *editorComponent) Submit() (tea.Model, tea.Cmd) {
  178. value := strings.TrimSpace(m.Value())
  179. if value == "" {
  180. return m, nil
  181. }
  182. if len(value) > 0 && value[len(value)-1] == '\\' {
  183. // If the last character is a backslash, remove it and add a newline
  184. m.textarea.SetValue(value[:len(value)-1] + "\n")
  185. return m, nil
  186. }
  187. var cmds []tea.Cmd
  188. updated, cmd := m.Clear()
  189. m = updated.(*editorComponent)
  190. cmds = append(cmds, cmd)
  191. attachments := m.attachments
  192. // Save to history if not empty and not a duplicate of the last entry
  193. if value != "" {
  194. if len(m.history) == 0 || m.history[len(m.history)-1] != value {
  195. m.history = append(m.history, value)
  196. }
  197. m.historyIndex = len(m.history)
  198. m.currentMessage = ""
  199. }
  200. m.attachments = nil
  201. cmds = append(cmds, util.CmdHandler(app.SendMsg{Text: value, Attachments: attachments}))
  202. return m, tea.Batch(cmds...)
  203. }
  204. func (m *editorComponent) Clear() (tea.Model, tea.Cmd) {
  205. m.textarea.Reset()
  206. return m, nil
  207. }
  208. func (m *editorComponent) Paste() (tea.Model, tea.Cmd) {
  209. imageBytes, text, err := image.GetImageFromClipboard()
  210. if err != nil {
  211. slog.Error(err.Error())
  212. return m, nil
  213. }
  214. if len(imageBytes) != 0 {
  215. attachmentName := fmt.Sprintf("clipboard-image-%d", len(m.attachments))
  216. attachment := app.Attachment{FilePath: attachmentName, FileName: attachmentName, Content: imageBytes, MimeType: "image/png"}
  217. m.attachments = append(m.attachments, attachment)
  218. } else {
  219. m.textarea.SetValue(m.textarea.Value() + text)
  220. }
  221. return m, nil
  222. }
  223. func (m *editorComponent) Newline() (tea.Model, tea.Cmd) {
  224. m.textarea.Newline()
  225. return m, nil
  226. }
  227. func (m *editorComponent) Previous() (tea.Model, tea.Cmd) {
  228. currentLine := m.textarea.Line()
  229. // Only navigate history if we're at the first line
  230. if currentLine == 0 && len(m.history) > 0 {
  231. // Save current message if we're just starting to navigate
  232. if m.historyIndex == len(m.history) {
  233. m.currentMessage = m.textarea.Value()
  234. }
  235. // Go to previous message in history
  236. if m.historyIndex > 0 {
  237. m.historyIndex--
  238. m.textarea.SetValue(m.history[m.historyIndex])
  239. }
  240. return m, nil
  241. }
  242. return m, nil
  243. }
  244. func (m *editorComponent) Next() (tea.Model, tea.Cmd) {
  245. currentLine := m.textarea.Line()
  246. value := m.textarea.Value()
  247. lines := strings.Split(value, "\n")
  248. totalLines := len(lines)
  249. // Only navigate history if we're at the last line
  250. if currentLine == totalLines-1 {
  251. if m.historyIndex < len(m.history)-1 {
  252. // Go to next message in history
  253. m.historyIndex++
  254. m.textarea.SetValue(m.history[m.historyIndex])
  255. } else if m.historyIndex == len(m.history)-1 {
  256. // Return to the current message being composed
  257. m.historyIndex = len(m.history)
  258. m.textarea.SetValue(m.currentMessage)
  259. }
  260. return m, nil
  261. }
  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().Foreground(textMutedColor).Background(bgColor).Lipgloss()
  282. ta.Styles.Blurred.Text = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
  283. ta.Styles.Focused.Base = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
  284. ta.Styles.Focused.CursorLine = styles.NewStyle().Background(bgColor).Lipgloss()
  285. ta.Styles.Focused.Placeholder = styles.NewStyle().Foreground(textMutedColor).Background(bgColor).Lipgloss()
  286. ta.Styles.Focused.Text = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
  287. ta.Styles.Cursor.Color = t.Primary()
  288. ta.Prompt = " "
  289. ta.ShowLineNumbers = false
  290. ta.CharLimit = -1
  291. ta.SetWidth(layout.Current.Container.Width - 6)
  292. if existing != nil {
  293. ta.SetValue(existing.Value())
  294. // ta.SetWidth(existing.Width())
  295. ta.SetHeight(existing.Height())
  296. }
  297. // ta.Focus()
  298. return ta
  299. }
  300. func createSpinner() spinner.Model {
  301. t := theme.CurrentTheme()
  302. return spinner.New(
  303. spinner.WithSpinner(spinner.Ellipsis),
  304. spinner.WithStyle(
  305. styles.NewStyle().
  306. Background(t.Background()).
  307. Foreground(t.TextMuted()).
  308. Width(3).
  309. Lipgloss(),
  310. ),
  311. )
  312. }
  313. func NewEditorComponent(app *app.App) EditorComponent {
  314. s := createSpinner()
  315. ta := createTextArea(nil)
  316. return &editorComponent{
  317. app: app,
  318. textarea: ta,
  319. history: []string{},
  320. historyIndex: 0,
  321. currentMessage: "",
  322. spinner: s,
  323. interruptKeyInDebounce: false,
  324. }
  325. }