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 filepicker.FilePickedMsg:
161		if len(m.attachments) >= maxAttachments {
162			return m, util.ReportError(fmt.Errorf("cannot add more than %d images", maxAttachments))
163		}
164		m.attachments = append(m.attachments, msg.Attachment)
165		return m, nil
166	case completions.CompletionsClosedMsg:
167		m.isCompletionsOpen = false
168		m.currentQuery = ""
169		m.completionsStartIndex = 0
170	case completions.SelectCompletionMsg:
171		if !m.isCompletionsOpen {
172			return m, nil
173		}
174		if item, ok := msg.Value.(FileCompletionItem); ok {
175			// If the selected item is a file, insert its path into the textarea
176			value := m.textarea.Value()
177			value = value[:m.completionsStartIndex]
178			if len(value) > 0 && value[len(value)-1] != ' ' {
179				value += " "
180			}
181			value += item.Path
182			m.textarea.SetValue(value)
183			m.isCompletionsOpen = false
184			m.currentQuery = ""
185			m.completionsStartIndex = 0
186			return m, nil
187		}
188	case tea.KeyPressMsg:
189		switch {
190		// Completions
191		case msg.String() == "/" && !m.isCompletionsOpen:
192			m.isCompletionsOpen = true
193			m.currentQuery = ""
194			cmds = append(cmds, m.startCompletions)
195			m.completionsStartIndex = len(m.textarea.Value())
196		case msg.String() == "space" && m.isCompletionsOpen:
197			m.isCompletionsOpen = false
198			m.currentQuery = ""
199			m.completionsStartIndex = 0
200			cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
201		case m.isCompletionsOpen && m.textarea.Cursor().X <= m.completionsStartIndex:
202			cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
203		case msg.String() == "backspace" && m.isCompletionsOpen:
204			if len(m.currentQuery) > 0 {
205				m.currentQuery = m.currentQuery[:len(m.currentQuery)-1]
206				cmds = append(cmds, util.CmdHandler(completions.FilterCompletionsMsg{
207					Query: m.currentQuery,
208				}))
209			} else {
210				m.isCompletionsOpen = false
211				m.currentQuery = ""
212				m.completionsStartIndex = 0
213				cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
214			}
215		default:
216			if m.isCompletionsOpen {
217				m.currentQuery += msg.String()
218				cmds = append(cmds, util.CmdHandler(completions.FilterCompletionsMsg{
219					Query: m.currentQuery,
220				}))
221			}
222		}
223		if key.Matches(msg, DeleteKeyMaps.AttachmentDeleteMode) {
224			m.deleteMode = true
225			return m, nil
226		}
227		if key.Matches(msg, DeleteKeyMaps.DeleteAllAttachments) && m.deleteMode {
228			m.deleteMode = false
229			m.attachments = nil
230			return m, nil
231		}
232		rune := msg.Code
233		if m.deleteMode && unicode.IsDigit(rune) {
234			num := int(rune - '0')
235			m.deleteMode = false
236			if num < 10 && len(m.attachments) > num {
237				if num == 0 {
238					m.attachments = m.attachments[num+1:]
239				} else {
240					m.attachments = slices.Delete(m.attachments, num, num+1)
241				}
242				return m, nil
243			}
244		}
245		if key.Matches(msg, m.keyMap.OpenEditor) {
246			if m.app.CoderAgent.IsSessionBusy(m.session.ID) {
247				return m, util.ReportWarn("Agent is working, please wait...")
248			}
249			return m, m.openEditor()
250		}
251		if key.Matches(msg, DeleteKeyMaps.Escape) {
252			m.deleteMode = false
253			return m, nil
254		}
255		// Hanlde Enter key
256		if m.textarea.Focused() && key.Matches(msg, m.keyMap.SendMessage) {
257			value := m.textarea.Value()
258			if len(value) > 0 && value[len(value)-1] == '\\' {
259				// If the last character is a backslash, remove it and add a newline
260				m.textarea.SetValue(value[:len(value)-1] + "\n")
261				return m, nil
262			} else {
263				// Otherwise, send the message
264				return m, m.send()
265			}
266		}
267	}
268
269	m.textarea, cmd = m.textarea.Update(msg)
270	cmds = append(cmds, cmd)
271	return m, tea.Batch(cmds...)
272}
273
274func (m *editorCmp) Cursor() *tea.Cursor {
275	cursor := m.textarea.Cursor()
276	if cursor != nil {
277		cursor.X = cursor.X + m.x + 1
278		cursor.Y = cursor.Y + m.y + 1 // adjust for padding
279	}
280	return cursor
281}
282
283func (m *editorCmp) View() string {
284	t := styles.CurrentTheme()
285	if len(m.attachments) == 0 {
286		content := t.S().Base.Padding(1).Render(
287			m.textarea.View(),
288		)
289		return content
290	}
291	content := t.S().Base.Padding(0, 1, 1, 1).Render(
292		lipgloss.JoinVertical(lipgloss.Top,
293			m.attachmentsContent(),
294			m.textarea.View(),
295		),
296	)
297	return content
298}
299
300func (m *editorCmp) SetSize(width, height int) tea.Cmd {
301	m.width = width
302	m.height = height
303	m.textarea.SetWidth(width - 2)   // adjust for padding
304	m.textarea.SetHeight(height - 2) // adjust for padding
305	return nil
306}
307
308func (m *editorCmp) GetSize() (int, int) {
309	return m.textarea.Width(), m.textarea.Height()
310}
311
312func (m *editorCmp) attachmentsContent() string {
313	var styledAttachments []string
314	t := styles.CurrentTheme()
315	attachmentStyles := t.S().Base.
316		MarginLeft(1).
317		Background(t.FgMuted).
318		Foreground(t.FgBase)
319	for i, attachment := range m.attachments {
320		var filename string
321		if len(attachment.FileName) > 10 {
322			filename = fmt.Sprintf(" %s %s...", styles.DocumentIcon, attachment.FileName[0:7])
323		} else {
324			filename = fmt.Sprintf(" %s %s", styles.DocumentIcon, attachment.FileName)
325		}
326		if m.deleteMode {
327			filename = fmt.Sprintf("%d%s", i, filename)
328		}
329		styledAttachments = append(styledAttachments, attachmentStyles.Render(filename))
330	}
331	content := lipgloss.JoinHorizontal(lipgloss.Left, styledAttachments...)
332	return content
333}
334
335func (m *editorCmp) SetPosition(x, y int) tea.Cmd {
336	m.x = x
337	m.y = y
338	return nil
339}
340
341func (m *editorCmp) startCompletions() tea.Msg {
342	files, _, _ := fsext.ListDirectory(".", []string{}, 0)
343	completionItems := make([]completions.Completion, 0, len(files))
344	for _, file := range files {
345		file = strings.TrimPrefix(file, "./")
346		completionItems = append(completionItems, completions.Completion{
347			Title: file,
348			Value: FileCompletionItem{
349				Path: file,
350			},
351		})
352	}
353
354	x := m.textarea.Cursor().X + m.x + 1
355	y := m.textarea.Cursor().Y + m.y + 1
356	return completions.OpenCompletionsMsg{
357		Completions: completionItems,
358		X:           x,
359		Y:           y,
360	}
361}
362
363// Blur implements Container.
364func (c *editorCmp) Blur() tea.Cmd {
365	c.textarea.Blur()
366	return nil
367}
368
369// Focus implements Container.
370func (c *editorCmp) Focus() tea.Cmd {
371	return c.textarea.Focus()
372}
373
374// IsFocused implements Container.
375func (c *editorCmp) IsFocused() bool {
376	return c.textarea.Focused()
377}
378
379func (c *editorCmp) Bindings() []key.Binding {
380	return c.keyMap.KeyBindings()
381}
382
383// TODO: most likely we do not need to have the session here
384// we need to move some functionality to the page level
385func (c *editorCmp) SetSession(session session.Session) tea.Cmd {
386	c.session = session
387	return nil
388}
389
390func New(app *app.App) Editor {
391	t := styles.CurrentTheme()
392	ta := textarea.New()
393	ta.SetStyles(t.S().TextArea)
394	ta.SetPromptFunc(4, func(lineIndex int, focused bool) string {
395		if lineIndex == 0 {
396			return "  > "
397		}
398		if focused {
399			return t.S().Base.Foreground(t.GreenDark).Render("::: ")
400		} else {
401			return t.S().Muted.Render("::: ")
402		}
403	})
404	ta.ShowLineNumbers = false
405	ta.CharLimit = -1
406	ta.Placeholder = "Tell me more about this project..."
407	ta.SetVirtualCursor(false)
408	ta.Focus()
409
410	return &editorCmp{
411		// TODO: remove the app instance from here
412		app:      app,
413		textarea: ta,
414		keyMap:   DefaultEditorKeyMap(),
415	}
416}