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