editor.go 9.3 KB

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