editor.go

  1package editor
  2
  3import (
  4	"fmt"
  5	"os"
  6	"os/exec"
  7	"runtime"
  8	"slices"
  9	"strings"
 10	"unicode"
 11
 12	"github.com/charmbracelet/bubbles/v2/key"
 13	"github.com/charmbracelet/bubbles/v2/textarea"
 14	tea "github.com/charmbracelet/bubbletea/v2"
 15	"github.com/charmbracelet/crush/internal/app"
 16	"github.com/charmbracelet/crush/internal/fsext"
 17	"github.com/charmbracelet/crush/internal/message"
 18	"github.com/charmbracelet/crush/internal/session"
 19	"github.com/charmbracelet/crush/internal/tui/components/chat"
 20	"github.com/charmbracelet/crush/internal/tui/components/completions"
 21	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
 22	"github.com/charmbracelet/crush/internal/tui/components/dialogs"
 23	"github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
 24	"github.com/charmbracelet/crush/internal/tui/components/dialogs/quit"
 25	"github.com/charmbracelet/crush/internal/tui/styles"
 26	"github.com/charmbracelet/crush/internal/tui/util"
 27	"github.com/charmbracelet/lipgloss/v2"
 28)
 29
 30type Editor interface {
 31	util.Model
 32	layout.Sizeable
 33	layout.Focusable
 34	layout.Help
 35	layout.Positional
 36
 37	SetSession(session session.Session) tea.Cmd
 38}
 39
 40type FileCompletionItem struct {
 41	Path string // The file path
 42}
 43
 44type editorCmp struct {
 45	width       int
 46	height      int
 47	x, y        int
 48	app         *app.App
 49	session     session.Session
 50	textarea    textarea.Model
 51	attachments []message.Attachment
 52	deleteMode  bool
 53
 54	keyMap EditorKeyMap
 55
 56	// File path completions
 57	currentQuery          string
 58	completionsStartIndex int
 59	isCompletionsOpen     bool
 60}
 61
 62var DeleteKeyMaps = DeleteAttachmentKeyMaps{
 63	AttachmentDeleteMode: key.NewBinding(
 64		key.WithKeys("ctrl+r"),
 65		key.WithHelp("ctrl+r+{i}", "delete attachment at index i"),
 66	),
 67	Escape: key.NewBinding(
 68		key.WithKeys("esc"),
 69		key.WithHelp("esc", "cancel delete mode"),
 70	),
 71	DeleteAllAttachments: key.NewBinding(
 72		key.WithKeys("r"),
 73		key.WithHelp("ctrl+r+r", "delete all attchments"),
 74	),
 75}
 76
 77const (
 78	maxAttachments = 5
 79)
 80
 81func (m *editorCmp) openEditor() tea.Cmd {
 82	editor := os.Getenv("EDITOR")
 83	if editor == "" {
 84		// Use platform-appropriate default editor
 85		if runtime.GOOS == "windows" {
 86			editor = "notepad"
 87		} else {
 88			editor = "nvim"
 89		}
 90	}
 91
 92	tmpfile, err := os.CreateTemp("", "msg_*.md")
 93	if err != nil {
 94		return util.ReportError(err)
 95	}
 96	tmpfile.Close()
 97	c := exec.Command(editor, tmpfile.Name())
 98	c.Stdin = os.Stdin
 99	c.Stdout = os.Stdout
100	c.Stderr = os.Stderr
101	return tea.ExecProcess(c, func(err error) tea.Msg {
102		if err != nil {
103			return util.ReportError(err)
104		}
105		content, err := os.ReadFile(tmpfile.Name())
106		if err != nil {
107			return util.ReportError(err)
108		}
109		if len(content) == 0 {
110			return util.ReportWarn("Message is empty")
111		}
112		os.Remove(tmpfile.Name())
113		attachments := m.attachments
114		m.attachments = nil
115		return chat.SendMsg{
116			Text:        string(content),
117			Attachments: attachments,
118		}
119	})
120}
121
122func (m *editorCmp) Init() tea.Cmd {
123	return nil
124}
125
126func (m *editorCmp) send() tea.Cmd {
127	if m.app.CoderAgent.IsSessionBusy(m.session.ID) {
128		return util.ReportWarn("Agent is working, please wait...")
129	}
130
131	value := m.textarea.Value()
132	value = strings.TrimSpace(value)
133
134	switch value {
135	case "exit", "quit":
136		m.textarea.Reset()
137		return util.CmdHandler(dialogs.OpenDialogMsg{Model: quit.NewQuitDialog()})
138	}
139
140	m.textarea.Reset()
141	attachments := m.attachments
142
143	m.attachments = nil
144	if value == "" {
145		return nil
146	}
147	return tea.Batch(
148		util.CmdHandler(chat.SendMsg{
149			Text:        value,
150			Attachments: attachments,
151		}),
152	)
153}
154
155func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
156	var cmd tea.Cmd
157	var cmds []tea.Cmd
158	switch msg := msg.(type) {
159	case filepicker.FilePickedMsg:
160		if len(m.attachments) >= maxAttachments {
161			return m, util.ReportError(fmt.Errorf("cannot add more than %d images", maxAttachments))
162		}
163		m.attachments = append(m.attachments, msg.Attachment)
164		return m, nil
165	case completions.CompletionsClosedMsg:
166		m.isCompletionsOpen = false
167		m.currentQuery = ""
168		m.completionsStartIndex = 0
169	case completions.SelectCompletionMsg:
170		if !m.isCompletionsOpen {
171			return m, nil
172		}
173		if item, ok := msg.Value.(FileCompletionItem); ok {
174			// If the selected item is a file, insert its path into the textarea
175			value := m.textarea.Value()
176			value = value[:m.completionsStartIndex]
177			if len(value) > 0 && value[len(value)-1] != ' ' {
178				value += " "
179			}
180			value += item.Path
181			m.textarea.SetValue(value)
182			m.isCompletionsOpen = false
183			m.currentQuery = ""
184			m.completionsStartIndex = 0
185			return m, nil
186		}
187	case tea.KeyPressMsg:
188		switch {
189		// Completions
190		case msg.String() == "/" && !m.isCompletionsOpen:
191			m.isCompletionsOpen = true
192			m.currentQuery = ""
193			cmds = append(cmds, m.startCompletions)
194			m.completionsStartIndex = len(m.textarea.Value())
195		case msg.String() == "space" && m.isCompletionsOpen:
196			m.isCompletionsOpen = false
197			m.currentQuery = ""
198			m.completionsStartIndex = 0
199			cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
200		case m.isCompletionsOpen && m.textarea.Cursor().X <= m.completionsStartIndex:
201			cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
202		case msg.String() == "backspace" && m.isCompletionsOpen:
203			if len(m.currentQuery) > 0 {
204				m.currentQuery = m.currentQuery[:len(m.currentQuery)-1]
205				cmds = append(cmds, util.CmdHandler(completions.FilterCompletionsMsg{
206					Query: m.currentQuery,
207				}))
208			} else {
209				m.isCompletionsOpen = false
210				m.currentQuery = ""
211				m.completionsStartIndex = 0
212				cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
213			}
214		default:
215			if m.isCompletionsOpen {
216				m.currentQuery += msg.String()
217				cmds = append(cmds, util.CmdHandler(completions.FilterCompletionsMsg{
218					Query: m.currentQuery,
219				}))
220			}
221		}
222		if key.Matches(msg, DeleteKeyMaps.AttachmentDeleteMode) {
223			m.deleteMode = true
224			return m, nil
225		}
226		if key.Matches(msg, DeleteKeyMaps.DeleteAllAttachments) && m.deleteMode {
227			m.deleteMode = false
228			m.attachments = nil
229			return m, nil
230		}
231		rune := msg.Code
232		if m.deleteMode && unicode.IsDigit(rune) {
233			num := int(rune - '0')
234			m.deleteMode = false
235			if num < 10 && len(m.attachments) > num {
236				if num == 0 {
237					m.attachments = m.attachments[num+1:]
238				} else {
239					m.attachments = slices.Delete(m.attachments, num, num+1)
240				}
241				return m, nil
242			}
243		}
244		if key.Matches(msg, m.keyMap.OpenEditor) {
245			if m.app.CoderAgent.IsSessionBusy(m.session.ID) {
246				return m, util.ReportWarn("Agent is working, please wait...")
247			}
248			return m, m.openEditor()
249		}
250		if key.Matches(msg, DeleteKeyMaps.Escape) {
251			m.deleteMode = false
252			return m, nil
253		}
254		// Hanlde Enter key
255		if m.textarea.Focused() && key.Matches(msg, m.keyMap.SendMessage) {
256			value := m.textarea.Value()
257			if len(value) > 0 && value[len(value)-1] == '\\' {
258				// If the last character is a backslash, remove it and add a newline
259				m.textarea.SetValue(value[:len(value)-1] + "\n")
260				return m, nil
261			} else {
262				// Otherwise, send the message
263				return m, m.send()
264			}
265		}
266	}
267	m.textarea, cmd = m.textarea.Update(msg)
268	cmds = append(cmds, cmd)
269	return m, tea.Batch(cmds...)
270}
271
272func (m *editorCmp) View() tea.View {
273	t := styles.CurrentTheme()
274	cursor := m.textarea.Cursor()
275	if cursor != nil {
276		cursor.X = cursor.X + m.x + 1
277		cursor.Y = cursor.Y + m.y + 1 // adjust for padding
278	}
279	if len(m.attachments) == 0 {
280		content := t.S().Base.Padding(1).Render(
281			m.textarea.View(),
282		)
283		view := tea.NewView(content)
284		view.SetCursor(cursor)
285		return view
286	}
287	content := t.S().Base.Padding(0, 1, 1, 1).Render(
288		lipgloss.JoinVertical(lipgloss.Top,
289			m.attachmentsContent(),
290			m.textarea.View(),
291		),
292	)
293	view := tea.NewView(content)
294	view.SetCursor(cursor)
295	return view
296}
297
298func (m *editorCmp) SetSize(width, height int) tea.Cmd {
299	m.width = width
300	m.height = height
301	m.textarea.SetWidth(width - 2)   // adjust for padding
302	m.textarea.SetHeight(height - 2) // adjust for padding
303	return nil
304}
305
306func (m *editorCmp) GetSize() (int, int) {
307	return m.textarea.Width(), m.textarea.Height()
308}
309
310func (m *editorCmp) attachmentsContent() string {
311	var styledAttachments []string
312	t := styles.CurrentTheme()
313	attachmentStyles := t.S().Base.
314		MarginLeft(1).
315		Background(t.FgMuted).
316		Foreground(t.FgBase)
317	for i, attachment := range m.attachments {
318		var filename string
319		if len(attachment.FileName) > 10 {
320			filename = fmt.Sprintf(" %s %s...", styles.DocumentIcon, attachment.FileName[0:7])
321		} else {
322			filename = fmt.Sprintf(" %s %s", styles.DocumentIcon, attachment.FileName)
323		}
324		if m.deleteMode {
325			filename = fmt.Sprintf("%d%s", i, filename)
326		}
327		styledAttachments = append(styledAttachments, attachmentStyles.Render(filename))
328	}
329	content := lipgloss.JoinHorizontal(lipgloss.Left, styledAttachments...)
330	return content
331}
332
333func (m *editorCmp) SetPosition(x, y int) tea.Cmd {
334	m.x = x
335	m.y = y
336	return nil
337}
338
339func (m *editorCmp) startCompletions() tea.Msg {
340	files, _, _ := fsext.ListDirectory(".", []string{}, 0)
341	completionItems := make([]completions.Completion, 0, len(files))
342	for _, file := range files {
343		file = strings.TrimPrefix(file, "./")
344		completionItems = append(completionItems, completions.Completion{
345			Title: file,
346			Value: FileCompletionItem{
347				Path: file,
348			},
349		})
350	}
351
352	x := m.textarea.Cursor().X + m.x + 1
353	y := m.textarea.Cursor().Y + m.y + 1
354	return completions.OpenCompletionsMsg{
355		Completions: completionItems,
356		X:           x,
357		Y:           y,
358	}
359}
360
361// Blur implements Container.
362func (c *editorCmp) Blur() tea.Cmd {
363	c.textarea.Blur()
364	return nil
365}
366
367// Focus implements Container.
368func (c *editorCmp) Focus() tea.Cmd {
369	return c.textarea.Focus()
370}
371
372// IsFocused implements Container.
373func (c *editorCmp) IsFocused() bool {
374	return c.textarea.Focused()
375}
376
377func (c *editorCmp) Bindings() []key.Binding {
378	return c.keyMap.KeyBindings()
379}
380
381// TODO: most likely we do not need to have the session here
382// we need to move some functionality to the page level
383func (c *editorCmp) SetSession(session session.Session) tea.Cmd {
384	c.session = session
385	return nil
386}
387
388func New(app *app.App) Editor {
389	t := styles.CurrentTheme()
390	ta := textarea.New()
391	ta.SetStyles(t.S().TextArea)
392	ta.SetPromptFunc(4, func(lineIndex int, focused bool) string {
393		if lineIndex == 0 {
394			return "  > "
395		}
396		if focused {
397			return t.S().Base.Foreground(t.GreenDark).Render("::: ")
398		} else {
399			return t.S().Muted.Render("::: ")
400		}
401	})
402	ta.ShowLineNumbers = false
403	ta.CharLimit = -1
404	ta.Placeholder = "Tell me more about this project..."
405	ta.SetVirtualCursor(false)
406	ta.Focus()
407
408	return &editorCmp{
409		// TODO: remove the app instance from here
410		app:      app,
411		textarea: ta,
412		keyMap:   DefaultEditorKeyMap(),
413	}
414}