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