editor.go 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562
  1. package editor
  2. import (
  3. "context"
  4. "fmt"
  5. "math/rand"
  6. "net/http"
  7. "os"
  8. "os/exec"
  9. "path/filepath"
  10. "runtime"
  11. "slices"
  12. "strings"
  13. "unicode"
  14. "github.com/charmbracelet/bubbles/v2/key"
  15. "github.com/charmbracelet/bubbles/v2/textarea"
  16. tea "github.com/charmbracelet/bubbletea/v2"
  17. "github.com/charmbracelet/crush/internal/app"
  18. "github.com/charmbracelet/crush/internal/fsext"
  19. "github.com/charmbracelet/crush/internal/message"
  20. "github.com/charmbracelet/crush/internal/session"
  21. "github.com/charmbracelet/crush/internal/tui/components/chat"
  22. "github.com/charmbracelet/crush/internal/tui/components/completions"
  23. "github.com/charmbracelet/crush/internal/tui/components/core/layout"
  24. "github.com/charmbracelet/crush/internal/tui/components/dialogs"
  25. "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
  26. "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
  27. "github.com/charmbracelet/crush/internal/tui/components/dialogs/quit"
  28. "github.com/charmbracelet/crush/internal/tui/styles"
  29. "github.com/charmbracelet/crush/internal/tui/util"
  30. "github.com/charmbracelet/lipgloss/v2"
  31. )
  32. type Editor interface {
  33. util.Model
  34. layout.Sizeable
  35. layout.Focusable
  36. layout.Help
  37. layout.Positional
  38. SetSession(session session.Session) tea.Cmd
  39. IsCompletionsOpen() bool
  40. HasAttachments() bool
  41. Cursor() *tea.Cursor
  42. }
  43. type FileCompletionItem struct {
  44. Path string // The file path
  45. }
  46. type editorCmp struct {
  47. width int
  48. height int
  49. x, y int
  50. app *app.App
  51. session session.Session
  52. textarea textarea.Model
  53. attachments []message.Attachment
  54. deleteMode bool
  55. readyPlaceholder string
  56. workingPlaceholder string
  57. keyMap EditorKeyMap
  58. // File path completions
  59. currentQuery string
  60. completionsStartIndex int
  61. isCompletionsOpen bool
  62. }
  63. var DeleteKeyMaps = DeleteAttachmentKeyMaps{
  64. AttachmentDeleteMode: key.NewBinding(
  65. key.WithKeys("ctrl+r"),
  66. key.WithHelp("ctrl+r+{i}", "delete attachment at index i"),
  67. ),
  68. Escape: key.NewBinding(
  69. key.WithKeys("esc"),
  70. key.WithHelp("esc", "cancel delete mode"),
  71. ),
  72. DeleteAllAttachments: key.NewBinding(
  73. key.WithKeys("r"),
  74. key.WithHelp("ctrl+r+r", "delete all attachments"),
  75. ),
  76. }
  77. const (
  78. maxAttachments = 5
  79. )
  80. type OpenEditorMsg struct {
  81. Text string
  82. }
  83. func (m *editorCmp) openEditor(value string) tea.Cmd {
  84. editor := os.Getenv("EDITOR")
  85. if editor == "" {
  86. // Use platform-appropriate default editor
  87. if runtime.GOOS == "windows" {
  88. editor = "notepad"
  89. } else {
  90. editor = "nvim"
  91. }
  92. }
  93. tmpfile, err := os.CreateTemp("", "msg_*.md")
  94. if err != nil {
  95. return util.ReportError(err)
  96. }
  97. defer tmpfile.Close() //nolint:errcheck
  98. if _, err := tmpfile.WriteString(value); err != nil {
  99. return util.ReportError(err)
  100. }
  101. c := exec.CommandContext(context.TODO(), editor, tmpfile.Name())
  102. c.Stdin = os.Stdin
  103. c.Stdout = os.Stdout
  104. c.Stderr = os.Stderr
  105. return tea.ExecProcess(c, func(err error) tea.Msg {
  106. if err != nil {
  107. return util.ReportError(err)
  108. }
  109. content, err := os.ReadFile(tmpfile.Name())
  110. if err != nil {
  111. return util.ReportError(err)
  112. }
  113. if len(content) == 0 {
  114. return util.ReportWarn("Message is empty")
  115. }
  116. os.Remove(tmpfile.Name())
  117. return OpenEditorMsg{
  118. Text: strings.TrimSpace(string(content)),
  119. }
  120. })
  121. }
  122. func (m *editorCmp) Init() tea.Cmd {
  123. return nil
  124. }
  125. func (m *editorCmp) send() tea.Cmd {
  126. if m.app.CoderAgent == nil {
  127. return util.ReportError(fmt.Errorf("coder agent is not initialized"))
  128. }
  129. if m.app.CoderAgent.IsSessionBusy(m.session.ID) {
  130. return util.ReportWarn("Agent is working, please wait...")
  131. }
  132. value := m.textarea.Value()
  133. value = strings.TrimSpace(value)
  134. switch value {
  135. case "exit", "quit":
  136. m.textarea.Reset()
  137. return util.CmdHandler(dialogs.OpenDialogMsg{Model: quit.NewQuitDialog()})
  138. }
  139. m.textarea.Reset()
  140. attachments := m.attachments
  141. m.attachments = nil
  142. if value == "" {
  143. return nil
  144. }
  145. // Change the placeholder when sending a new message.
  146. m.randomizePlaceholders()
  147. return tea.Batch(
  148. util.CmdHandler(chat.SendMsg{
  149. Text: value,
  150. Attachments: attachments,
  151. }),
  152. )
  153. }
  154. func (m *editorCmp) repositionCompletions() tea.Msg {
  155. x, y := m.completionsPosition()
  156. return completions.RepositionCompletionsMsg{X: x, Y: y}
  157. }
  158. func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
  159. var cmd tea.Cmd
  160. var cmds []tea.Cmd
  161. switch msg := msg.(type) {
  162. case tea.WindowSizeMsg:
  163. return m, m.repositionCompletions
  164. case filepicker.FilePickedMsg:
  165. if len(m.attachments) >= maxAttachments {
  166. return m, util.ReportError(fmt.Errorf("cannot add more than %d images", maxAttachments))
  167. }
  168. m.attachments = append(m.attachments, msg.Attachment)
  169. return m, nil
  170. case completions.CompletionsOpenedMsg:
  171. m.isCompletionsOpen = true
  172. case completions.CompletionsClosedMsg:
  173. m.isCompletionsOpen = false
  174. m.currentQuery = ""
  175. m.completionsStartIndex = 0
  176. case completions.SelectCompletionMsg:
  177. if !m.isCompletionsOpen {
  178. return m, nil
  179. }
  180. if item, ok := msg.Value.(FileCompletionItem); ok {
  181. word := m.textarea.Word()
  182. // If the selected item is a file, insert its path into the textarea
  183. value := m.textarea.Value()
  184. value = value[:m.completionsStartIndex] + // Remove the current query
  185. item.Path + // Insert the file path
  186. value[m.completionsStartIndex+len(word):] // Append the rest of the value
  187. // XXX: This will always move the cursor to the end of the textarea.
  188. m.textarea.SetValue(value)
  189. m.textarea.MoveToEnd()
  190. if !msg.Insert {
  191. m.isCompletionsOpen = false
  192. m.currentQuery = ""
  193. m.completionsStartIndex = 0
  194. }
  195. }
  196. case commands.OpenExternalEditorMsg:
  197. if m.app.CoderAgent.IsSessionBusy(m.session.ID) {
  198. return m, util.ReportWarn("Agent is working, please wait...")
  199. }
  200. return m, m.openEditor(m.textarea.Value())
  201. case OpenEditorMsg:
  202. m.textarea.SetValue(msg.Text)
  203. m.textarea.MoveToEnd()
  204. case tea.PasteMsg:
  205. path := strings.ReplaceAll(string(msg), "\\ ", " ")
  206. // try to get an image
  207. path, err := filepath.Abs(path)
  208. if err != nil {
  209. m.textarea, cmd = m.textarea.Update(msg)
  210. return m, cmd
  211. }
  212. isAllowedType := false
  213. for _, ext := range filepicker.AllowedTypes {
  214. if strings.HasSuffix(path, ext) {
  215. isAllowedType = true
  216. break
  217. }
  218. }
  219. if !isAllowedType {
  220. m.textarea, cmd = m.textarea.Update(msg)
  221. return m, cmd
  222. }
  223. tooBig, _ := filepicker.IsFileTooBig(path, filepicker.MaxAttachmentSize)
  224. if tooBig {
  225. m.textarea, cmd = m.textarea.Update(msg)
  226. return m, cmd
  227. }
  228. content, err := os.ReadFile(path)
  229. if err != nil {
  230. m.textarea, cmd = m.textarea.Update(msg)
  231. return m, cmd
  232. }
  233. mimeBufferSize := min(512, len(content))
  234. mimeType := http.DetectContentType(content[:mimeBufferSize])
  235. fileName := filepath.Base(path)
  236. attachment := message.Attachment{FilePath: path, FileName: fileName, MimeType: mimeType, Content: content}
  237. return m, util.CmdHandler(filepicker.FilePickedMsg{
  238. Attachment: attachment,
  239. })
  240. case tea.KeyPressMsg:
  241. cur := m.textarea.Cursor()
  242. curIdx := m.textarea.Width()*cur.Y + cur.X
  243. switch {
  244. // Completions
  245. case msg.String() == "/" && !m.isCompletionsOpen &&
  246. // only show if beginning of prompt, or if previous char is a space or newline:
  247. (len(m.textarea.Value()) == 0 || unicode.IsSpace(rune(m.textarea.Value()[len(m.textarea.Value())-1]))):
  248. m.isCompletionsOpen = true
  249. m.currentQuery = ""
  250. m.completionsStartIndex = curIdx
  251. cmds = append(cmds, m.startCompletions)
  252. case m.isCompletionsOpen && curIdx <= m.completionsStartIndex:
  253. cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
  254. }
  255. if key.Matches(msg, DeleteKeyMaps.AttachmentDeleteMode) {
  256. m.deleteMode = true
  257. return m, nil
  258. }
  259. if key.Matches(msg, DeleteKeyMaps.DeleteAllAttachments) && m.deleteMode {
  260. m.deleteMode = false
  261. m.attachments = nil
  262. return m, nil
  263. }
  264. rune := msg.Code
  265. if m.deleteMode && unicode.IsDigit(rune) {
  266. num := int(rune - '0')
  267. m.deleteMode = false
  268. if num < 10 && len(m.attachments) > num {
  269. if num == 0 {
  270. m.attachments = m.attachments[num+1:]
  271. } else {
  272. m.attachments = slices.Delete(m.attachments, num, num+1)
  273. }
  274. return m, nil
  275. }
  276. }
  277. if key.Matches(msg, m.keyMap.OpenEditor) {
  278. if m.app.CoderAgent.IsSessionBusy(m.session.ID) {
  279. return m, util.ReportWarn("Agent is working, please wait...")
  280. }
  281. return m, m.openEditor(m.textarea.Value())
  282. }
  283. if key.Matches(msg, DeleteKeyMaps.Escape) {
  284. m.deleteMode = false
  285. return m, nil
  286. }
  287. if key.Matches(msg, m.keyMap.Newline) {
  288. m.textarea.InsertRune('\n')
  289. cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
  290. }
  291. // Handle Enter key
  292. if m.textarea.Focused() && key.Matches(msg, m.keyMap.SendMessage) {
  293. value := m.textarea.Value()
  294. if len(value) > 0 && value[len(value)-1] == '\\' {
  295. // If the last character is a backslash, remove it and add a newline
  296. m.textarea.SetValue(value[:len(value)-1])
  297. } else {
  298. // Otherwise, send the message
  299. return m, m.send()
  300. }
  301. }
  302. }
  303. m.textarea, cmd = m.textarea.Update(msg)
  304. cmds = append(cmds, cmd)
  305. if m.textarea.Focused() {
  306. kp, ok := msg.(tea.KeyPressMsg)
  307. if ok {
  308. if kp.String() == "space" || m.textarea.Value() == "" {
  309. m.isCompletionsOpen = false
  310. m.currentQuery = ""
  311. m.completionsStartIndex = 0
  312. cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
  313. } else {
  314. word := m.textarea.Word()
  315. if strings.HasPrefix(word, "/") {
  316. // XXX: wont' work if editing in the middle of the field.
  317. m.completionsStartIndex = strings.LastIndex(m.textarea.Value(), word)
  318. m.currentQuery = word[1:]
  319. x, y := m.completionsPosition()
  320. x -= len(m.currentQuery)
  321. m.isCompletionsOpen = true
  322. cmds = append(cmds,
  323. util.CmdHandler(completions.FilterCompletionsMsg{
  324. Query: m.currentQuery,
  325. Reopen: m.isCompletionsOpen,
  326. X: x,
  327. Y: y,
  328. }),
  329. )
  330. } else if m.isCompletionsOpen {
  331. m.isCompletionsOpen = false
  332. m.currentQuery = ""
  333. m.completionsStartIndex = 0
  334. cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
  335. }
  336. }
  337. }
  338. }
  339. return m, tea.Batch(cmds...)
  340. }
  341. func (m *editorCmp) completionsPosition() (int, int) {
  342. cur := m.textarea.Cursor()
  343. if cur == nil {
  344. return m.x, m.y + 1 // adjust for padding
  345. }
  346. x := cur.X + m.x
  347. y := cur.Y + m.y + 1 // adjust for padding
  348. return x, y
  349. }
  350. func (m *editorCmp) Cursor() *tea.Cursor {
  351. cursor := m.textarea.Cursor()
  352. if cursor != nil {
  353. cursor.X = cursor.X + m.x + 1
  354. cursor.Y = cursor.Y + m.y + 1 // adjust for padding
  355. }
  356. return cursor
  357. }
  358. var readyPlaceholders = [...]string{
  359. "Ready!",
  360. "Ready...",
  361. "Ready?",
  362. "Ready for instructions",
  363. }
  364. var workingPlaceholders = [...]string{
  365. "Working!",
  366. "Working...",
  367. "Brrrrr...",
  368. "Prrrrrrrr...",
  369. "Processing...",
  370. "Thinking...",
  371. }
  372. func (m *editorCmp) randomizePlaceholders() {
  373. m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))]
  374. m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))]
  375. }
  376. func (m *editorCmp) View() string {
  377. t := styles.CurrentTheme()
  378. // Update placeholder
  379. if m.app.CoderAgent != nil && m.app.CoderAgent.IsBusy() {
  380. m.textarea.Placeholder = m.workingPlaceholder
  381. } else {
  382. m.textarea.Placeholder = m.readyPlaceholder
  383. }
  384. if len(m.attachments) == 0 {
  385. content := t.S().Base.Padding(1).Render(
  386. m.textarea.View(),
  387. )
  388. return content
  389. }
  390. content := t.S().Base.Padding(0, 1, 1, 1).Render(
  391. lipgloss.JoinVertical(lipgloss.Top,
  392. m.attachmentsContent(),
  393. m.textarea.View(),
  394. ),
  395. )
  396. return content
  397. }
  398. func (m *editorCmp) SetSize(width, height int) tea.Cmd {
  399. m.width = width
  400. m.height = height
  401. m.textarea.SetWidth(width - 2) // adjust for padding
  402. m.textarea.SetHeight(height - 2) // adjust for padding
  403. return nil
  404. }
  405. func (m *editorCmp) GetSize() (int, int) {
  406. return m.textarea.Width(), m.textarea.Height()
  407. }
  408. func (m *editorCmp) attachmentsContent() string {
  409. var styledAttachments []string
  410. t := styles.CurrentTheme()
  411. attachmentStyles := t.S().Base.
  412. MarginLeft(1).
  413. Background(t.FgMuted).
  414. Foreground(t.FgBase)
  415. for i, attachment := range m.attachments {
  416. var filename string
  417. if len(attachment.FileName) > 10 {
  418. filename = fmt.Sprintf(" %s %s...", styles.DocumentIcon, attachment.FileName[0:7])
  419. } else {
  420. filename = fmt.Sprintf(" %s %s", styles.DocumentIcon, attachment.FileName)
  421. }
  422. if m.deleteMode {
  423. filename = fmt.Sprintf("%d%s", i, filename)
  424. }
  425. styledAttachments = append(styledAttachments, attachmentStyles.Render(filename))
  426. }
  427. content := lipgloss.JoinHorizontal(lipgloss.Left, styledAttachments...)
  428. return content
  429. }
  430. func (m *editorCmp) SetPosition(x, y int) tea.Cmd {
  431. m.x = x
  432. m.y = y
  433. return nil
  434. }
  435. func (m *editorCmp) startCompletions() tea.Msg {
  436. files, _, _ := fsext.ListDirectory(".", []string{}, 0)
  437. completionItems := make([]completions.Completion, 0, len(files))
  438. for _, file := range files {
  439. file = strings.TrimPrefix(file, "./")
  440. completionItems = append(completionItems, completions.Completion{
  441. Title: file,
  442. Value: FileCompletionItem{
  443. Path: file,
  444. },
  445. })
  446. }
  447. x, y := m.completionsPosition()
  448. return completions.OpenCompletionsMsg{
  449. Completions: completionItems,
  450. X: x,
  451. Y: y,
  452. }
  453. }
  454. // Blur implements Container.
  455. func (c *editorCmp) Blur() tea.Cmd {
  456. c.textarea.Blur()
  457. return nil
  458. }
  459. // Focus implements Container.
  460. func (c *editorCmp) Focus() tea.Cmd {
  461. return c.textarea.Focus()
  462. }
  463. // IsFocused implements Container.
  464. func (c *editorCmp) IsFocused() bool {
  465. return c.textarea.Focused()
  466. }
  467. // Bindings implements Container.
  468. func (c *editorCmp) Bindings() []key.Binding {
  469. return c.keyMap.KeyBindings()
  470. }
  471. // TODO: most likely we do not need to have the session here
  472. // we need to move some functionality to the page level
  473. func (c *editorCmp) SetSession(session session.Session) tea.Cmd {
  474. c.session = session
  475. return nil
  476. }
  477. func (c *editorCmp) IsCompletionsOpen() bool {
  478. return c.isCompletionsOpen
  479. }
  480. func (c *editorCmp) HasAttachments() bool {
  481. return len(c.attachments) > 0
  482. }
  483. func New(app *app.App) Editor {
  484. t := styles.CurrentTheme()
  485. ta := textarea.New()
  486. ta.SetStyles(t.S().TextArea)
  487. ta.SetPromptFunc(4, func(info textarea.PromptInfo) string {
  488. if info.LineNumber == 0 {
  489. return " > "
  490. }
  491. if info.Focused {
  492. return t.S().Base.Foreground(t.GreenDark).Render("::: ")
  493. } else {
  494. return t.S().Muted.Render("::: ")
  495. }
  496. })
  497. ta.ShowLineNumbers = false
  498. ta.CharLimit = -1
  499. ta.SetVirtualCursor(false)
  500. ta.Focus()
  501. e := &editorCmp{
  502. // TODO: remove the app instance from here
  503. app: app,
  504. textarea: ta,
  505. keyMap: DefaultEditorKeyMap(),
  506. }
  507. e.randomizePlaceholders()
  508. e.textarea.Placeholder = e.readyPlaceholder
  509. return e
  510. }