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