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
178// NOTE(tauraamui) [12/09/2025]: I want to use this shared logic to denote an attachment event
179func handleSelectedPath(path string) {
180 /*
181 path = strings.ReplaceAll(string(path), "\\ ", " ")
182 // try to get an image
183 path, err := filepath.Abs(strings.TrimSpace(path))
184 if err != nil {
185 m.textarea, cmd = m.textarea.Update(msg)
186 return m, cmd
187 }
188 isAllowedType := false
189 for _, ext := range filepicker.AllowedTypes {
190 if strings.HasSuffix(path, ext) {
191 isAllowedType = true
192 break
193 }
194 }
195 if !isAllowedType {
196 m.textarea, cmd = m.textarea.Update(msg)
197 return m, cmd
198 }
199 tooBig, _ := filepicker.IsFileTooBig(path, filepicker.MaxAttachmentSize)
200 if tooBig {
201 m.textarea, cmd = m.textarea.Update(msg)
202 return m, cmd
203 }
204
205 content, err := os.ReadFile(path)
206 if err != nil {
207 m.textarea, cmd = m.textarea.Update(msg)
208 return m, cmd
209 }
210 mimeBufferSize := min(512, len(content))
211 mimeType := http.DetectContentType(content[:mimeBufferSize])
212 fileName := filepath.Base(path)
213 attachment := message.Attachment{FilePath: path, FileName: fileName, MimeType: mimeType, Content: content}
214 return m, util.CmdHandler(filepicker.FilePickedMsg{
215 Attachment: attachment,
216 })
217 */
218}
219
220func attachPicture() {
221
222}
223
224func onCompletionItemSelect(msg completions.SelectCompletionMsg, m *editorCmp) {
225 if item, ok := msg.Value.(FileCompletionItem); ok {
226 word := m.textarea.Word()
227 // If the selected item is a file, insert its path into the textarea
228 value := m.textarea.Value()
229 value = value[:m.completionsStartIndex] + // Remove the current query
230 item.Path + // Insert the file path
231 value[m.completionsStartIndex+len(word):] // Append the rest of the value
232 // XXX: This will always move the cursor to the end of the textarea.
233 m.textarea.SetValue(value)
234 m.textarea.MoveToEnd()
235 if !msg.Insert {
236 m.isCompletionsOpen = false
237 m.currentQuery = ""
238 m.completionsStartIndex = 0
239 }
240 }
241}
242
243type ResolveAbs func(path string) (string, error)
244
245func onPaste(fsys fs.FS, fsysAbs ResolveAbs, m *editorCmp, msg tea.PasteMsg) (tea.Model, tea.Cmd) {
246 var cmd tea.Cmd
247 path := strings.ReplaceAll(string(msg), "\\ ", " ")
248 // try to get an image, in this case specifically because the file
249 // path cannot have been limited to just the PWD as the root, since the
250 // path is coming from the contents of a clipboard
251 path, err := fsysAbs(strings.TrimSpace(path))
252 if err != nil {
253 m.textarea, cmd = m.textarea.Update(msg)
254 return m, cmd
255 }
256 isAllowedType := false
257 for _, ext := range filepicker.AllowedTypes {
258 if strings.HasSuffix(path, ext) {
259 isAllowedType = true
260 break
261 }
262 }
263 if !isAllowedType {
264 m.textarea, cmd = m.textarea.Update(msg)
265 return m, cmd
266 }
267 tooBig, _ := filepicker.IsFileTooBigWithFS(fsys, path, filepicker.MaxAttachmentSize)
268 if tooBig {
269 m.textarea, cmd = m.textarea.Update(msg)
270 return m, cmd
271 }
272
273 content, err := fs.ReadFile(fsys, path)
274 if err != nil {
275 m.textarea, cmd = m.textarea.Update(msg)
276 return m, cmd
277 }
278 mimeBufferSize := min(512, len(content))
279 mimeType := http.DetectContentType(content[:mimeBufferSize])
280 fileName := filepath.Base(path)
281 attachment := message.Attachment{FilePath: path, FileName: fileName, MimeType: mimeType, Content: content}
282 return m, util.CmdHandler(filepicker.FilePickedMsg{
283 Attachment: attachment,
284 })
285}
286
287func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
288 var cmd tea.Cmd
289 var cmds []tea.Cmd
290 switch msg := msg.(type) {
291 case tea.WindowSizeMsg:
292 return m, m.repositionCompletions
293 case filepicker.FilePickedMsg:
294 if len(m.attachments) >= maxAttachments {
295 return m, util.ReportError(fmt.Errorf("cannot add more than %d images", maxAttachments))
296 }
297 m.attachments = append(m.attachments, msg.Attachment)
298 return m, nil
299 case completions.CompletionsOpenedMsg:
300 m.isCompletionsOpen = true
301 case completions.CompletionsClosedMsg:
302 m.isCompletionsOpen = false
303 m.currentQuery = ""
304 m.completionsStartIndex = 0
305 // NOTE(tauraamui) [15/09/2025]: this is the correct location to add picture attaching
306 case completions.SelectCompletionMsg:
307 if !m.isCompletionsOpen {
308 return m, nil
309 }
310 onCompletionItemSelect(msg, m)
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(os.DirFS("."), 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}