editor.go 15 KB

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