1package editor
2
3import (
4 "errors"
5 "fmt"
6 "math/rand"
7 "net/http"
8 "os"
9 "path/filepath"
10 "slices"
11 "strings"
12 "unicode"
13
14 "charm.land/bubbles/v2/key"
15 "charm.land/bubbles/v2/textarea"
16 tea "charm.land/bubbletea/v2"
17 "charm.land/lipgloss/v2"
18 "github.com/charmbracelet/crush/internal/app"
19 "github.com/charmbracelet/crush/internal/filetracker"
20 "github.com/charmbracelet/crush/internal/fsext"
21 "github.com/charmbracelet/crush/internal/message"
22 "github.com/charmbracelet/crush/internal/session"
23 "github.com/charmbracelet/crush/internal/tui/components/chat"
24 "github.com/charmbracelet/crush/internal/tui/components/completions"
25 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
26 "github.com/charmbracelet/crush/internal/tui/components/dialogs"
27 "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
28 "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
29 "github.com/charmbracelet/crush/internal/tui/components/dialogs/quit"
30 "github.com/charmbracelet/crush/internal/tui/styles"
31 "github.com/charmbracelet/crush/internal/tui/util"
32 "github.com/charmbracelet/x/ansi"
33 "github.com/charmbracelet/x/editor"
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 tmpfile, err := os.CreateTemp("", "msg_*.md")
97 if err != nil {
98 return util.ReportError(err)
99 }
100 defer tmpfile.Close() //nolint:errcheck
101 if _, err := tmpfile.WriteString(value); err != nil {
102 return util.ReportError(err)
103 }
104 cmd, err := editor.Command(
105 "crush",
106 tmpfile.Name(),
107 editor.AtPosition(
108 m.textarea.Line()+1,
109 m.textarea.Column()+1,
110 ),
111 )
112 if err != nil {
113 return util.ReportError(err)
114 }
115 return tea.ExecProcess(cmd, 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 absPath, _ := filepath.Abs(item.Path)
206 // Skip attachment if file was already read and hasn't been modified.
207 lastRead := filetracker.LastReadTime(absPath)
208 if !lastRead.IsZero() {
209 if info, err := os.Stat(item.Path); err == nil && !info.ModTime().After(lastRead) {
210 return m, nil
211 }
212 }
213 content, err := os.ReadFile(item.Path)
214 if err != nil {
215 // if it fails, let the LLM handle it later.
216 return m, nil
217 }
218 filetracker.RecordRead(absPath)
219 m.attachments = append(m.attachments, message.Attachment{
220 FilePath: item.Path,
221 FileName: filepath.Base(item.Path),
222 MimeType: mimeOf(content),
223 Content: content,
224 })
225 }
226
227 case commands.OpenExternalEditorMsg:
228 if m.app.AgentCoordinator.IsSessionBusy(m.session.ID) {
229 return m, util.ReportWarn("Agent is working, please wait...")
230 }
231 return m, m.openEditor(m.textarea.Value())
232 case OpenEditorMsg:
233 m.textarea.SetValue(msg.Text)
234 m.textarea.MoveToEnd()
235 case tea.PasteMsg:
236 content, path, err := pasteToFile(msg)
237 if errors.Is(err, errNotAFile) {
238 m.textarea, cmd = m.textarea.Update(msg)
239 return m, cmd
240 }
241 if err != nil {
242 return m, util.ReportError(err)
243 }
244
245 if len(content) > maxAttachmentSize {
246 return m, util.ReportWarn("File is too big (>5mb)")
247 }
248
249 mimeType := mimeOf(content)
250 attachment := message.Attachment{
251 FilePath: path,
252 FileName: filepath.Base(path),
253 MimeType: mimeType,
254 Content: content,
255 }
256 if !attachment.IsText() && !attachment.IsImage() {
257 return m, util.ReportWarn("Invalid file content type: " + mimeType)
258 }
259 m.textarea.InsertString(attachment.FileName)
260 return m, util.CmdHandler(filepicker.FilePickedMsg{
261 Attachment: attachment,
262 })
263
264 case commands.ToggleYoloModeMsg:
265 m.setEditorPrompt()
266 return m, nil
267 case tea.KeyPressMsg:
268 cur := m.textarea.Cursor()
269 curIdx := m.textarea.Width()*cur.Y + cur.X
270 switch {
271 // Open command palette when "/" is pressed on empty prompt
272 case msg.String() == "/" && m.IsEmpty():
273 return m, util.CmdHandler(dialogs.OpenDialogMsg{
274 Model: commands.NewCommandDialog(m.session.ID),
275 })
276 // Completions
277 case msg.String() == "@" && !m.isCompletionsOpen &&
278 // only show if beginning of prompt, or if previous char is a space or newline:
279 (len(m.textarea.Value()) == 0 || unicode.IsSpace(rune(m.textarea.Value()[len(m.textarea.Value())-1]))):
280 m.isCompletionsOpen = true
281 m.currentQuery = ""
282 m.completionsStartIndex = curIdx
283 cmds = append(cmds, m.startCompletions)
284 case m.isCompletionsOpen && curIdx <= m.completionsStartIndex:
285 cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
286 }
287 if key.Matches(msg, DeleteKeyMaps.AttachmentDeleteMode) {
288 m.deleteMode = true
289 return m, nil
290 }
291 if key.Matches(msg, DeleteKeyMaps.DeleteAllAttachments) && m.deleteMode {
292 m.deleteMode = false
293 m.attachments = nil
294 return m, nil
295 }
296 rune := msg.Code
297 if m.deleteMode && unicode.IsDigit(rune) {
298 num := int(rune - '0')
299 m.deleteMode = false
300 if num < 10 && len(m.attachments) > num {
301 if num == 0 {
302 m.attachments = m.attachments[num+1:]
303 } else {
304 m.attachments = slices.Delete(m.attachments, num, num+1)
305 }
306 return m, nil
307 }
308 }
309 if key.Matches(msg, m.keyMap.OpenEditor) {
310 if m.app.AgentCoordinator.IsSessionBusy(m.session.ID) {
311 return m, util.ReportWarn("Agent is working, please wait...")
312 }
313 return m, m.openEditor(m.textarea.Value())
314 }
315 if key.Matches(msg, DeleteKeyMaps.Escape) {
316 m.deleteMode = false
317 return m, nil
318 }
319 if key.Matches(msg, m.keyMap.Newline) {
320 m.textarea.InsertRune('\n')
321 cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
322 }
323 // Handle Enter key
324 if m.textarea.Focused() && key.Matches(msg, m.keyMap.SendMessage) {
325 value := m.textarea.Value()
326 if strings.HasSuffix(value, "\\") {
327 // If the last character is a backslash, remove it and add a newline.
328 m.textarea.SetValue(strings.TrimSuffix(value, "\\"))
329 } else {
330 // Otherwise, send the message
331 return m, m.send()
332 }
333 }
334 }
335
336 m.textarea, cmd = m.textarea.Update(msg)
337 cmds = append(cmds, cmd)
338
339 if m.textarea.Focused() {
340 kp, ok := msg.(tea.KeyPressMsg)
341 if ok {
342 if kp.String() == "space" || m.textarea.Value() == "" {
343 m.isCompletionsOpen = false
344 m.currentQuery = ""
345 m.completionsStartIndex = 0
346 cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
347 } else {
348 word := m.textarea.Word()
349 if strings.HasPrefix(word, "@") {
350 // XXX: wont' work if editing in the middle of the field.
351 m.completionsStartIndex = strings.LastIndex(m.textarea.Value(), word)
352 m.currentQuery = word[1:]
353 x, y := m.completionsPosition()
354 x -= len(m.currentQuery)
355 m.isCompletionsOpen = true
356 cmds = append(cmds,
357 util.CmdHandler(completions.FilterCompletionsMsg{
358 Query: m.currentQuery,
359 Reopen: m.isCompletionsOpen,
360 X: x,
361 Y: y,
362 }),
363 )
364 } else if m.isCompletionsOpen {
365 m.isCompletionsOpen = false
366 m.currentQuery = ""
367 m.completionsStartIndex = 0
368 cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
369 }
370 }
371 }
372 }
373
374 return m, tea.Batch(cmds...)
375}
376
377func (m *editorCmp) setEditorPrompt() {
378 if m.app.Permissions.SkipRequests() {
379 m.textarea.SetPromptFunc(4, yoloPromptFunc)
380 return
381 }
382 m.textarea.SetPromptFunc(4, normalPromptFunc)
383}
384
385func (m *editorCmp) completionsPosition() (int, int) {
386 cur := m.textarea.Cursor()
387 if cur == nil {
388 return m.x, m.y + 1 // adjust for padding
389 }
390 x := cur.X + m.x
391 y := cur.Y + m.y + 1 // adjust for padding
392 return x, y
393}
394
395func (m *editorCmp) Cursor() *tea.Cursor {
396 cursor := m.textarea.Cursor()
397 if cursor != nil {
398 cursor.X = cursor.X + m.x + 1
399 cursor.Y = cursor.Y + m.y + 1 // adjust for padding
400 }
401 return cursor
402}
403
404var readyPlaceholders = [...]string{
405 "Ready!",
406 "Ready...",
407 "Ready?",
408 "Ready for instructions",
409}
410
411var workingPlaceholders = [...]string{
412 "Working!",
413 "Working...",
414 "Brrrrr...",
415 "Prrrrrrrr...",
416 "Processing...",
417 "Thinking...",
418}
419
420func (m *editorCmp) randomizePlaceholders() {
421 m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))]
422 m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))]
423}
424
425func (m *editorCmp) View() string {
426 t := styles.CurrentTheme()
427 // Update placeholder
428 if m.app.AgentCoordinator != nil && m.app.AgentCoordinator.IsBusy() {
429 m.textarea.Placeholder = m.workingPlaceholder
430 } else {
431 m.textarea.Placeholder = m.readyPlaceholder
432 }
433 if m.app.Permissions.SkipRequests() {
434 m.textarea.Placeholder = "Yolo mode!"
435 }
436 if len(m.attachments) == 0 {
437 return t.S().Base.Padding(1).Render(
438 m.textarea.View(),
439 )
440 }
441 return t.S().Base.Padding(0, 1, 1, 1).Render(
442 lipgloss.JoinVertical(
443 lipgloss.Top,
444 m.attachmentsContent(),
445 m.textarea.View(),
446 ),
447 )
448}
449
450func (m *editorCmp) SetSize(width, height int) tea.Cmd {
451 m.width = width
452 m.height = height
453 m.textarea.SetWidth(width - 2) // adjust for padding
454 m.textarea.SetHeight(height - 2) // adjust for padding
455 return nil
456}
457
458func (m *editorCmp) GetSize() (int, int) {
459 return m.textarea.Width(), m.textarea.Height()
460}
461
462func (m *editorCmp) attachmentsContent() string {
463 var styledAttachments []string
464 t := styles.CurrentTheme()
465 attachmentStyle := t.S().Base.
466 Padding(0, 1).
467 MarginRight(1).
468 Background(t.FgMuted).
469 Foreground(t.FgBase).
470 Render
471 iconStyle := t.S().Base.
472 Foreground(t.BgSubtle).
473 Background(t.Green).
474 Padding(0, 1).
475 Bold(true).
476 Render
477 rmStyle := t.S().Base.
478 Padding(0, 1).
479 Bold(true).
480 Background(t.Red).
481 Foreground(t.FgBase).
482 Render
483 for i, attachment := range m.attachments {
484 filename := ansi.Truncate(filepath.Base(attachment.FileName), 10, "...")
485 icon := styles.ImageIcon
486 if attachment.IsText() {
487 icon = styles.TextIcon
488 }
489 if m.deleteMode {
490 styledAttachments = append(
491 styledAttachments,
492 rmStyle(fmt.Sprintf("%d", i)),
493 attachmentStyle(filename),
494 )
495 continue
496 }
497 styledAttachments = append(
498 styledAttachments,
499 iconStyle(icon),
500 attachmentStyle(filename),
501 )
502 }
503 return lipgloss.JoinHorizontal(lipgloss.Left, styledAttachments...)
504}
505
506func (m *editorCmp) SetPosition(x, y int) tea.Cmd {
507 m.x = x
508 m.y = y
509 return nil
510}
511
512func (m *editorCmp) startCompletions() tea.Msg {
513 ls := m.app.Config().Options.TUI.Completions
514 depth, limit := ls.Limits()
515 files, _, _ := fsext.ListDirectory(".", nil, depth, limit)
516 slices.Sort(files)
517 completionItems := make([]completions.Completion, 0, len(files))
518 for _, file := range files {
519 file = strings.TrimPrefix(file, "./")
520 completionItems = append(completionItems, completions.Completion{
521 Title: file,
522 Value: FileCompletionItem{
523 Path: file,
524 },
525 })
526 }
527
528 x, y := m.completionsPosition()
529 return completions.OpenCompletionsMsg{
530 Completions: completionItems,
531 X: x,
532 Y: y,
533 MaxResults: maxFileResults,
534 }
535}
536
537// Blur implements Container.
538func (c *editorCmp) Blur() tea.Cmd {
539 c.textarea.Blur()
540 return nil
541}
542
543// Focus implements Container.
544func (c *editorCmp) Focus() tea.Cmd {
545 return c.textarea.Focus()
546}
547
548// IsFocused implements Container.
549func (c *editorCmp) IsFocused() bool {
550 return c.textarea.Focused()
551}
552
553// Bindings implements Container.
554func (c *editorCmp) Bindings() []key.Binding {
555 return c.keyMap.KeyBindings()
556}
557
558// TODO: most likely we do not need to have the session here
559// we need to move some functionality to the page level
560func (c *editorCmp) SetSession(session session.Session) tea.Cmd {
561 c.session = session
562 return nil
563}
564
565func (c *editorCmp) IsCompletionsOpen() bool {
566 return c.isCompletionsOpen
567}
568
569func (c *editorCmp) HasAttachments() bool {
570 return len(c.attachments) > 0
571}
572
573func (c *editorCmp) IsEmpty() bool {
574 return strings.TrimSpace(c.textarea.Value()) == ""
575}
576
577func normalPromptFunc(info textarea.PromptInfo) string {
578 t := styles.CurrentTheme()
579 if info.LineNumber == 0 {
580 if info.Focused {
581 return " > "
582 }
583 return "::: "
584 }
585 if info.Focused {
586 return t.S().Base.Foreground(t.GreenDark).Render("::: ")
587 }
588 return t.S().Muted.Render("::: ")
589}
590
591func yoloPromptFunc(info textarea.PromptInfo) string {
592 t := styles.CurrentTheme()
593 if info.LineNumber == 0 {
594 if info.Focused {
595 return fmt.Sprintf("%s ", t.YoloIconFocused)
596 } else {
597 return fmt.Sprintf("%s ", t.YoloIconBlurred)
598 }
599 }
600 if info.Focused {
601 return fmt.Sprintf("%s ", t.YoloDotsFocused)
602 }
603 return fmt.Sprintf("%s ", t.YoloDotsBlurred)
604}
605
606func New(app *app.App) Editor {
607 t := styles.CurrentTheme()
608 ta := textarea.New()
609 ta.SetStyles(t.S().TextArea)
610 ta.ShowLineNumbers = false
611 ta.CharLimit = -1
612 ta.SetVirtualCursor(false)
613 ta.Focus()
614 e := &editorCmp{
615 // TODO: remove the app instance from here
616 app: app,
617 textarea: ta,
618 keyMap: DefaultEditorKeyMap(),
619 }
620 e.setEditorPrompt()
621
622 e.randomizePlaceholders()
623 e.textarea.Placeholder = e.readyPlaceholder
624
625 return e
626}
627
628var maxAttachmentSize = 5 * 1024 * 1024 // 5MB
629
630var errNotAFile = errors.New("not a file")
631
632func pasteToFile(msg tea.PasteMsg) ([]byte, string, error) {
633 content, path, err := filepathToFile(msg.Content)
634 if err == nil {
635 return content, path, err
636 }
637
638 if strings.Count(msg.Content, "\n") > 2 {
639 return contentToFile([]byte(msg.Content))
640 }
641
642 return nil, "", errNotAFile
643}
644
645func contentToFile(content []byte) ([]byte, string, error) {
646 f, err := os.CreateTemp("", "paste_*.txt")
647 if err != nil {
648 return nil, "", err
649 }
650 if _, err := f.Write(content); err != nil {
651 return nil, "", err
652 }
653 if err := f.Close(); err != nil {
654 return nil, "", err
655 }
656 return content, f.Name(), nil
657}
658
659func filepathToFile(name string) ([]byte, string, error) {
660 path, err := filepath.Abs(strings.TrimSpace(strings.ReplaceAll(name, "\\", "")))
661 if err != nil {
662 return nil, "", err
663 }
664 content, err := os.ReadFile(path)
665 if err != nil {
666 return nil, "", err
667 }
668 return content, path, nil
669}
670
671func mimeOf(content []byte) string {
672 mimeBufferSize := min(512, len(content))
673 return http.DetectContentType(content[:mimeBufferSize])
674}