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