editor.go

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