editor.go

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