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