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