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