editor.go 9.9 KB

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