1package editor
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "math/rand"
8 "net/http"
9 "os"
10 "path/filepath"
11 "runtime"
12 "slices"
13 "strings"
14 "unicode"
15
16 "charm.land/bubbles/v2/key"
17 "charm.land/bubbles/v2/textarea"
18 tea "charm.land/bubbletea/v2"
19 "charm.land/lipgloss/v2"
20 "github.com/charmbracelet/crush/internal/app"
21 "github.com/charmbracelet/crush/internal/fsext"
22 "github.com/charmbracelet/crush/internal/message"
23 "github.com/charmbracelet/crush/internal/session"
24 "github.com/charmbracelet/crush/internal/tui/components/chat"
25 "github.com/charmbracelet/crush/internal/tui/components/completions"
26 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
27 "github.com/charmbracelet/crush/internal/tui/components/dialogs"
28 "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
29 "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
30 "github.com/charmbracelet/crush/internal/tui/components/dialogs/quit"
31 "github.com/charmbracelet/crush/internal/tui/styles"
32 "github.com/charmbracelet/crush/internal/tui/util"
33 "github.com/charmbracelet/x/ansi"
34)
35
36type Editor interface {
37 util.Model
38 layout.Sizeable
39 layout.Focusable
40 layout.Help
41 layout.Positional
42
43 SetSession(session session.Session) tea.Cmd
44 IsCompletionsOpen() bool
45 HasAttachments() bool
46 IsEmpty() bool
47 Cursor() *tea.Cursor
48}
49
50type FileCompletionItem struct {
51 Path string // The file path
52}
53
54type editorCmp struct {
55 width int
56 height int
57 x, y int
58 app *app.App
59 session session.Session
60 textarea textarea.Model
61 attachments []message.Attachment
62 deleteMode bool
63 readyPlaceholder string
64 workingPlaceholder string
65
66 keyMap EditorKeyMap
67
68 // File path completions
69 currentQuery string
70 completionsStartIndex int
71 isCompletionsOpen bool
72}
73
74var DeleteKeyMaps = DeleteAttachmentKeyMaps{
75 AttachmentDeleteMode: key.NewBinding(
76 key.WithKeys("ctrl+r"),
77 key.WithHelp("ctrl+r+{i}", "delete attachment at index i"),
78 ),
79 Escape: key.NewBinding(
80 key.WithKeys("esc", "alt+esc"),
81 key.WithHelp("esc", "cancel delete mode"),
82 ),
83 DeleteAllAttachments: key.NewBinding(
84 key.WithKeys("r"),
85 key.WithHelp("ctrl+r+r", "delete all attachments"),
86 ),
87}
88
89const maxFileResults = 25
90
91type OpenEditorMsg struct {
92 Text string
93}
94
95func (m *editorCmp) openEditor(value string) tea.Cmd {
96 editor := os.Getenv("EDITOR")
97 if editor == "" {
98 // Use platform-appropriate default editor
99 if runtime.GOOS == "windows" {
100 editor = "notepad"
101 } else {
102 editor = "nvim"
103 }
104 }
105
106 tmpfile, err := os.CreateTemp("", "msg_*.md")
107 if err != nil {
108 return util.ReportError(err)
109 }
110 defer tmpfile.Close() //nolint:errcheck
111 if _, err := tmpfile.WriteString(value); err != nil {
112 return util.ReportError(err)
113 }
114 cmdStr := editor + " " + tmpfile.Name()
115 return util.ExecShell(context.TODO(), cmdStr, func(err error) tea.Msg {
116 if err != nil {
117 return util.ReportError(err)
118 }
119 content, err := os.ReadFile(tmpfile.Name())
120 if err != nil {
121 return util.ReportError(err)
122 }
123 if len(content) == 0 {
124 return util.ReportWarn("Message is empty")
125 }
126 os.Remove(tmpfile.Name())
127 return OpenEditorMsg{
128 Text: strings.TrimSpace(string(content)),
129 }
130 })
131}
132
133func (m *editorCmp) Init() tea.Cmd {
134 return nil
135}
136
137func (m *editorCmp) send() tea.Cmd {
138 value := m.textarea.Value()
139 value = strings.TrimSpace(value)
140
141 switch value {
142 case "exit", "quit":
143 m.textarea.Reset()
144 return util.CmdHandler(dialogs.OpenDialogMsg{Model: quit.NewQuitDialog()})
145 }
146
147 attachments := m.attachments
148
149 if value == "" {
150 return nil
151 }
152
153 m.textarea.Reset()
154 m.attachments = nil
155 // Change the placeholder when sending a new message.
156 m.randomizePlaceholders()
157
158 return tea.Batch(
159 util.CmdHandler(chat.SendMsg{
160 Text: value,
161 Attachments: attachments,
162 }),
163 )
164}
165
166func (m *editorCmp) repositionCompletions() tea.Msg {
167 x, y := m.completionsPosition()
168 return completions.RepositionCompletionsMsg{X: x, Y: y}
169}
170
171func (m *editorCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
172 var cmd tea.Cmd
173 var cmds []tea.Cmd
174 switch msg := msg.(type) {
175 case tea.WindowSizeMsg:
176 return m, m.repositionCompletions
177 case filepicker.FilePickedMsg:
178 m.attachments = append(m.attachments, msg.Attachment)
179 return m, nil
180 case completions.CompletionsOpenedMsg:
181 m.isCompletionsOpen = true
182 case completions.CompletionsClosedMsg:
183 m.isCompletionsOpen = false
184 m.currentQuery = ""
185 m.completionsStartIndex = 0
186 case completions.SelectCompletionMsg:
187 if !m.isCompletionsOpen {
188 return m, nil
189 }
190 if item, ok := msg.Value.(FileCompletionItem); ok {
191 word := m.textarea.Word()
192 // If the selected item is a file, insert its path into the textarea
193 value := m.textarea.Value()
194 value = value[:m.completionsStartIndex] + // Remove the current query
195 item.Path + // Insert the file path
196 value[m.completionsStartIndex+len(word):] // Append the rest of the value
197 // XXX: This will always move the cursor to the end of the textarea.
198 m.textarea.SetValue(value)
199 m.textarea.MoveToEnd()
200 if !msg.Insert {
201 m.isCompletionsOpen = false
202 m.currentQuery = ""
203 m.completionsStartIndex = 0
204 }
205 content, err := os.ReadFile(item.Path)
206 if err != nil {
207 // if it fails, let the LLM handle it later.
208 return m, nil
209 }
210 m.attachments = append(m.attachments, message.Attachment{
211 FilePath: item.Path,
212 FileName: filepath.Base(item.Path),
213 MimeType: mimeOf(content),
214 Content: content,
215 })
216 }
217
218 case commands.OpenExternalEditorMsg:
219 if m.app.AgentCoordinator.IsSessionBusy(m.session.ID) {
220 return m, util.ReportWarn("Agent is working, please wait...")
221 }
222 return m, m.openEditor(m.textarea.Value())
223 case OpenEditorMsg:
224 m.textarea.SetValue(msg.Text)
225 m.textarea.MoveToEnd()
226 case tea.PasteMsg:
227 content, path, err := pasteToFile(msg)
228 if errors.Is(err, errNotAFile) {
229 m.textarea, cmd = m.textarea.Update(msg)
230 return m, cmd
231 }
232 if err != nil {
233 return m, util.ReportError(err)
234 }
235
236 if len(content) > maxAttachmentSize {
237 return m, util.ReportWarn("File is too big (>5mb)")
238 }
239
240 mimeType := mimeOf(content)
241 attachment := message.Attachment{
242 FilePath: path,
243 FileName: filepath.Base(path),
244 MimeType: mimeType,
245 Content: content,
246 }
247 if !attachment.IsText() && !attachment.IsImage() {
248 return m, util.ReportWarn("Invalid file content type: " + mimeType)
249 }
250 return m, util.CmdHandler(filepicker.FilePickedMsg{
251 Attachment: attachment,
252 })
253
254 case commands.ToggleYoloModeMsg:
255 m.setEditorPrompt()
256 return m, nil
257 case tea.KeyPressMsg:
258 cur := m.textarea.Cursor()
259 curIdx := m.textarea.Width()*cur.Y + cur.X
260 switch {
261 // Open command palette when "/" is pressed on empty prompt
262 case msg.String() == "/" && m.IsEmpty():
263 return m, util.CmdHandler(dialogs.OpenDialogMsg{
264 Model: commands.NewCommandDialog(m.session.ID),
265 })
266 // Completions
267 case msg.String() == "@" && !m.isCompletionsOpen &&
268 // only show if beginning of prompt, or if previous char is a space or newline:
269 (len(m.textarea.Value()) == 0 || unicode.IsSpace(rune(m.textarea.Value()[len(m.textarea.Value())-1]))):
270 m.isCompletionsOpen = true
271 m.currentQuery = ""
272 m.completionsStartIndex = curIdx
273 cmds = append(cmds, m.startCompletions)
274 case m.isCompletionsOpen && curIdx <= m.completionsStartIndex:
275 cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
276 }
277 if key.Matches(msg, DeleteKeyMaps.AttachmentDeleteMode) {
278 m.deleteMode = true
279 return m, nil
280 }
281 if key.Matches(msg, DeleteKeyMaps.DeleteAllAttachments) && m.deleteMode {
282 m.deleteMode = false
283 m.attachments = nil
284 return m, nil
285 }
286 rune := msg.Code
287 if m.deleteMode && unicode.IsDigit(rune) {
288 num := int(rune - '0')
289 m.deleteMode = false
290 if num < 10 && len(m.attachments) > num {
291 if num == 0 {
292 m.attachments = m.attachments[num+1:]
293 } else {
294 m.attachments = slices.Delete(m.attachments, num, num+1)
295 }
296 return m, nil
297 }
298 }
299 if key.Matches(msg, m.keyMap.OpenEditor) {
300 if m.app.AgentCoordinator.IsSessionBusy(m.session.ID) {
301 return m, util.ReportWarn("Agent is working, please wait...")
302 }
303 return m, m.openEditor(m.textarea.Value())
304 }
305 if key.Matches(msg, DeleteKeyMaps.Escape) {
306 m.deleteMode = false
307 return m, nil
308 }
309 if key.Matches(msg, m.keyMap.Newline) {
310 m.textarea.InsertRune('\n')
311 cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
312 }
313 // Handle Enter key
314 if m.textarea.Focused() && key.Matches(msg, m.keyMap.SendMessage) {
315 value := m.textarea.Value()
316 if strings.HasSuffix(value, "\\") {
317 // If the last character is a backslash, remove it and add a newline.
318 m.textarea.SetValue(strings.TrimSuffix(value, "\\"))
319 } else {
320 // Otherwise, send the message
321 return m, m.send()
322 }
323 }
324 }
325
326 m.textarea, cmd = m.textarea.Update(msg)
327 cmds = append(cmds, cmd)
328
329 if m.textarea.Focused() {
330 kp, ok := msg.(tea.KeyPressMsg)
331 if ok {
332 if kp.String() == "space" || m.textarea.Value() == "" {
333 m.isCompletionsOpen = false
334 m.currentQuery = ""
335 m.completionsStartIndex = 0
336 cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
337 } else {
338 word := m.textarea.Word()
339 if strings.HasPrefix(word, "@") {
340 // XXX: wont' work if editing in the middle of the field.
341 m.completionsStartIndex = strings.LastIndex(m.textarea.Value(), word)
342 m.currentQuery = word[1:]
343 x, y := m.completionsPosition()
344 x -= len(m.currentQuery)
345 m.isCompletionsOpen = true
346 cmds = append(cmds,
347 util.CmdHandler(completions.FilterCompletionsMsg{
348 Query: m.currentQuery,
349 Reopen: m.isCompletionsOpen,
350 X: x,
351 Y: y,
352 }),
353 )
354 } else if m.isCompletionsOpen {
355 m.isCompletionsOpen = false
356 m.currentQuery = ""
357 m.completionsStartIndex = 0
358 cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
359 }
360 }
361 }
362 }
363
364 return m, tea.Batch(cmds...)
365}
366
367func (m *editorCmp) setEditorPrompt() {
368 if m.app.Permissions.SkipRequests() {
369 m.textarea.SetPromptFunc(4, yoloPromptFunc)
370 return
371 }
372 m.textarea.SetPromptFunc(4, normalPromptFunc)
373}
374
375func (m *editorCmp) completionsPosition() (int, int) {
376 cur := m.textarea.Cursor()
377 if cur == nil {
378 return m.x, m.y + 1 // adjust for padding
379 }
380 x := cur.X + m.x
381 y := cur.Y + m.y + 1 // adjust for padding
382 return x, y
383}
384
385func (m *editorCmp) Cursor() *tea.Cursor {
386 cursor := m.textarea.Cursor()
387 if cursor != nil {
388 cursor.X = cursor.X + m.x + 1
389 cursor.Y = cursor.Y + m.y + 1 // adjust for padding
390 }
391 return cursor
392}
393
394var readyPlaceholders = [...]string{
395 "Ready!",
396 "Ready...",
397 "Ready?",
398 "Ready for instructions",
399}
400
401var workingPlaceholders = [...]string{
402 "Working!",
403 "Working...",
404 "Brrrrr...",
405 "Prrrrrrrr...",
406 "Processing...",
407 "Thinking...",
408}
409
410func (m *editorCmp) randomizePlaceholders() {
411 m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))]
412 m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))]
413}
414
415func (m *editorCmp) View() string {
416 t := styles.CurrentTheme()
417 // Update placeholder
418 if m.app.AgentCoordinator != nil && m.app.AgentCoordinator.IsBusy() {
419 m.textarea.Placeholder = m.workingPlaceholder
420 } else {
421 m.textarea.Placeholder = m.readyPlaceholder
422 }
423 if m.app.Permissions.SkipRequests() {
424 m.textarea.Placeholder = "Yolo mode!"
425 }
426 if len(m.attachments) == 0 {
427 return t.S().Base.Padding(1).Render(
428 m.textarea.View(),
429 )
430 }
431 return t.S().Base.Padding(0, 1, 1, 1).Render(
432 lipgloss.JoinVertical(
433 lipgloss.Top,
434 m.attachmentsContent(),
435 m.textarea.View(),
436 ),
437 )
438}
439
440func (m *editorCmp) SetSize(width, height int) tea.Cmd {
441 m.width = width
442 m.height = height
443 m.textarea.SetWidth(width - 2) // adjust for padding
444 m.textarea.SetHeight(height - 2) // adjust for padding
445 return nil
446}
447
448func (m *editorCmp) GetSize() (int, int) {
449 return m.textarea.Width(), m.textarea.Height()
450}
451
452func (m *editorCmp) attachmentsContent() string {
453 var styledAttachments []string
454 t := styles.CurrentTheme()
455 attachmentStyle := t.S().Base.
456 Padding(0, 1).
457 MarginRight(1).
458 Background(t.FgMuted).
459 Foreground(t.FgBase).
460 Render
461 iconStyle := t.S().Base.
462 Foreground(t.BgSubtle).
463 Background(t.Green).
464 Padding(0, 1).
465 Bold(true).
466 Render
467 rmStyle := t.S().Base.
468 Padding(0, 1).
469 Bold(true).
470 Background(t.Red).
471 Foreground(t.FgBase).
472 Render
473 for i, attachment := range m.attachments {
474 filename := ansi.Truncate(filepath.Base(attachment.FileName), 10, "...")
475 icon := styles.ImageIcon
476 if attachment.IsText() {
477 icon = styles.TextIcon
478 }
479 if m.deleteMode {
480 styledAttachments = append(
481 styledAttachments,
482 rmStyle(fmt.Sprintf("%d", i)),
483 attachmentStyle(filename),
484 )
485 continue
486 }
487 styledAttachments = append(
488 styledAttachments,
489 iconStyle(icon),
490 attachmentStyle(filename),
491 )
492 }
493 return lipgloss.JoinHorizontal(lipgloss.Left, styledAttachments...)
494}
495
496func (m *editorCmp) SetPosition(x, y int) tea.Cmd {
497 m.x = x
498 m.y = y
499 return nil
500}
501
502func (m *editorCmp) startCompletions() tea.Msg {
503 ls := m.app.Config().Options.TUI.Completions
504 depth, limit := ls.Limits()
505 files, _, _ := fsext.ListDirectory(".", nil, depth, limit)
506 slices.Sort(files)
507 completionItems := make([]completions.Completion, 0, len(files))
508 for _, file := range files {
509 file = strings.TrimPrefix(file, "./")
510 completionItems = append(completionItems, completions.Completion{
511 Title: file,
512 Value: FileCompletionItem{
513 Path: file,
514 },
515 })
516 }
517
518 x, y := m.completionsPosition()
519 return completions.OpenCompletionsMsg{
520 Completions: completionItems,
521 X: x,
522 Y: y,
523 MaxResults: maxFileResults,
524 }
525}
526
527// Blur implements Container.
528func (c *editorCmp) Blur() tea.Cmd {
529 c.textarea.Blur()
530 return nil
531}
532
533// Focus implements Container.
534func (c *editorCmp) Focus() tea.Cmd {
535 return c.textarea.Focus()
536}
537
538// IsFocused implements Container.
539func (c *editorCmp) IsFocused() bool {
540 return c.textarea.Focused()
541}
542
543// Bindings implements Container.
544func (c *editorCmp) Bindings() []key.Binding {
545 return c.keyMap.KeyBindings()
546}
547
548// TODO: most likely we do not need to have the session here
549// we need to move some functionality to the page level
550func (c *editorCmp) SetSession(session session.Session) tea.Cmd {
551 c.session = session
552 return nil
553}
554
555func (c *editorCmp) IsCompletionsOpen() bool {
556 return c.isCompletionsOpen
557}
558
559func (c *editorCmp) HasAttachments() bool {
560 return len(c.attachments) > 0
561}
562
563func (c *editorCmp) IsEmpty() bool {
564 return strings.TrimSpace(c.textarea.Value()) == ""
565}
566
567func normalPromptFunc(info textarea.PromptInfo) string {
568 t := styles.CurrentTheme()
569 if info.LineNumber == 0 {
570 if info.Focused {
571 return " > "
572 }
573 return "::: "
574 }
575 if info.Focused {
576 return t.S().Base.Foreground(t.GreenDark).Render("::: ")
577 }
578 return t.S().Muted.Render("::: ")
579}
580
581func yoloPromptFunc(info textarea.PromptInfo) string {
582 t := styles.CurrentTheme()
583 if info.LineNumber == 0 {
584 if info.Focused {
585 return fmt.Sprintf("%s ", t.YoloIconFocused)
586 } else {
587 return fmt.Sprintf("%s ", t.YoloIconBlurred)
588 }
589 }
590 if info.Focused {
591 return fmt.Sprintf("%s ", t.YoloDotsFocused)
592 }
593 return fmt.Sprintf("%s ", t.YoloDotsBlurred)
594}
595
596func New(app *app.App) Editor {
597 t := styles.CurrentTheme()
598 ta := textarea.New()
599 ta.SetStyles(t.S().TextArea)
600 ta.ShowLineNumbers = false
601 ta.CharLimit = -1
602 ta.SetVirtualCursor(false)
603 ta.Focus()
604 e := &editorCmp{
605 // TODO: remove the app instance from here
606 app: app,
607 textarea: ta,
608 keyMap: DefaultEditorKeyMap(),
609 }
610 e.setEditorPrompt()
611
612 e.randomizePlaceholders()
613 e.textarea.Placeholder = e.readyPlaceholder
614
615 return e
616}
617
618var maxAttachmentSize = 5 * 1024 * 1024 // 5MB
619
620var errNotAFile = errors.New("not a file")
621
622func pasteToFile(msg tea.PasteMsg) ([]byte, string, error) {
623 content, path, err := filepathToFile(msg.Content)
624 if err == nil {
625 return content, path, err
626 }
627
628 if strings.Count(msg.Content, "\n") > 2 {
629 return contentToFile([]byte(msg.Content))
630 }
631
632 return nil, "", errNotAFile
633}
634
635func contentToFile(content []byte) ([]byte, string, error) {
636 f, err := os.CreateTemp("", "paste_*.txt")
637 if err != nil {
638 return nil, "", err
639 }
640 if _, err := f.Write(content); err != nil {
641 return nil, "", err
642 }
643 if err := f.Close(); err != nil {
644 return nil, "", err
645 }
646 return content, f.Name(), nil
647}
648
649func filepathToFile(name string) ([]byte, string, error) {
650 path, err := filepath.Abs(strings.TrimSpace(strings.ReplaceAll(name, "\\", "")))
651 if err != nil {
652 return nil, "", err
653 }
654 content, err := os.ReadFile(path)
655 if err != nil {
656 return nil, "", err
657 }
658 return content, path, nil
659}
660
661func mimeOf(content []byte) string {
662 mimeBufferSize := min(512, len(content))
663 return http.DetectContentType(content[:mimeBufferSize])
664}