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