1package editor
2
3import (
4 "context"
5 "fmt"
6 "io/fs"
7 "math/rand"
8 "net/http"
9 "os"
10 "os/exec"
11 "path/filepath"
12 "runtime"
13 "slices"
14 "strings"
15 "unicode"
16
17 "github.com/charmbracelet/bubbles/v2/key"
18 "github.com/charmbracelet/bubbles/v2/textarea"
19 tea "github.com/charmbracelet/bubbletea/v2"
20 "github.com/charmbracelet/crush/internal/app"
21 "github.com/charmbracelet/crush/internal/fsext"
22 "github.com/charmbracelet/crush/internal/message"
23 "github.com/charmbracelet/crush/internal/session"
24 "github.com/charmbracelet/crush/internal/tui/components/chat"
25 "github.com/charmbracelet/crush/internal/tui/components/completions"
26 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
27 "github.com/charmbracelet/crush/internal/tui/components/dialogs"
28 "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
29 "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
30 "github.com/charmbracelet/crush/internal/tui/components/dialogs/quit"
31 "github.com/charmbracelet/crush/internal/tui/styles"
32 "github.com/charmbracelet/crush/internal/tui/util"
33 "github.com/charmbracelet/lipgloss/v2"
34)
35
36type Editor interface {
37 util.Model
38 layout.Sizeable
39 layout.Focusable
40 layout.Help
41 layout.Positional
42
43 SetSession(session session.Session) tea.Cmd
44 IsCompletionsOpen() bool
45 HasAttachments() bool
46 Cursor() *tea.Cursor
47}
48
49type FileCompletionItem struct {
50 Path string // The file path
51}
52
53type editorCmp struct {
54 width int
55 height int
56 x, y int
57 app *app.App
58 session session.Session
59 textarea *textarea.Model
60 attachments []message.Attachment
61 deleteMode bool
62 readyPlaceholder string
63 workingPlaceholder string
64
65 keyMap EditorKeyMap
66
67 // injected file dir lister
68 listDirResolver fsext.DirectoryListerResolver
69
70 // File path completions
71 currentQuery string
72 completionsStartIndex int
73 isCompletionsOpen bool
74}
75
76var DeleteKeyMaps = DeleteAttachmentKeyMaps{
77 AttachmentDeleteMode: key.NewBinding(
78 key.WithKeys("ctrl+r"),
79 key.WithHelp("ctrl+r+{i}", "delete attachment at index i"),
80 ),
81 Escape: key.NewBinding(
82 key.WithKeys("esc"),
83 key.WithHelp("esc", "cancel delete mode"),
84 ),
85 DeleteAllAttachments: key.NewBinding(
86 key.WithKeys("r"),
87 key.WithHelp("ctrl+r+r", "delete all attachments"),
88 ),
89}
90
91const (
92 maxAttachments = 5
93)
94
95type OpenEditorMsg struct {
96 Text string
97}
98
99func (m *editorCmp) openEditor(value string) tea.Cmd {
100 editor := os.Getenv("EDITOR")
101 if editor == "" {
102 // Use platform-appropriate default editor
103 if runtime.GOOS == "windows" {
104 editor = "notepad"
105 } else {
106 editor = "nvim"
107 }
108 }
109
110 tmpfile, err := os.CreateTemp("", "msg_*.md")
111 if err != nil {
112 return util.ReportError(err)
113 }
114 defer tmpfile.Close() //nolint:errcheck
115 if _, err := tmpfile.WriteString(value); err != nil {
116 return util.ReportError(err)
117 }
118 c := exec.CommandContext(context.TODO(), editor, tmpfile.Name())
119 c.Stdin = os.Stdin
120 c.Stdout = os.Stdout
121 c.Stderr = os.Stderr
122 return tea.ExecProcess(c, func(err error) tea.Msg {
123 if err != nil {
124 return util.ReportError(err)
125 }
126 content, err := os.ReadFile(tmpfile.Name())
127 if err != nil {
128 return util.ReportError(err)
129 }
130 if len(content) == 0 {
131 return util.ReportWarn("Message is empty")
132 }
133 os.Remove(tmpfile.Name())
134 return OpenEditorMsg{
135 Text: strings.TrimSpace(string(content)),
136 }
137 })
138}
139
140func (m *editorCmp) Init() tea.Cmd {
141 return nil
142}
143
144func (m *editorCmp) send() tea.Cmd {
145 value := m.textarea.Value()
146 value = strings.TrimSpace(value)
147
148 switch value {
149 case "exit", "quit":
150 m.textarea.Reset()
151 return util.CmdHandler(dialogs.OpenDialogMsg{Model: quit.NewQuitDialog()})
152 }
153
154 m.textarea.Reset()
155 attachments := m.attachments
156
157 m.attachments = nil
158 if value == "" {
159 return nil
160 }
161
162 // Change the placeholder when sending a new message.
163 m.randomizePlaceholders()
164
165 return tea.Batch(
166 util.CmdHandler(chat.SendMsg{
167 Text: value,
168 Attachments: attachments,
169 }),
170 )
171}
172
173func (m *editorCmp) repositionCompletions() tea.Msg {
174 x, y := m.completionsPosition()
175 return completions.RepositionCompletionsMsg{X: x, Y: y}
176}
177
178func onCompletionItemSelect(fsys fs.FS, item FileCompletionItem, insert bool, m *editorCmp) (tea.Model, tea.Cmd) {
179 var cmd tea.Cmd
180 path := item.Path
181 // check if item is an image
182 if isExtOfAllowedImageType(path) {
183 tooBig, _ := filepicker.IsFileTooBigWithFS(fsys, path, filepicker.MaxAttachmentSize)
184 if tooBig {
185 return m, nil
186 }
187
188 content, err := fs.ReadFile(fsys, path)
189 if err != nil {
190 return m, nil
191 }
192 mimeBufferSize := min(512, len(content))
193 mimeType := http.DetectContentType(content[:mimeBufferSize])
194 fileName := filepath.Base(path)
195 attachment := message.Attachment{FilePath: path, FileName: fileName, MimeType: mimeType, Content: content}
196 cmd = util.CmdHandler(filepicker.FilePickedMsg{
197 Attachment: attachment,
198 })
199 }
200
201 word := m.textarea.Word()
202 // If the selected item is a file, insert its path into the textarea
203 originalValue := m.textarea.Value()
204 newValue := originalValue[:m.completionsStartIndex] // Remove the current query
205 if cmd == nil {
206 newValue += path // insert the file path for non-images
207 }
208 newValue += originalValue[m.completionsStartIndex+len(word):] // Append the rest of the value
209 // XXX: This will always move the cursor to the end of the textarea.
210 m.textarea.SetValue(newValue)
211 m.textarea.MoveToEnd()
212 if !insert {
213 m.isCompletionsOpen = false
214 m.currentQuery = ""
215 m.completionsStartIndex = 0
216 }
217
218 return m, cmd
219}
220
221func isExtOfAllowedImageType(path string) bool {
222 isAllowedType := false
223 // TODO(tauraamui) [17/09/2025]: this needs to be combined with the actual data inference/checking
224 // of the contents that happens when we resolve the "mime" type
225 for _, ext := range filepicker.AllowedTypes {
226 if strings.HasSuffix(path, ext) {
227 isAllowedType = true
228 break
229 }
230 }
231 return isAllowedType
232}
233
234type ResolveAbs func(path string) (string, error)
235
236func onPaste(fsys fs.FS, fsysAbs ResolveAbs, m *editorCmp, msg tea.PasteMsg) (tea.Model, tea.Cmd) {
237 var cmd tea.Cmd
238 path := strings.ReplaceAll(string(msg), "\\ ", " ")
239 // try to get an image, in this case specifically because the file
240 // path cannot have been limited to just the PWD as the root, since the
241 // path is coming from the contents of a clipboard
242 path, err := fsysAbs(strings.TrimSpace(path))
243 if err != nil {
244 m.textarea, cmd = m.textarea.Update(msg)
245 return m, cmd
246 }
247 // TODO(tauraamui) [17/09/2025]: this needs to be combined with the actual data inference/checking
248 // of the contents that happens when we resolve the "mime" type
249 if !isExtOfAllowedImageType(path) {
250 m.textarea, cmd = m.textarea.Update(msg)
251 return m, cmd
252 }
253 tooBig, _ := filepicker.IsFileTooBigWithFS(fsys, path, filepicker.MaxAttachmentSize)
254 if tooBig {
255 m.textarea, cmd = m.textarea.Update(msg)
256 return m, cmd
257 }
258
259 // FIX(tauraamui) [19/09/2025]: this is incorrectly attempting to read a file from its abs path,
260 // whereas the FS we're accessing only starts from our relative dir/PWD
261 content, err := fs.ReadFile(fsys, path)
262 if err != nil {
263 m.textarea, cmd = m.textarea.Update(msg)
264 return m, cmd
265 }
266 mimeBufferSize := min(512, len(content))
267 mimeType := http.DetectContentType(content[:mimeBufferSize])
268 fileName := filepath.Base(path)
269 attachment := message.Attachment{FilePath: path, FileName: fileName, MimeType: mimeType, Content: content}
270 return m, util.CmdHandler(filepicker.FilePickedMsg{
271 Attachment: attachment,
272 })
273}
274
275func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
276 var cmd tea.Cmd
277 var cmds []tea.Cmd
278 switch msg := msg.(type) {
279 case tea.WindowSizeMsg:
280 return m, m.repositionCompletions
281 case filepicker.FilePickedMsg:
282 if len(m.attachments) >= maxAttachments {
283 return m, util.ReportError(fmt.Errorf("cannot add more than %d images", maxAttachments))
284 }
285 m.attachments = append(m.attachments, msg.Attachment)
286 return m, nil
287 case completions.CompletionsOpenedMsg:
288 m.isCompletionsOpen = true
289 case completions.CompletionsClosedMsg:
290 m.isCompletionsOpen = false
291 m.currentQuery = ""
292 m.completionsStartIndex = 0
293 // NOTE(tauraamui) [15/09/2025]: this is the correct location to add picture attaching
294 case completions.SelectCompletionMsg:
295 if !m.isCompletionsOpen {
296 return m, nil
297 }
298 if item, ok := msg.Value.(FileCompletionItem); ok {
299 return onCompletionItemSelect(os.DirFS("."), item, msg.Insert, m)
300 }
301 case commands.OpenExternalEditorMsg:
302 if m.app.CoderAgent.IsSessionBusy(m.session.ID) {
303 return m, util.ReportWarn("Agent is working, please wait...")
304 }
305 return m, m.openEditor(m.textarea.Value())
306 case OpenEditorMsg:
307 m.textarea.SetValue(msg.Text)
308 m.textarea.MoveToEnd()
309 case tea.PasteMsg:
310 return onPaste(os.DirFS("."), filepath.Abs, m, msg) // inject fsys accessible from PWD
311 case commands.ToggleYoloModeMsg:
312 m.setEditorPrompt()
313 return m, nil
314 case tea.KeyPressMsg:
315 cur := m.textarea.Cursor()
316 curIdx := m.textarea.Width()*cur.Y + cur.X
317 switch {
318 // Completions
319 case msg.String() == "/" && !m.isCompletionsOpen &&
320 // only show if beginning of prompt, or if previous char is a space or newline:
321 (len(m.textarea.Value()) == 0 || unicode.IsSpace(rune(m.textarea.Value()[len(m.textarea.Value())-1]))):
322 m.isCompletionsOpen = true
323 m.currentQuery = ""
324 m.completionsStartIndex = curIdx
325 cmds = append(cmds, m.startCompletions)
326 case m.isCompletionsOpen && curIdx <= m.completionsStartIndex:
327 cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
328 }
329 if key.Matches(msg, DeleteKeyMaps.AttachmentDeleteMode) {
330 m.deleteMode = true
331 return m, nil
332 }
333 if key.Matches(msg, DeleteKeyMaps.DeleteAllAttachments) && m.deleteMode {
334 m.deleteMode = false
335 m.attachments = nil
336 return m, nil
337 }
338 rune := msg.Code
339 if m.deleteMode && unicode.IsDigit(rune) {
340 num := int(rune - '0')
341 m.deleteMode = false
342 if num < 10 && len(m.attachments) > num {
343 if num == 0 {
344 m.attachments = m.attachments[num+1:]
345 } else {
346 m.attachments = slices.Delete(m.attachments, num, num+1)
347 }
348 return m, nil
349 }
350 }
351 if key.Matches(msg, m.keyMap.OpenEditor) {
352 if m.app.CoderAgent.IsSessionBusy(m.session.ID) {
353 return m, util.ReportWarn("Agent is working, please wait...")
354 }
355 return m, m.openEditor(m.textarea.Value())
356 }
357 if key.Matches(msg, DeleteKeyMaps.Escape) {
358 m.deleteMode = false
359 return m, nil
360 }
361 if key.Matches(msg, m.keyMap.Newline) {
362 m.textarea.InsertRune('\n')
363 cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
364 }
365 // Handle Enter key
366 if m.textarea.Focused() && key.Matches(msg, m.keyMap.SendMessage) {
367 value := m.textarea.Value()
368 if strings.HasSuffix(value, "\\") {
369 // If the last character is a backslash, remove it and add a newline.
370 m.textarea.SetValue(strings.TrimSuffix(value, "\\"))
371 } else {
372 // Otherwise, send the message
373 return m, m.send()
374 }
375 }
376 }
377
378 m.textarea, cmd = m.textarea.Update(msg)
379 cmds = append(cmds, cmd)
380
381 if m.textarea.Focused() {
382 kp, ok := msg.(tea.KeyPressMsg)
383 if ok {
384 if kp.String() == "space" || m.textarea.Value() == "" {
385 m.isCompletionsOpen = false
386 m.currentQuery = ""
387 m.completionsStartIndex = 0
388 cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
389 } else {
390 word := m.textarea.Word()
391 if strings.HasPrefix(word, "/") {
392 // XXX: wont' work if editing in the middle of the field.
393 m.completionsStartIndex = strings.LastIndex(m.textarea.Value(), word)
394 m.currentQuery = word[1:]
395 x, y := m.completionsPosition()
396 x -= len(m.currentQuery)
397 m.isCompletionsOpen = true
398 cmds = append(cmds,
399 util.CmdHandler(completions.FilterCompletionsMsg{
400 Query: m.currentQuery,
401 Reopen: m.isCompletionsOpen,
402 X: x,
403 Y: y,
404 }),
405 )
406 } else if m.isCompletionsOpen {
407 m.isCompletionsOpen = false
408 m.currentQuery = ""
409 m.completionsStartIndex = 0
410 cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
411 }
412 }
413 }
414 }
415
416 return m, tea.Batch(cmds...)
417}
418
419func (m *editorCmp) setEditorPrompt() {
420 if perm := m.app.Permissions; perm != nil {
421 if perm.SkipRequests() {
422 m.textarea.SetPromptFunc(4, yoloPromptFunc)
423 return
424 }
425 }
426 m.textarea.SetPromptFunc(4, normalPromptFunc)
427}
428
429func (m *editorCmp) completionsPosition() (int, int) {
430 cur := m.textarea.Cursor()
431 if cur == nil {
432 return m.x, m.y + 1 // adjust for padding
433 }
434 x := cur.X + m.x
435 y := cur.Y + m.y + 1 // adjust for padding
436 return x, y
437}
438
439func (m *editorCmp) Cursor() *tea.Cursor {
440 cursor := m.textarea.Cursor()
441 if cursor != nil {
442 cursor.X = cursor.X + m.x + 1
443 cursor.Y = cursor.Y + m.y + 1 // adjust for padding
444 }
445 return cursor
446}
447
448var readyPlaceholders = [...]string{
449 "Ready!",
450 "Ready...",
451 "Ready?",
452 "Ready for instructions",
453}
454
455var workingPlaceholders = [...]string{
456 "Working!",
457 "Working...",
458 "Brrrrr...",
459 "Prrrrrrrr...",
460 "Processing...",
461 "Thinking...",
462}
463
464func (m *editorCmp) randomizePlaceholders() {
465 m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))]
466 m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))]
467}
468
469func (m *editorCmp) View() string {
470 t := styles.CurrentTheme()
471 // Update placeholder
472 if m.app.CoderAgent != nil && m.app.CoderAgent.IsBusy() {
473 m.textarea.Placeholder = m.workingPlaceholder
474 } else {
475 m.textarea.Placeholder = m.readyPlaceholder
476 }
477 if m.app.Permissions.SkipRequests() {
478 m.textarea.Placeholder = "Yolo mode!"
479 }
480 if len(m.attachments) == 0 {
481 content := t.S().Base.Padding(1).Render(
482 m.textarea.View(),
483 )
484 return content
485 }
486 content := t.S().Base.Padding(0, 1, 1, 1).Render(
487 lipgloss.JoinVertical(lipgloss.Top,
488 m.attachmentsContent(),
489 m.textarea.View(),
490 ),
491 )
492 return content
493}
494
495func (m *editorCmp) SetSize(width, height int) tea.Cmd {
496 m.width = width
497 m.height = height
498 m.textarea.SetWidth(width - 2) // adjust for padding
499 m.textarea.SetHeight(height - 2) // adjust for padding
500 return nil
501}
502
503func (m *editorCmp) GetSize() (int, int) {
504 return m.textarea.Width(), m.textarea.Height()
505}
506
507func (m *editorCmp) attachmentsContent() string {
508 var styledAttachments []string
509 t := styles.CurrentTheme()
510 attachmentStyles := t.S().Base.
511 MarginLeft(1).
512 Background(t.FgMuted).
513 Foreground(t.FgBase)
514 for i, attachment := range m.attachments {
515 var filename string
516 if len(attachment.FileName) > 10 {
517 filename = fmt.Sprintf(" %s %s...", styles.DocumentIcon, attachment.FileName[0:7])
518 } else {
519 filename = fmt.Sprintf(" %s %s", styles.DocumentIcon, attachment.FileName)
520 }
521 if m.deleteMode {
522 filename = fmt.Sprintf("%d%s", i, filename)
523 }
524 styledAttachments = append(styledAttachments, attachmentStyles.Render(filename))
525 }
526 content := lipgloss.JoinHorizontal(lipgloss.Left, styledAttachments...)
527 return content
528}
529
530func (m *editorCmp) SetPosition(x, y int) tea.Cmd {
531 m.x = x
532 m.y = y
533 return nil
534}
535
536func (m *editorCmp) startCompletions() tea.Msg {
537 files, _, _ := m.listDirResolver()(".", nil, 0)
538 slices.Sort(files)
539 completionItems := make([]completions.Completion, 0, len(files))
540 for _, file := range files {
541 file = strings.TrimPrefix(file, "./")
542 completionItems = append(completionItems, completions.Completion{
543 Title: file,
544 Value: FileCompletionItem{
545 Path: file,
546 },
547 })
548 }
549
550 x, y := m.completionsPosition()
551 return completions.OpenCompletionsMsg{
552 Completions: completionItems,
553 X: x,
554 Y: y,
555 }
556}
557
558// Blur implements Container.
559func (c *editorCmp) Blur() tea.Cmd {
560 c.textarea.Blur()
561 return nil
562}
563
564// Focus implements Container.
565func (c *editorCmp) Focus() tea.Cmd {
566 return c.textarea.Focus()
567}
568
569// IsFocused implements Container.
570func (c *editorCmp) IsFocused() bool {
571 return c.textarea.Focused()
572}
573
574// Bindings implements Container.
575func (c *editorCmp) Bindings() []key.Binding {
576 return c.keyMap.KeyBindings()
577}
578
579// TODO: most likely we do not need to have the session here
580// we need to move some functionality to the page level
581func (c *editorCmp) SetSession(session session.Session) tea.Cmd {
582 c.session = session
583 return nil
584}
585
586func (c *editorCmp) IsCompletionsOpen() bool {
587 return c.isCompletionsOpen
588}
589
590func (c *editorCmp) HasAttachments() bool {
591 return len(c.attachments) > 0
592}
593
594func normalPromptFunc(info textarea.PromptInfo) string {
595 t := styles.CurrentTheme()
596 if info.LineNumber == 0 {
597 return " > "
598 }
599 if info.Focused {
600 return t.S().Base.Foreground(t.GreenDark).Render("::: ")
601 }
602 return t.S().Muted.Render("::: ")
603}
604
605func yoloPromptFunc(info textarea.PromptInfo) string {
606 t := styles.CurrentTheme()
607 if info.LineNumber == 0 {
608 if info.Focused {
609 return fmt.Sprintf("%s ", t.YoloIconFocused)
610 } else {
611 return fmt.Sprintf("%s ", t.YoloIconBlurred)
612 }
613 }
614 if info.Focused {
615 return fmt.Sprintf("%s ", t.YoloDotsFocused)
616 }
617 return fmt.Sprintf("%s ", t.YoloDotsBlurred)
618}
619
620func newTextArea() *textarea.Model {
621 t := styles.CurrentTheme()
622 ta := textarea.New()
623 ta.SetStyles(t.S().TextArea)
624 ta.ShowLineNumbers = false
625 ta.CharLimit = -1
626 ta.SetVirtualCursor(false)
627 ta.Focus()
628 return ta
629}
630
631func newEditor(app *app.App, resolveDirLister fsext.DirectoryListerResolver) *editorCmp {
632 e := editorCmp{
633 // TODO: remove the app instance from here
634 app: app,
635 textarea: newTextArea(),
636 keyMap: DefaultEditorKeyMap(),
637 listDirResolver: resolveDirLister,
638 }
639 e.setEditorPrompt()
640
641 e.randomizePlaceholders()
642 e.textarea.Placeholder = e.readyPlaceholder
643
644 return &e
645}
646
647func New(app *app.App) Editor {
648 return newEditor(app, fsext.ResolveDirectoryLister)
649}