1package editor
2
3import (
4 "fmt"
5 "net/http"
6 "os"
7 "os/exec"
8 "path/filepath"
9 "runtime"
10 "slices"
11 "strings"
12 "unicode"
13
14 "github.com/charmbracelet/bubbles/v2/key"
15 "github.com/charmbracelet/bubbles/v2/textarea"
16 tea "github.com/charmbracelet/bubbletea/v2"
17 "github.com/charmbracelet/crush/internal/app"
18 "github.com/charmbracelet/crush/internal/fsext"
19 "github.com/charmbracelet/crush/internal/message"
20 "github.com/charmbracelet/crush/internal/session"
21 "github.com/charmbracelet/crush/internal/tui/components/chat"
22 "github.com/charmbracelet/crush/internal/tui/components/completions"
23 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
24 "github.com/charmbracelet/crush/internal/tui/components/dialogs"
25 "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
26 "github.com/charmbracelet/crush/internal/tui/components/dialogs/quit"
27 "github.com/charmbracelet/crush/internal/tui/styles"
28 "github.com/charmbracelet/crush/internal/tui/util"
29 "github.com/charmbracelet/lipgloss/v2"
30)
31
32type Editor interface {
33 util.Model
34 layout.Sizeable
35 layout.Focusable
36 layout.Help
37 layout.Positional
38
39 SetSession(session session.Session) tea.Cmd
40 IsCompletionsOpen() bool
41 Cursor() *tea.Cursor
42}
43
44type FileCompletionItem struct {
45 Path string // The file path
46}
47
48type editorCmp struct {
49 width int
50 height int
51 x, y int
52 app *app.App
53 session session.Session
54 textarea textarea.Model
55 attachments []message.Attachment
56 deleteMode bool
57
58 keyMap EditorKeyMap
59
60 // File path completions
61 currentQuery string
62 completionsStartIndex int
63 isCompletionsOpen bool
64}
65
66var DeleteKeyMaps = DeleteAttachmentKeyMaps{
67 AttachmentDeleteMode: key.NewBinding(
68 key.WithKeys("ctrl+r"),
69 key.WithHelp("ctrl+r+{i}", "delete attachment at index i"),
70 ),
71 Escape: key.NewBinding(
72 key.WithKeys("esc"),
73 key.WithHelp("esc", "cancel delete mode"),
74 ),
75 DeleteAllAttachments: key.NewBinding(
76 key.WithKeys("r"),
77 key.WithHelp("ctrl+r+r", "delete all attachments"),
78 ),
79}
80
81const (
82 maxAttachments = 5
83)
84
85type OpenEditorMsg struct {
86 Text string
87}
88
89func (m *editorCmp) openEditor(value string) tea.Cmd {
90 editor := os.Getenv("EDITOR")
91 if editor == "" {
92 // Use platform-appropriate default editor
93 if runtime.GOOS == "windows" {
94 editor = "notepad"
95 } else {
96 editor = "nvim"
97 }
98 }
99
100 tmpfile, err := os.CreateTemp("", "msg_*.md")
101 if err != nil {
102 return util.ReportError(err)
103 }
104 defer tmpfile.Close() //nolint:errcheck
105 if _, err := tmpfile.WriteString(value); err != nil {
106 return util.ReportError(err)
107 }
108 c := exec.Command(editor, tmpfile.Name())
109 c.Stdin = os.Stdin
110 c.Stdout = os.Stdout
111 c.Stderr = os.Stderr
112 return tea.ExecProcess(c, func(err error) tea.Msg {
113 if err != nil {
114 return util.ReportError(err)
115 }
116 content, err := os.ReadFile(tmpfile.Name())
117 if err != nil {
118 return util.ReportError(err)
119 }
120 if len(content) == 0 {
121 return util.ReportWarn("Message is empty")
122 }
123 os.Remove(tmpfile.Name())
124 return OpenEditorMsg{
125 Text: strings.TrimSpace(string(content)),
126 }
127 })
128}
129
130func (m *editorCmp) Init() tea.Cmd {
131 return nil
132}
133
134func (m *editorCmp) send() tea.Cmd {
135 if m.app.CoderAgent == nil {
136 return util.ReportError(fmt.Errorf("coder agent is not initialized"))
137 }
138 if m.app.CoderAgent.IsSessionBusy(m.session.ID) {
139 return util.ReportWarn("Agent is working, please wait...")
140 }
141
142 value := m.textarea.Value()
143 value = strings.TrimSpace(value)
144
145 switch value {
146 case "exit", "quit":
147 m.textarea.Reset()
148 return util.CmdHandler(dialogs.OpenDialogMsg{Model: quit.NewQuitDialog()})
149 }
150
151 m.textarea.Reset()
152 attachments := m.attachments
153
154 m.attachments = nil
155 if value == "" {
156 return nil
157 }
158 return tea.Batch(
159 util.CmdHandler(chat.SendMsg{
160 Text: value,
161 Attachments: attachments,
162 }),
163 )
164}
165
166func (m *editorCmp) repositionCompletions() tea.Msg {
167 x, y := m.completionsPosition()
168 return completions.RepositionCompletionsMsg{X: x, Y: y}
169}
170
171func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
172 var cmd tea.Cmd
173 var cmds []tea.Cmd
174 switch msg := msg.(type) {
175 case tea.WindowSizeMsg:
176 return m, m.repositionCompletions
177 case filepicker.FilePickedMsg:
178 if len(m.attachments) >= maxAttachments {
179 return m, util.ReportError(fmt.Errorf("cannot add more than %d images", maxAttachments))
180 }
181 m.attachments = append(m.attachments, msg.Attachment)
182 return m, nil
183 case completions.CompletionsOpenedMsg:
184 m.isCompletionsOpen = true
185 case completions.CompletionsClosedMsg:
186 m.isCompletionsOpen = false
187 m.currentQuery = ""
188 m.completionsStartIndex = 0
189 case completions.SelectCompletionMsg:
190 if !m.isCompletionsOpen {
191 return m, nil
192 }
193 if item, ok := msg.Value.(FileCompletionItem); ok {
194 word := m.textarea.Word()
195 // If the selected item is a file, insert its path into the textarea
196 value := m.textarea.Value()
197 value = value[:m.completionsStartIndex] + // Remove the current query
198 item.Path + // Insert the file path
199 value[m.completionsStartIndex+len(word):] // Append the rest of the value
200 // XXX: This will always move the cursor to the end of the textarea.
201 m.textarea.SetValue(value)
202 m.textarea.MoveToEnd()
203 if !msg.Insert {
204 m.isCompletionsOpen = false
205 m.currentQuery = ""
206 m.completionsStartIndex = 0
207 }
208 }
209 case OpenEditorMsg:
210 m.textarea.SetValue(msg.Text)
211 m.textarea.MoveToEnd()
212 case tea.PasteMsg:
213 path := strings.ReplaceAll(string(msg), "\\ ", " ")
214 // try to get an image
215 path, err := filepath.Abs(path)
216 if err != nil {
217 m.textarea, cmd = m.textarea.Update(msg)
218 return m, cmd
219 }
220 isAllowedType := false
221 for _, ext := range filepicker.AllowedTypes {
222 if strings.HasSuffix(path, ext) {
223 isAllowedType = true
224 break
225 }
226 }
227 if !isAllowedType {
228 m.textarea, cmd = m.textarea.Update(msg)
229 return m, cmd
230 }
231 tooBig, _ := filepicker.IsFileTooBig(path, filepicker.MaxAttachmentSize)
232 if tooBig {
233 m.textarea, cmd = m.textarea.Update(msg)
234 return m, cmd
235 }
236
237 content, err := os.ReadFile(path)
238 if err != nil {
239 m.textarea, cmd = m.textarea.Update(msg)
240 return m, cmd
241 }
242 mimeBufferSize := min(512, len(content))
243 mimeType := http.DetectContentType(content[:mimeBufferSize])
244 fileName := filepath.Base(path)
245 attachment := message.Attachment{FilePath: path, FileName: fileName, MimeType: mimeType, Content: content}
246 return m, util.CmdHandler(filepicker.FilePickedMsg{
247 Attachment: attachment,
248 })
249
250 case tea.KeyPressMsg:
251 cur := m.textarea.Cursor()
252 curIdx := m.textarea.Width()*cur.Y + cur.X
253 switch {
254 // Completions
255 case msg.String() == "/" && !m.isCompletionsOpen &&
256 // only show if beginning of prompt, or if previous char is a space or newline:
257 (len(m.textarea.Value()) == 0 || unicode.IsSpace(rune(m.textarea.Value()[len(m.textarea.Value())-1]))):
258 m.isCompletionsOpen = true
259 m.currentQuery = ""
260 m.completionsStartIndex = curIdx
261 cmds = append(cmds, m.startCompletions)
262 case m.isCompletionsOpen && curIdx <= m.completionsStartIndex:
263 cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
264 }
265 if key.Matches(msg, DeleteKeyMaps.AttachmentDeleteMode) {
266 m.deleteMode = true
267 return m, nil
268 }
269 if key.Matches(msg, DeleteKeyMaps.DeleteAllAttachments) && m.deleteMode {
270 m.deleteMode = false
271 m.attachments = nil
272 return m, nil
273 }
274 rune := msg.Code
275 if m.deleteMode && unicode.IsDigit(rune) {
276 num := int(rune - '0')
277 m.deleteMode = false
278 if num < 10 && len(m.attachments) > num {
279 if num == 0 {
280 m.attachments = m.attachments[num+1:]
281 } else {
282 m.attachments = slices.Delete(m.attachments, num, num+1)
283 }
284 return m, nil
285 }
286 }
287 if key.Matches(msg, m.keyMap.OpenEditor) {
288 if m.app.CoderAgent.IsSessionBusy(m.session.ID) {
289 return m, util.ReportWarn("Agent is working, please wait...")
290 }
291 return m, m.openEditor(m.textarea.Value())
292 }
293 if key.Matches(msg, DeleteKeyMaps.Escape) {
294 m.deleteMode = false
295 return m, nil
296 }
297 if key.Matches(msg, m.keyMap.Newline) {
298 m.textarea.InsertRune('\n')
299 cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
300 }
301 // Handle Enter key
302 if m.textarea.Focused() && key.Matches(msg, m.keyMap.SendMessage) {
303 value := m.textarea.Value()
304 if len(value) > 0 && value[len(value)-1] == '\\' {
305 // If the last character is a backslash, remove it and add a newline
306 m.textarea.SetValue(value[:len(value)-1])
307 } else {
308 // Otherwise, send the message
309 return m, m.send()
310 }
311 }
312 }
313
314 m.textarea, cmd = m.textarea.Update(msg)
315 cmds = append(cmds, cmd)
316
317 if m.textarea.Focused() {
318 kp, ok := msg.(tea.KeyPressMsg)
319 if ok {
320 if kp.String() == "space" || m.textarea.Value() == "" {
321 m.isCompletionsOpen = false
322 m.currentQuery = ""
323 m.completionsStartIndex = 0
324 cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
325 } else {
326 word := m.textarea.Word()
327 if strings.HasPrefix(word, "/") {
328 // XXX: wont' work if editing in the middle of the field.
329 m.completionsStartIndex = strings.LastIndex(m.textarea.Value(), word)
330 m.currentQuery = word[1:]
331 x, y := m.completionsPosition()
332 x -= len(m.currentQuery)
333 m.isCompletionsOpen = true
334 cmds = append(cmds,
335 util.CmdHandler(completions.FilterCompletionsMsg{
336 Query: m.currentQuery,
337 Reopen: m.isCompletionsOpen,
338 X: x,
339 Y: y,
340 }),
341 )
342 } else if m.isCompletionsOpen {
343 m.isCompletionsOpen = false
344 m.currentQuery = ""
345 m.completionsStartIndex = 0
346 cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
347 }
348 }
349 }
350 }
351
352 return m, tea.Batch(cmds...)
353}
354
355func (m *editorCmp) completionsPosition() (int, int) {
356 cur := m.textarea.Cursor()
357 if cur == nil {
358 return m.x, m.y + 1 // adjust for padding
359 }
360 x := cur.X + m.x
361 y := cur.Y + m.y + 1 // adjust for padding
362 return x, y
363}
364
365func (m *editorCmp) Cursor() *tea.Cursor {
366 cursor := m.textarea.Cursor()
367 if cursor != nil {
368 cursor.X = cursor.X + m.x + 1
369 cursor.Y = cursor.Y + m.y + 1 // adjust for padding
370 }
371 return cursor
372}
373
374func (m *editorCmp) View() string {
375 t := styles.CurrentTheme()
376 if len(m.attachments) == 0 {
377 content := t.S().Base.Padding(1).Render(
378 m.textarea.View(),
379 )
380 return content
381 }
382 content := t.S().Base.Padding(0, 1, 1, 1).Render(
383 lipgloss.JoinVertical(lipgloss.Top,
384 m.attachmentsContent(),
385 m.textarea.View(),
386 ),
387 )
388 return content
389}
390
391func (m *editorCmp) SetSize(width, height int) tea.Cmd {
392 m.width = width
393 m.height = height
394 m.textarea.SetWidth(width - 2) // adjust for padding
395 m.textarea.SetHeight(height - 2) // adjust for padding
396 return nil
397}
398
399func (m *editorCmp) GetSize() (int, int) {
400 return m.textarea.Width(), m.textarea.Height()
401}
402
403func (m *editorCmp) attachmentsContent() string {
404 var styledAttachments []string
405 t := styles.CurrentTheme()
406 attachmentStyles := t.S().Base.
407 MarginLeft(1).
408 Background(t.FgMuted).
409 Foreground(t.FgBase)
410 for i, attachment := range m.attachments {
411 var filename string
412 if len(attachment.FileName) > 10 {
413 filename = fmt.Sprintf(" %s %s...", styles.DocumentIcon, attachment.FileName[0:7])
414 } else {
415 filename = fmt.Sprintf(" %s %s", styles.DocumentIcon, attachment.FileName)
416 }
417 if m.deleteMode {
418 filename = fmt.Sprintf("%d%s", i, filename)
419 }
420 styledAttachments = append(styledAttachments, attachmentStyles.Render(filename))
421 }
422 content := lipgloss.JoinHorizontal(lipgloss.Left, styledAttachments...)
423 return content
424}
425
426func (m *editorCmp) SetPosition(x, y int) tea.Cmd {
427 m.x = x
428 m.y = y
429 return nil
430}
431
432func (m *editorCmp) startCompletions() tea.Msg {
433 files, _, _ := fsext.ListDirectory(".", []string{}, 0)
434 completionItems := make([]completions.Completion, 0, len(files))
435 for _, file := range files {
436 file = strings.TrimPrefix(file, "./")
437 completionItems = append(completionItems, completions.Completion{
438 Title: file,
439 Value: FileCompletionItem{
440 Path: file,
441 },
442 })
443 }
444
445 x, y := m.completionsPosition()
446 return completions.OpenCompletionsMsg{
447 Completions: completionItems,
448 X: x,
449 Y: y,
450 }
451}
452
453// Blur implements Container.
454func (c *editorCmp) Blur() tea.Cmd {
455 c.textarea.Blur()
456 return nil
457}
458
459// Focus implements Container.
460func (c *editorCmp) Focus() tea.Cmd {
461 return c.textarea.Focus()
462}
463
464// IsFocused implements Container.
465func (c *editorCmp) IsFocused() bool {
466 return c.textarea.Focused()
467}
468
469// Bindings implements Container.
470func (c *editorCmp) Bindings() []key.Binding {
471 return c.keyMap.KeyBindings()
472}
473
474// TODO: most likely we do not need to have the session here
475// we need to move some functionality to the page level
476func (c *editorCmp) SetSession(session session.Session) tea.Cmd {
477 c.session = session
478 return nil
479}
480
481func (c *editorCmp) IsCompletionsOpen() bool {
482 return c.isCompletionsOpen
483}
484
485func New(app *app.App) Editor {
486 t := styles.CurrentTheme()
487 ta := textarea.New()
488 ta.SetStyles(t.S().TextArea)
489 ta.SetPromptFunc(4, func(info textarea.PromptInfo) string {
490 if info.LineNumber == 0 {
491 return " > "
492 }
493 if info.Focused {
494 return t.S().Base.Foreground(t.GreenDark).Render("::: ")
495 } else {
496 return t.S().Muted.Render("::: ")
497 }
498 })
499 ta.ShowLineNumbers = false
500 ta.CharLimit = -1
501 ta.Placeholder = "Tell me more about this project..."
502 ta.SetVirtualCursor(false)
503 ta.Focus()
504
505 return &editorCmp{
506 // TODO: remove the app instance from here
507 app: app,
508 textarea: ta,
509 keyMap: DefaultEditorKeyMap(),
510 }
511}