editor.go

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