editor.go 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369
  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. layout.Sizeable
  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. return ""
  143. }
  144. return m.Content()
  145. }
  146. func (m *editorComponent) Focused() bool {
  147. return m.textarea.Focused()
  148. }
  149. func (m *editorComponent) Focus() (tea.Model, tea.Cmd) {
  150. return m, m.textarea.Focus()
  151. }
  152. func (m *editorComponent) Blur() {
  153. m.textarea.Blur()
  154. }
  155. func (m *editorComponent) GetSize() (width, height int) {
  156. return m.width, m.height
  157. }
  158. func (m *editorComponent) SetSize(width, height int) tea.Cmd {
  159. m.width = width
  160. m.height = height
  161. return nil
  162. }
  163. func (m *editorComponent) Lines() int {
  164. return m.textarea.LineCount()
  165. }
  166. func (m *editorComponent) Value() string {
  167. return m.textarea.Value()
  168. }
  169. func (m *editorComponent) Submit() (tea.Model, tea.Cmd) {
  170. value := strings.TrimSpace(m.Value())
  171. if value == "" {
  172. return m, nil
  173. }
  174. if len(value) > 0 && value[len(value)-1] == '\\' {
  175. // If the last character is a backslash, remove it and add a newline
  176. m.textarea.SetValue(value[:len(value)-1] + "\n")
  177. return m, nil
  178. }
  179. var cmds []tea.Cmd
  180. updated, cmd := m.Clear()
  181. m = updated.(*editorComponent)
  182. cmds = append(cmds, cmd)
  183. attachments := m.attachments
  184. // Save to history if not empty and not a duplicate of the last entry
  185. if value != "" {
  186. if len(m.history) == 0 || m.history[len(m.history)-1] != value {
  187. m.history = append(m.history, value)
  188. }
  189. m.historyIndex = len(m.history)
  190. m.currentMessage = ""
  191. }
  192. m.attachments = nil
  193. cmds = append(cmds, util.CmdHandler(app.SendMsg{Text: value, Attachments: attachments}))
  194. return m, tea.Batch(cmds...)
  195. }
  196. func (m *editorComponent) Clear() (tea.Model, tea.Cmd) {
  197. m.textarea.Reset()
  198. return m, nil
  199. }
  200. func (m *editorComponent) Paste() (tea.Model, tea.Cmd) {
  201. imageBytes, text, err := image.GetImageFromClipboard()
  202. if err != nil {
  203. slog.Error(err.Error())
  204. return m, nil
  205. }
  206. if len(imageBytes) != 0 {
  207. attachmentName := fmt.Sprintf("clipboard-image-%d", len(m.attachments))
  208. attachment := app.Attachment{FilePath: attachmentName, FileName: attachmentName, Content: imageBytes, MimeType: "image/png"}
  209. m.attachments = append(m.attachments, attachment)
  210. } else {
  211. m.textarea.SetValue(m.textarea.Value() + text)
  212. }
  213. return m, nil
  214. }
  215. func (m *editorComponent) Newline() (tea.Model, tea.Cmd) {
  216. m.textarea.Newline()
  217. return m, nil
  218. }
  219. func (m *editorComponent) Previous() (tea.Model, tea.Cmd) {
  220. currentLine := m.textarea.Line()
  221. // Only navigate history if we're at the first line
  222. if currentLine == 0 && len(m.history) > 0 {
  223. // Save current message if we're just starting to navigate
  224. if m.historyIndex == len(m.history) {
  225. m.currentMessage = m.textarea.Value()
  226. }
  227. // Go to previous message in history
  228. if m.historyIndex > 0 {
  229. m.historyIndex--
  230. m.textarea.SetValue(m.history[m.historyIndex])
  231. }
  232. return m, nil
  233. }
  234. return m, nil
  235. }
  236. func (m *editorComponent) Next() (tea.Model, tea.Cmd) {
  237. currentLine := m.textarea.Line()
  238. value := m.textarea.Value()
  239. lines := strings.Split(value, "\n")
  240. totalLines := len(lines)
  241. // Only navigate history if we're at the last line
  242. if currentLine == totalLines-1 {
  243. if m.historyIndex < len(m.history)-1 {
  244. // Go to next message in history
  245. m.historyIndex++
  246. m.textarea.SetValue(m.history[m.historyIndex])
  247. } else if m.historyIndex == len(m.history)-1 {
  248. // Return to the current message being composed
  249. m.historyIndex = len(m.history)
  250. m.textarea.SetValue(m.currentMessage)
  251. }
  252. return m, nil
  253. }
  254. return m, nil
  255. }
  256. func (m *editorComponent) SetInterruptKeyInDebounce(inDebounce bool) {
  257. m.interruptKeyInDebounce = inDebounce
  258. }
  259. func (m *editorComponent) getInterruptKeyText() string {
  260. return m.app.Commands[commands.SessionInterruptCommand].Keys()[0]
  261. }
  262. func (m *editorComponent) getSubmitKeyText() string {
  263. return m.app.Commands[commands.InputSubmitCommand].Keys()[0]
  264. }
  265. func createTextArea(existing *textarea.Model) textarea.Model {
  266. t := theme.CurrentTheme()
  267. bgColor := t.BackgroundElement()
  268. textColor := t.Text()
  269. textMutedColor := t.TextMuted()
  270. ta := textarea.New()
  271. ta.Styles.Blurred.Base = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
  272. ta.Styles.Blurred.CursorLine = styles.NewStyle().Background(bgColor).Lipgloss()
  273. ta.Styles.Blurred.Placeholder = styles.NewStyle().Foreground(textMutedColor).Background(bgColor).Lipgloss()
  274. ta.Styles.Blurred.Text = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
  275. ta.Styles.Focused.Base = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
  276. ta.Styles.Focused.CursorLine = styles.NewStyle().Background(bgColor).Lipgloss()
  277. ta.Styles.Focused.Placeholder = styles.NewStyle().Foreground(textMutedColor).Background(bgColor).Lipgloss()
  278. ta.Styles.Focused.Text = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
  279. ta.Styles.Cursor.Color = t.Primary()
  280. ta.Prompt = " "
  281. ta.ShowLineNumbers = false
  282. ta.CharLimit = -1
  283. ta.SetWidth(layout.Current.Container.Width - 6)
  284. if existing != nil {
  285. ta.SetValue(existing.Value())
  286. // ta.SetWidth(existing.Width())
  287. ta.SetHeight(existing.Height())
  288. }
  289. // ta.Focus()
  290. return ta
  291. }
  292. func createSpinner() spinner.Model {
  293. t := theme.CurrentTheme()
  294. return spinner.New(
  295. spinner.WithSpinner(spinner.Ellipsis),
  296. spinner.WithStyle(
  297. styles.NewStyle().
  298. Background(t.Background()).
  299. Foreground(t.TextMuted()).
  300. Width(3).
  301. Lipgloss(),
  302. ),
  303. )
  304. }
  305. func NewEditorComponent(app *app.App) EditorComponent {
  306. s := createSpinner()
  307. ta := createTextArea(nil)
  308. return &editorComponent{
  309. app: app,
  310. textarea: ta,
  311. history: []string{},
  312. historyIndex: 0,
  313. currentMessage: "",
  314. spinner: s,
  315. interruptKeyInDebounce: false,
  316. }
  317. }