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 onPaste(msg tea.PasteMsg) tea.Msg {
251 return filepicker.OnPaste(filepicker.ResolveFS, string(msg))
252}
253
254func activeModelHasImageSupport() (bool, string) {
255 agentCfg := config.Get().Agents["coder"]
256 model := config.Get().GetModelByType(agentCfg.Model)
257 return model.SupportsImages, model.Name
258}
259
260func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
261 var cmd tea.Cmd
262 var cmds []tea.Cmd
263 switch msg := msg.(type) {
264 case tea.WindowSizeMsg:
265 return m, m.repositionCompletions
266 case filepicker.FilePickedMsg:
267 if len(m.attachments) >= maxAttachments {
268 return m, util.ReportError(fmt.Errorf("cannot add more than %d images", maxAttachments))
269 }
270 m.attachments = append(m.attachments, msg.Attachment)
271 return m, nil
272 case completions.CompletionsOpenedMsg:
273 m.isCompletionsOpen = true
274 case completions.CompletionsClosedMsg:
275 m.isCompletionsOpen = false
276 m.currentQuery = ""
277 m.completionsStartIndex = 0
278 case completions.SelectCompletionMsg:
279 if !m.isCompletionsOpen {
280 return m, nil
281 }
282 if item, ok := msg.Value.(FileCompletionItem); ok {
283 return onCompletionItemSelect(os.DirFS("."), activeModelHasImageSupport, item, msg.Insert, m)
284 }
285 case commands.OpenExternalEditorMsg:
286 if m.app.CoderAgent.IsSessionBusy(m.session.ID) {
287 return m, util.ReportWarn("Agent is working, please wait...")
288 }
289 return m, m.openEditor(m.textarea.Value())
290 case OpenEditorMsg:
291 m.textarea.SetValue(msg.Text)
292 m.textarea.MoveToEnd()
293 case tea.PasteMsg:
294 agentCfg := config.Get().Agents["coder"]
295 model := config.Get().GetModelByType(agentCfg.Model)
296 if !model.SupportsImages {
297 return m, util.ReportWarn("File attachments are not supported by the current model: " + model.Name)
298 }
299 return m, util.CmdHandler(onPaste(msg)) // inject fsys accessible from PWD
300 case commands.ToggleYoloModeMsg:
301 m.setEditorPrompt()
302 return m, nil
303 case tea.KeyPressMsg:
304 cur := m.textarea.Cursor()
305 curIdx := m.textarea.Width()*cur.Y + cur.X
306 switch {
307 // Completions
308 case msg.String() == "/" && !m.isCompletionsOpen &&
309 // only show if beginning of prompt, or if previous char is a space or newline:
310 (len(m.textarea.Value()) == 0 || unicode.IsSpace(rune(m.textarea.Value()[len(m.textarea.Value())-1]))):
311 m.isCompletionsOpen = true
312 m.currentQuery = ""
313 m.completionsStartIndex = curIdx
314
315 cmds = append(cmds, m.startCompletions())
316 case m.isCompletionsOpen && curIdx <= m.completionsStartIndex:
317 cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
318 }
319 if key.Matches(msg, DeleteKeyMaps.AttachmentDeleteMode) {
320 m.deleteMode = true
321 return m, nil
322 }
323 if key.Matches(msg, DeleteKeyMaps.DeleteAllAttachments) && m.deleteMode {
324 m.deleteMode = false
325 m.attachments = nil
326 return m, nil
327 }
328 rune := msg.Code
329 if m.deleteMode && unicode.IsDigit(rune) {
330 num := int(rune - '0')
331 m.deleteMode = false
332 if num < 10 && len(m.attachments) > num {
333 if num == 0 {
334 m.attachments = m.attachments[num+1:]
335 } else {
336 m.attachments = slices.Delete(m.attachments, num, num+1)
337 }
338 return m, nil
339 }
340 }
341 if key.Matches(msg, m.keyMap.OpenEditor) {
342 if m.app.CoderAgent.IsSessionBusy(m.session.ID) {
343 return m, util.ReportWarn("Agent is working, please wait...")
344 }
345 return m, m.openEditor(m.textarea.Value())
346 }
347 if key.Matches(msg, DeleteKeyMaps.Escape) {
348 m.deleteMode = false
349 return m, nil
350 }
351 if key.Matches(msg, m.keyMap.Newline) {
352 m.textarea.InsertRune('\n')
353 cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
354 }
355 // History
356 if key.Matches(msg, m.keyMap.Previous) || key.Matches(msg, m.keyMap.Next) {
357 m.textarea.SetValue(m.stepOverHistory(m.getUserMessagesAsText, m.getDirectionFromKey(msg)))
358 }
359 // Handle Enter key
360 if m.textarea.Focused() && key.Matches(msg, m.keyMap.SendMessage) {
361 value := m.textarea.Value()
362 if strings.HasSuffix(value, "\\") {
363 // If the last character is a backslash, remove it and add a newline.
364 m.textarea.SetValue(strings.TrimSuffix(value, "\\"))
365 } else {
366 // Otherwise, send the message
367 return m, m.send()
368 }
369 }
370 }
371
372 m.textarea, cmd = m.textarea.Update(msg)
373 cmds = append(cmds, cmd)
374
375 if m.textarea.Focused() {
376 kp, ok := msg.(tea.KeyPressMsg)
377 if ok {
378 if kp.String() == "space" || m.textarea.Value() == "" {
379 m.isCompletionsOpen = false
380 m.currentQuery = ""
381 m.completionsStartIndex = 0
382 cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
383 } else {
384 word := m.textarea.Word()
385 if strings.HasPrefix(word, "/") {
386 // XXX: wont' work if editing in the middle of the field.
387 m.completionsStartIndex = strings.LastIndex(m.textarea.Value(), word)
388 m.currentQuery = word[1:]
389 x, y := m.completionsPosition()
390 x -= len(m.currentQuery)
391 m.isCompletionsOpen = true
392 cmds = append(cmds,
393 util.CmdHandler(completions.FilterCompletionsMsg{
394 Query: m.currentQuery,
395 Reopen: m.isCompletionsOpen,
396 X: x,
397 Y: y,
398 }),
399 )
400 } else if m.isCompletionsOpen {
401 m.isCompletionsOpen = false
402 m.currentQuery = ""
403 m.completionsStartIndex = 0
404 cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
405 }
406 }
407 }
408 }
409
410 return m, tea.Batch(cmds...)
411}
412
413func (m *editorCmp) setEditorPrompt() {
414 if perm := m.app.Permissions; perm != nil {
415 if perm.SkipRequests() {
416 m.textarea.SetPromptFunc(4, yoloPromptFunc)
417 return
418 }
419 }
420 m.textarea.SetPromptFunc(4, normalPromptFunc)
421}
422
423func (m *editorCmp) completionsPosition() (int, int) {
424 cur := m.textarea.Cursor()
425 if cur == nil {
426 return m.x, m.y + 1 // adjust for padding
427 }
428 x := cur.X + m.x
429 y := cur.Y + m.y + 1 // adjust for padding
430 return x, y
431}
432
433func (m *editorCmp) Cursor() *tea.Cursor {
434 cursor := m.textarea.Cursor()
435 if cursor != nil {
436 cursor.X = cursor.X + m.x + 1
437 cursor.Y = cursor.Y + m.y + 1 // adjust for padding
438 }
439 return cursor
440}
441
442var readyPlaceholders = [...]string{
443 "Ready!",
444 "Ready...",
445 "Ready?",
446 "Ready for instructions",
447}
448
449var workingPlaceholders = [...]string{
450 "Working!",
451 "Working...",
452 "Brrrrr...",
453 "Prrrrrrrr...",
454 "Processing...",
455 "Thinking...",
456}
457
458func (m *editorCmp) randomizePlaceholders() {
459 m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))]
460 m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))]
461}
462
463func (m *editorCmp) View() string {
464 t := styles.CurrentTheme()
465 // Update placeholder
466 if m.app.CoderAgent != nil && m.app.CoderAgent.IsBusy() {
467 m.textarea.Placeholder = m.workingPlaceholder
468 } else {
469 m.textarea.Placeholder = m.readyPlaceholder
470 }
471 if m.app.Permissions.SkipRequests() {
472 m.textarea.Placeholder = "Yolo mode!"
473 }
474 if len(m.attachments) == 0 {
475 content := t.S().Base.Padding(1).Render(
476 m.textarea.View(),
477 )
478 return content
479 }
480 content := t.S().Base.Padding(0, 1, 1, 1).Render(
481 lipgloss.JoinVertical(lipgloss.Top,
482 m.attachmentsContent(),
483 m.textarea.View(),
484 ),
485 )
486 return content
487}
488
489func (m *editorCmp) SetSize(width, height int) tea.Cmd {
490 m.width = width
491 m.height = height
492 m.textarea.SetWidth(width - 2) // adjust for padding
493 m.textarea.SetHeight(height - 2) // adjust for padding
494 return nil
495}
496
497func (m *editorCmp) GetSize() (int, int) {
498 return m.textarea.Width(), m.textarea.Height()
499}
500
501func (m *editorCmp) attachmentsContent() string {
502 var styledAttachments []string
503 t := styles.CurrentTheme()
504 attachmentStyles := t.S().Base.
505 MarginLeft(1).
506 Background(t.FgMuted).
507 Foreground(t.FgBase)
508 for i, attachment := range m.attachments {
509 var filename string
510 if len(attachment.FileName) > 10 {
511 filename = fmt.Sprintf(" %s %s...", styles.DocumentIcon, attachment.FileName[0:7])
512 } else {
513 filename = fmt.Sprintf(" %s %s", styles.DocumentIcon, attachment.FileName)
514 }
515 if m.deleteMode {
516 filename = fmt.Sprintf("%d%s", i, filename)
517 }
518 styledAttachments = append(styledAttachments, attachmentStyles.Render(filename))
519 }
520 content := lipgloss.JoinHorizontal(lipgloss.Left, styledAttachments...)
521 return content
522}
523
524func (m *editorCmp) SetPosition(x, y int) tea.Cmd {
525 m.x = x
526 m.y = y
527 return nil
528}
529
530func (m *editorCmp) startCompletions() func() tea.Msg {
531 return func() tea.Msg {
532 files, _, _ := m.listDirResolver()(".", nil)
533 slices.Sort(files)
534 completionItems := make([]completions.Completion, 0, len(files))
535 for _, file := range files {
536 file = strings.TrimPrefix(file, "./")
537 completionItems = append(completionItems, completions.Completion{
538 Title: file,
539 Value: FileCompletionItem{
540 Path: file,
541 },
542 })
543 }
544
545 x, y := m.completionsPosition()
546 return completions.OpenCompletionsMsg{
547 Completions: completionItems,
548 X: x,
549 Y: y,
550 }
551 }
552}
553
554// Blur implements Container.
555func (c *editorCmp) Blur() tea.Cmd {
556 c.textarea.Blur()
557 return nil
558}
559
560// Focus implements Container.
561func (c *editorCmp) Focus() tea.Cmd {
562 return c.textarea.Focus()
563}
564
565// IsFocused implements Container.
566func (c *editorCmp) IsFocused() bool {
567 return c.textarea.Focused()
568}
569
570// Bindings implements Container.
571func (c *editorCmp) Bindings() []key.Binding {
572 return c.keyMap.KeyBindings()
573}
574
575// TODO: most likely we do not need to have the session here
576// we need to move some functionality to the page level
577func (c *editorCmp) SetSession(session session.Session) tea.Cmd {
578 c.session = session
579 return nil
580}
581
582func (c *editorCmp) IsCompletionsOpen() bool {
583 return c.isCompletionsOpen
584}
585
586func (c *editorCmp) HasAttachments() bool {
587 return len(c.attachments) > 0
588}
589
590func normalPromptFunc(info textarea.PromptInfo) string {
591 t := styles.CurrentTheme()
592 if info.LineNumber == 0 {
593 return " > "
594 }
595 if info.Focused {
596 return t.S().Base.Foreground(t.GreenDark).Render("::: ")
597 }
598 return t.S().Muted.Render("::: ")
599}
600
601func yoloPromptFunc(info textarea.PromptInfo) string {
602 t := styles.CurrentTheme()
603 if info.LineNumber == 0 {
604 if info.Focused {
605 return fmt.Sprintf("%s ", t.YoloIconFocused)
606 } else {
607 return fmt.Sprintf("%s ", t.YoloIconBlurred)
608 }
609 }
610 if info.Focused {
611 return fmt.Sprintf("%s ", t.YoloDotsFocused)
612 }
613 return fmt.Sprintf("%s ", t.YoloDotsBlurred)
614}
615
616func (m *editorCmp) getUserMessagesAsText(ctx context.Context) ([]string, error) {
617 if len(m.historyCache) > 0 {
618 return m.historyCache, nil
619 }
620 allMessages, err := m.app.Messages.List(ctx, m.session.ID)
621 if err != nil {
622 return nil, err
623 }
624
625 var userMessages []string
626 for _, msg := range allMessages {
627 if msg.Role == message.User {
628 userMessages = append(userMessages, msg.Content().Text)
629 }
630 }
631
632 userMessages = append(userMessages, m.textarea.Value())
633 m.historyCache = userMessages
634 return userMessages, nil
635}
636
637type direction int
638
639const (
640 previous = iota
641 next
642)
643
644func (m *editorCmp) getDirectionFromKey(msg tea.KeyPressMsg) func() direction {
645 return func() direction {
646 if key.Matches(msg, m.keyMap.Previous) {
647 return previous
648 }
649 return next
650 }
651}
652
653func (m *editorCmp) stepOverHistory(resolveHistoricMessages func(context.Context) ([]string, error), resolveDirection func() direction) string {
654 // NOTE(tauraamui): the last entry in this list will be the current contents of the input field/box
655 messageHistory, err := resolveHistoricMessages(context.Background())
656 if err != nil {
657 return ""
658 }
659
660 // the list will/should always have at least the current message in the input in the list
661 if len(messageHistory) == 1 {
662 return messageHistory[0]
663 }
664
665 // the first time we invoke scroll we need to start from top of the list
666 if !m.previouslyScrollingPromptHistory {
667 m.promptHistoryIndex = len(messageHistory) - 1
668 m.previouslyScrollingPromptHistory = true
669 }
670
671 switch resolveDirection() {
672 case previous:
673 return m.stepBack(messageHistory)
674 case next:
675 return m.stepForward(messageHistory)
676 }
677 return ""
678}
679
680func (m *editorCmp) stepBack(history []string) string {
681 m.promptHistoryIndex -= 1
682 if m.promptHistoryIndex < 0 {
683 m.promptHistoryIndex = 0
684 }
685 return history[m.promptHistoryIndex]
686}
687
688func (m *editorCmp) stepForward(history []string) string {
689 m.promptHistoryIndex += 1
690 maxIndex := len(history) - 1
691 if m.promptHistoryIndex > maxIndex {
692 m.promptHistoryIndex = maxIndex
693 }
694 return history[m.promptHistoryIndex]
695}
696
697func (m *editorCmp) resetHistory() {
698 m.historyCache = nil
699 m.promptHistoryIndex = 0
700 m.previouslyScrollingPromptHistory = false
701}
702
703func newTextArea() *textarea.Model {
704 t := styles.CurrentTheme()
705 ta := textarea.New()
706 ta.SetStyles(t.S().TextArea)
707 ta.ShowLineNumbers = false
708 ta.CharLimit = -1
709 ta.SetVirtualCursor(false)
710 ta.Focus()
711 return ta
712}
713
714func newEditor(app *app.App, resolveDirLister fsext.DirectoryListerResolver) *editorCmp {
715 e := editorCmp{
716 // TODO: remove the app instance from here
717 app: app,
718 textarea: newTextArea(),
719 keyMap: DefaultEditorKeyMap(),
720 listDirResolver: resolveDirLister,
721 }
722 e.setEditorPrompt()
723
724 e.randomizePlaceholders()
725 e.textarea.Placeholder = e.readyPlaceholder
726
727 return &e
728}
729
730func New(app *app.App) Editor {
731 ls := app.Config().Options.TUI.Completions.Limits
732 return newEditor(app, fsext.ResolveDirectoryLister(ls()))
733}