editor.go

  1package editor
  2
  3import (
  4	"context"
  5	"fmt"
  6	"math/rand"
  7	"net/http"
  8	"os"
  9	"os/exec"
 10	"path/filepath"
 11	"runtime"
 12	"slices"
 13	"strings"
 14	"unicode"
 15
 16	"github.com/charmbracelet/bubbles/v2/key"
 17	"github.com/charmbracelet/bubbles/v2/textarea"
 18	tea "github.com/charmbracelet/bubbletea/v2"
 19	"github.com/charmbracelet/crush/internal/app"
 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/mcp"
 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/lipgloss/v2"
 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	Cursor() *tea.Cursor
 47}
 48
 49type FileCompletionItem struct {
 50	Path string // The file path
 51}
 52
 53type editorCmp struct {
 54	width              int
 55	height             int
 56	x, y               int
 57	app                *app.App
 58	session            session.Session
 59	textarea           *textarea.Model
 60	attachments        []message.Attachment
 61	deleteMode         bool
 62	readyPlaceholder   string
 63	workingPlaceholder string
 64
 65	keyMap EditorKeyMap
 66
 67	// File path completions
 68	currentQuery          string
 69	completionsStartIndex int
 70	isCompletionsOpen     bool
 71}
 72
 73var DeleteKeyMaps = DeleteAttachmentKeyMaps{
 74	AttachmentDeleteMode: key.NewBinding(
 75		key.WithKeys("ctrl+r"),
 76		key.WithHelp("ctrl+r+{i}", "delete attachment at index i"),
 77	),
 78	Escape: key.NewBinding(
 79		key.WithKeys("esc", "alt+esc"),
 80		key.WithHelp("esc", "cancel delete mode"),
 81	),
 82	DeleteAllAttachments: key.NewBinding(
 83		key.WithKeys("r"),
 84		key.WithHelp("ctrl+r+r", "delete all attachments"),
 85	),
 86}
 87
 88const (
 89	maxAttachments = 5
 90)
 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	c := exec.CommandContext(context.TODO(), editor, tmpfile.Name())
116	c.Stdin = os.Stdin
117	c.Stdout = os.Stdout
118	c.Stderr = os.Stderr
119	return tea.ExecProcess(c, func(err error) tea.Msg {
120		if err != nil {
121			return util.ReportError(err)
122		}
123		content, err := os.ReadFile(tmpfile.Name())
124		if err != nil {
125			return util.ReportError(err)
126		}
127		if len(content) == 0 {
128			return util.ReportWarn("Message is empty")
129		}
130		os.Remove(tmpfile.Name())
131		return OpenEditorMsg{
132			Text: strings.TrimSpace(string(content)),
133		}
134	})
135}
136
137func (m *editorCmp) Init() tea.Cmd {
138	return nil
139}
140
141func (m *editorCmp) send() tea.Cmd {
142	value := m.textarea.Value()
143	value = strings.TrimSpace(value)
144
145	switch value {
146	case "exit", "quit":
147		m.textarea.Reset()
148		return util.CmdHandler(dialogs.OpenDialogMsg{Model: quit.NewQuitDialog()})
149	}
150
151	m.textarea.Reset()
152	attachments := m.attachments
153
154	m.attachments = nil
155	if value == "" {
156		return nil
157	}
158
159	// Change the placeholder when sending a new message.
160	m.randomizePlaceholders()
161
162	return tea.Batch(
163		util.CmdHandler(chat.SendMsg{
164			Text:        value,
165			Attachments: attachments,
166		}),
167	)
168}
169
170func (m *editorCmp) repositionCompletions() tea.Msg {
171	x, y := m.completionsPosition()
172	return completions.RepositionCompletionsMsg{X: x, Y: y}
173}
174
175func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
176	var cmd tea.Cmd
177	var cmds []tea.Cmd
178	switch msg := msg.(type) {
179	case tea.WindowSizeMsg:
180		return m, m.repositionCompletions
181	case filepicker.FilePickedMsg:
182		if len(m.attachments) >= maxAttachments {
183			// TODO: check if this still needed
184			return m, util.ReportError(fmt.Errorf("cannot add more than %d attachments", maxAttachments))
185		}
186		m.attachments = append(m.attachments, msg.Attachment)
187		return m, nil
188	case mcp.ResourcePickedMsg:
189		m.attachments = append(m.attachments, msg.Attachment)
190	case completions.CompletionsOpenedMsg:
191		m.isCompletionsOpen = true
192	case completions.CompletionsClosedMsg:
193		m.isCompletionsOpen = false
194		m.currentQuery = ""
195		m.completionsStartIndex = 0
196	case completions.SelectCompletionMsg:
197		if !m.isCompletionsOpen {
198			return m, nil
199		}
200		if item, ok := msg.Value.(FileCompletionItem); ok {
201			word := m.textarea.Word()
202			// If the selected item is a file, insert its path into the textarea
203			value := m.textarea.Value()
204			value = value[:m.completionsStartIndex] + // Remove the current query
205				item.Path + // Insert the file path
206				value[m.completionsStartIndex+len(word):] // Append the rest of the value
207			// XXX: This will always move the cursor to the end of the textarea.
208			m.textarea.SetValue(value)
209			m.textarea.MoveToEnd()
210			if !msg.Insert {
211				m.isCompletionsOpen = false
212				m.currentQuery = ""
213				m.completionsStartIndex = 0
214			}
215		}
216
217	case commands.OpenExternalEditorMsg:
218		if m.app.CoderAgent.IsSessionBusy(m.session.ID) {
219			return m, util.ReportWarn("Agent is working, please wait...")
220		}
221		return m, m.openEditor(m.textarea.Value())
222	case OpenEditorMsg:
223		m.textarea.SetValue(msg.Text)
224		m.textarea.MoveToEnd()
225	case tea.PasteMsg:
226		path := strings.ReplaceAll(string(msg), "\\ ", " ")
227		// try to get an image
228		path, err := filepath.Abs(strings.TrimSpace(path))
229		if err != nil {
230			m.textarea, cmd = m.textarea.Update(msg)
231			return m, cmd
232		}
233		isAllowedType := false
234		for _, ext := range filepicker.AllowedTypes {
235			if strings.HasSuffix(path, ext) {
236				isAllowedType = true
237				break
238			}
239		}
240		if !isAllowedType {
241			m.textarea, cmd = m.textarea.Update(msg)
242			return m, cmd
243		}
244		tooBig, _ := filepicker.IsFileTooBig(path, filepicker.MaxAttachmentSize)
245		if tooBig {
246			m.textarea, cmd = m.textarea.Update(msg)
247			return m, cmd
248		}
249
250		content, err := os.ReadFile(path)
251		if err != nil {
252			m.textarea, cmd = m.textarea.Update(msg)
253			return m, cmd
254		}
255		mimeBufferSize := min(512, len(content))
256		mimeType := http.DetectContentType(content[:mimeBufferSize])
257		fileName := filepath.Base(path)
258		attachment := message.Attachment{FilePath: path, FileName: fileName, MimeType: mimeType, Content: content}
259		return m, util.CmdHandler(filepicker.FilePickedMsg{
260			Attachment: attachment,
261		})
262
263	case commands.ToggleYoloModeMsg:
264		m.setEditorPrompt()
265		return m, nil
266	case tea.KeyPressMsg:
267		cur := m.textarea.Cursor()
268		curIdx := m.textarea.Width()*cur.Y + cur.X
269		switch {
270		// Completions
271		case msg.String() == "/" && !m.isCompletionsOpen &&
272			// only show if beginning of prompt, or if previous char is a space or newline:
273			(len(m.textarea.Value()) == 0 || unicode.IsSpace(rune(m.textarea.Value()[len(m.textarea.Value())-1]))):
274			m.isCompletionsOpen = true
275			m.currentQuery = ""
276			m.completionsStartIndex = curIdx
277			cmds = append(cmds, m.startCompletions)
278		case m.isCompletionsOpen && curIdx <= m.completionsStartIndex:
279			cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
280		}
281		if key.Matches(msg, DeleteKeyMaps.AttachmentDeleteMode) {
282			m.deleteMode = true
283			return m, nil
284		}
285		if key.Matches(msg, DeleteKeyMaps.DeleteAllAttachments) && m.deleteMode {
286			m.deleteMode = false
287			m.attachments = nil
288			return m, nil
289		}
290		rune := msg.Code
291		if m.deleteMode && unicode.IsDigit(rune) {
292			num := int(rune - '0')
293			m.deleteMode = false
294			if num < 10 && len(m.attachments) > num {
295				if num == 0 {
296					m.attachments = m.attachments[num+1:]
297				} else {
298					m.attachments = slices.Delete(m.attachments, num, num+1)
299				}
300				return m, nil
301			}
302		}
303		if key.Matches(msg, m.keyMap.OpenEditor) {
304			if m.app.CoderAgent.IsSessionBusy(m.session.ID) {
305				return m, util.ReportWarn("Agent is working, please wait...")
306			}
307			return m, m.openEditor(m.textarea.Value())
308		}
309		if key.Matches(msg, DeleteKeyMaps.Escape) {
310			m.deleteMode = false
311			return m, nil
312		}
313		if key.Matches(msg, m.keyMap.Newline) {
314			m.textarea.InsertRune('\n')
315			cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
316		}
317		// Handle Enter key
318		if m.textarea.Focused() && key.Matches(msg, m.keyMap.SendMessage) {
319			value := m.textarea.Value()
320			if strings.HasSuffix(value, "\\") {
321				// If the last character is a backslash, remove it and add a newline.
322				m.textarea.SetValue(strings.TrimSuffix(value, "\\"))
323			} else {
324				// Otherwise, send the message
325				return m, m.send()
326			}
327		}
328	}
329
330	m.textarea, cmd = m.textarea.Update(msg)
331	cmds = append(cmds, cmd)
332
333	if m.textarea.Focused() {
334		kp, ok := msg.(tea.KeyPressMsg)
335		if ok {
336			if kp.String() == "space" || m.textarea.Value() == "" {
337				m.isCompletionsOpen = false
338				m.currentQuery = ""
339				m.completionsStartIndex = 0
340				cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
341			} else {
342				word := m.textarea.Word()
343				if strings.HasPrefix(word, "/") {
344					// XXX: wont' work if editing in the middle of the field.
345					m.completionsStartIndex = strings.LastIndex(m.textarea.Value(), word)
346					m.currentQuery = word[1:]
347					x, y := m.completionsPosition()
348					x -= len(m.currentQuery)
349					m.isCompletionsOpen = true
350					cmds = append(cmds,
351						util.CmdHandler(completions.FilterCompletionsMsg{
352							Query:  m.currentQuery,
353							Reopen: m.isCompletionsOpen,
354							X:      x,
355							Y:      y,
356						}),
357					)
358				} else if m.isCompletionsOpen {
359					m.isCompletionsOpen = false
360					m.currentQuery = ""
361					m.completionsStartIndex = 0
362					cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
363				}
364			}
365		}
366	}
367
368	return m, tea.Batch(cmds...)
369}
370
371func (m *editorCmp) setEditorPrompt() {
372	if m.app.Permissions.SkipRequests() {
373		m.textarea.SetPromptFunc(4, yoloPromptFunc)
374		return
375	}
376	m.textarea.SetPromptFunc(4, normalPromptFunc)
377}
378
379func (m *editorCmp) completionsPosition() (int, int) {
380	cur := m.textarea.Cursor()
381	if cur == nil {
382		return m.x, m.y + 1 // adjust for padding
383	}
384	x := cur.X + m.x
385	y := cur.Y + m.y + 1 // adjust for padding
386	return x, y
387}
388
389func (m *editorCmp) Cursor() *tea.Cursor {
390	cursor := m.textarea.Cursor()
391	if cursor != nil {
392		cursor.X = cursor.X + m.x + 1
393		cursor.Y = cursor.Y + m.y + 1 // adjust for padding
394	}
395	return cursor
396}
397
398var readyPlaceholders = [...]string{
399	"Ready!",
400	"Ready...",
401	"Ready?",
402	"Ready for instructions",
403}
404
405var workingPlaceholders = [...]string{
406	"Working!",
407	"Working...",
408	"Brrrrr...",
409	"Prrrrrrrr...",
410	"Processing...",
411	"Thinking...",
412}
413
414func (m *editorCmp) randomizePlaceholders() {
415	m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))]
416	m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))]
417}
418
419func (m *editorCmp) View() string {
420	t := styles.CurrentTheme()
421	// Update placeholder
422	if m.app.CoderAgent != nil && m.app.CoderAgent.IsBusy() {
423		m.textarea.Placeholder = m.workingPlaceholder
424	} else {
425		m.textarea.Placeholder = m.readyPlaceholder
426	}
427	if m.app.Permissions.SkipRequests() {
428		m.textarea.Placeholder = "Yolo mode!"
429	}
430	if len(m.attachments) == 0 {
431		content := t.S().Base.Padding(1).Render(
432			m.textarea.View(),
433		)
434		return content
435	}
436	content := t.S().Base.Padding(0, 1, 1, 1).Render(
437		lipgloss.JoinVertical(lipgloss.Top,
438			m.attachmentsContent(),
439			m.textarea.View(),
440		),
441	)
442	return content
443}
444
445func (m *editorCmp) SetSize(width, height int) tea.Cmd {
446	m.width = width
447	m.height = height
448	m.textarea.SetWidth(width - 2)   // adjust for padding
449	m.textarea.SetHeight(height - 2) // adjust for padding
450	return nil
451}
452
453func (m *editorCmp) GetSize() (int, int) {
454	return m.textarea.Width(), m.textarea.Height()
455}
456
457func (m *editorCmp) attachmentsContent() string {
458	var styledAttachments []string
459	t := styles.CurrentTheme()
460	attachmentStyles := t.S().Base.
461		MarginLeft(1).
462		Background(t.FgMuted).
463		Foreground(t.FgBase)
464	for i, attachment := range m.attachments {
465		icon := styles.DocumentIcon
466		if strings.HasPrefix(attachment.MimeType, "image/") {
467			icon = styles.ImageIcon
468		}
469
470		var item string
471		if len(attachment.FileName) > 10 {
472			item = fmt.Sprintf(" %s %s...", icon, attachment.FileName[0:7])
473		} else {
474			item = fmt.Sprintf(" %s %s", icon, attachment.FileName)
475		}
476		if m.deleteMode {
477			item = fmt.Sprintf("%d%s", i, item)
478		}
479		styledAttachments = append(styledAttachments, attachmentStyles.Render(item))
480	}
481	content := lipgloss.JoinHorizontal(lipgloss.Left, styledAttachments...)
482	return content
483}
484
485func (m *editorCmp) SetPosition(x, y int) tea.Cmd {
486	m.x = x
487	m.y = y
488	return nil
489}
490
491func (m *editorCmp) startCompletions() tea.Msg {
492	ls := m.app.Config().Options.TUI.Completions
493	depth, limit := ls.Limits()
494	files, _, _ := fsext.ListDirectory(".", nil, depth, limit)
495	slices.Sort(files)
496	completionItems := make([]completions.Completion, 0, len(files))
497	for _, file := range files {
498		file = strings.TrimPrefix(file, "./")
499		completionItems = append(completionItems, completions.Completion{
500			Title: file,
501			Value: FileCompletionItem{
502				Path: file,
503			},
504		})
505	}
506
507	x, y := m.completionsPosition()
508	return completions.OpenCompletionsMsg{
509		Completions: completionItems,
510		X:           x,
511		Y:           y,
512	}
513}
514
515// Blur implements Container.
516func (c *editorCmp) Blur() tea.Cmd {
517	c.textarea.Blur()
518	return nil
519}
520
521// Focus implements Container.
522func (c *editorCmp) Focus() tea.Cmd {
523	return c.textarea.Focus()
524}
525
526// IsFocused implements Container.
527func (c *editorCmp) IsFocused() bool {
528	return c.textarea.Focused()
529}
530
531// Bindings implements Container.
532func (c *editorCmp) Bindings() []key.Binding {
533	return c.keyMap.KeyBindings()
534}
535
536// TODO: most likely we do not need to have the session here
537// we need to move some functionality to the page level
538func (c *editorCmp) SetSession(session session.Session) tea.Cmd {
539	c.session = session
540	return nil
541}
542
543func (c *editorCmp) IsCompletionsOpen() bool {
544	return c.isCompletionsOpen
545}
546
547func (c *editorCmp) HasAttachments() bool {
548	return len(c.attachments) > 0
549}
550
551func normalPromptFunc(info textarea.PromptInfo) string {
552	t := styles.CurrentTheme()
553	if info.LineNumber == 0 {
554		return "  > "
555	}
556	if info.Focused {
557		return t.S().Base.Foreground(t.GreenDark).Render("::: ")
558	}
559	return t.S().Muted.Render("::: ")
560}
561
562func yoloPromptFunc(info textarea.PromptInfo) string {
563	t := styles.CurrentTheme()
564	if info.LineNumber == 0 {
565		if info.Focused {
566			return fmt.Sprintf("%s ", t.YoloIconFocused)
567		} else {
568			return fmt.Sprintf("%s ", t.YoloIconBlurred)
569		}
570	}
571	if info.Focused {
572		return fmt.Sprintf("%s ", t.YoloDotsFocused)
573	}
574	return fmt.Sprintf("%s ", t.YoloDotsBlurred)
575}
576
577func New(app *app.App) Editor {
578	t := styles.CurrentTheme()
579	ta := textarea.New()
580	ta.SetStyles(t.S().TextArea)
581	ta.ShowLineNumbers = false
582	ta.CharLimit = -1
583	ta.SetVirtualCursor(false)
584	ta.Focus()
585	e := &editorCmp{
586		// TODO: remove the app instance from here
587		app:      app,
588		textarea: ta,
589		keyMap:   DefaultEditorKeyMap(),
590	}
591	e.setEditorPrompt()
592
593	e.randomizePlaceholders()
594	e.textarea.Placeholder = e.readyPlaceholder
595
596	return e
597}