editor.go 9.5 KB

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