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(msg completions.SelectCompletionMsg, m *editorCmp) {
179 if item, ok := msg.Value.(FileCompletionItem); ok {
180 word := m.textarea.Word()
181 // If the selected item is a file, insert its path into the textarea
182 value := m.textarea.Value()
183 value = value[:m.completionsStartIndex] + // Remove the current query
184 item.Path + // Insert the file path
185 value[m.completionsStartIndex+len(word):] // Append the rest of the value
186 // XXX: This will always move the cursor to the end of the textarea.
187 m.textarea.SetValue(value)
188 m.textarea.MoveToEnd()
189 if !msg.Insert {
190 m.isCompletionsOpen = false
191 m.currentQuery = ""
192 m.completionsStartIndex = 0
193 }
194 }
195}
196
197type ResolveAbs func(path string) (string, error)
198
199func onPaste(fsys fs.FS, fsysAbs ResolveAbs, m *editorCmp, msg tea.PasteMsg) (tea.Model, tea.Cmd) {
200 var cmd tea.Cmd
201 path := strings.ReplaceAll(string(msg), "\\ ", " ")
202 // try to get an image, in this case specifically because the file
203 // path cannot have been limited to just the PWD as the root, since the
204 // path is coming from the contents of a clipboard
205 path, err := fsysAbs(strings.TrimSpace(path))
206 if err != nil {
207 m.textarea, cmd = m.textarea.Update(msg)
208 return m, cmd
209 }
210 isAllowedType := false
211 for _, ext := range filepicker.AllowedTypes {
212 if strings.HasSuffix(path, ext) {
213 isAllowedType = true
214 break
215 }
216 }
217 if !isAllowedType {
218 m.textarea, cmd = m.textarea.Update(msg)
219 return m, cmd
220 }
221 tooBig, _ := filepicker.IsFileTooBigWithFS(fsys, path, filepicker.MaxAttachmentSize)
222 if tooBig {
223 m.textarea, cmd = m.textarea.Update(msg)
224 return m, cmd
225 }
226
227 content, err := fs.ReadFile(fsys, path)
228 if err != nil {
229 m.textarea, cmd = m.textarea.Update(msg)
230 return m, cmd
231 }
232 mimeBufferSize := min(512, len(content))
233 mimeType := http.DetectContentType(content[:mimeBufferSize])
234 fileName := filepath.Base(path)
235 attachment := message.Attachment{FilePath: path, FileName: fileName, MimeType: mimeType, Content: content}
236 return m, util.CmdHandler(filepicker.FilePickedMsg{
237 Attachment: attachment,
238 })
239}
240
241func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
242 var cmd tea.Cmd
243 var cmds []tea.Cmd
244 switch msg := msg.(type) {
245 case tea.WindowSizeMsg:
246 return m, m.repositionCompletions
247 case filepicker.FilePickedMsg:
248 if len(m.attachments) >= maxAttachments {
249 return m, util.ReportError(fmt.Errorf("cannot add more than %d images", maxAttachments))
250 }
251 m.attachments = append(m.attachments, msg.Attachment)
252 return m, nil
253 case completions.CompletionsOpenedMsg:
254 m.isCompletionsOpen = true
255 case completions.CompletionsClosedMsg:
256 m.isCompletionsOpen = false
257 m.currentQuery = ""
258 m.completionsStartIndex = 0
259 // NOTE(tauraamui) [15/09/2025]: this is the correct location to add picture attaching
260 case completions.SelectCompletionMsg:
261 if !m.isCompletionsOpen {
262 return m, nil
263 }
264 onCompletionItemSelect(msg, m)
265 case commands.OpenExternalEditorMsg:
266 if m.app.CoderAgent.IsSessionBusy(m.session.ID) {
267 return m, util.ReportWarn("Agent is working, please wait...")
268 }
269 return m, m.openEditor(m.textarea.Value())
270 case OpenEditorMsg:
271 m.textarea.SetValue(msg.Text)
272 m.textarea.MoveToEnd()
273 case tea.PasteMsg:
274 return onPaste(os.DirFS("."), filepath.Abs, m, msg) // inject fsys accessible from PWD
275 case commands.ToggleYoloModeMsg:
276 m.setEditorPrompt()
277 return m, nil
278 case tea.KeyPressMsg:
279 cur := m.textarea.Cursor()
280 curIdx := m.textarea.Width()*cur.Y + cur.X
281 switch {
282 // Completions
283 case msg.String() == "/" && !m.isCompletionsOpen &&
284 // only show if beginning of prompt, or if previous char is a space or newline:
285 (len(m.textarea.Value()) == 0 || unicode.IsSpace(rune(m.textarea.Value()[len(m.textarea.Value())-1]))):
286 m.isCompletionsOpen = true
287 m.currentQuery = ""
288 m.completionsStartIndex = curIdx
289 cmds = append(cmds, m.startCompletions)
290 case m.isCompletionsOpen && curIdx <= m.completionsStartIndex:
291 cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
292 }
293 if key.Matches(msg, DeleteKeyMaps.AttachmentDeleteMode) {
294 m.deleteMode = true
295 return m, nil
296 }
297 if key.Matches(msg, DeleteKeyMaps.DeleteAllAttachments) && m.deleteMode {
298 m.deleteMode = false
299 m.attachments = nil
300 return m, nil
301 }
302 rune := msg.Code
303 if m.deleteMode && unicode.IsDigit(rune) {
304 num := int(rune - '0')
305 m.deleteMode = false
306 if num < 10 && len(m.attachments) > num {
307 if num == 0 {
308 m.attachments = m.attachments[num+1:]
309 } else {
310 m.attachments = slices.Delete(m.attachments, num, num+1)
311 }
312 return m, nil
313 }
314 }
315 if key.Matches(msg, m.keyMap.OpenEditor) {
316 if m.app.CoderAgent.IsSessionBusy(m.session.ID) {
317 return m, util.ReportWarn("Agent is working, please wait...")
318 }
319 return m, m.openEditor(m.textarea.Value())
320 }
321 if key.Matches(msg, DeleteKeyMaps.Escape) {
322 m.deleteMode = false
323 return m, nil
324 }
325 if key.Matches(msg, m.keyMap.Newline) {
326 m.textarea.InsertRune('\n')
327 cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
328 }
329 // Handle Enter key
330 if m.textarea.Focused() && key.Matches(msg, m.keyMap.SendMessage) {
331 value := m.textarea.Value()
332 if strings.HasSuffix(value, "\\") {
333 // If the last character is a backslash, remove it and add a newline.
334 m.textarea.SetValue(strings.TrimSuffix(value, "\\"))
335 } else {
336 // Otherwise, send the message
337 return m, m.send()
338 }
339 }
340 }
341
342 m.textarea, cmd = m.textarea.Update(msg)
343 cmds = append(cmds, cmd)
344
345 if m.textarea.Focused() {
346 kp, ok := msg.(tea.KeyPressMsg)
347 if ok {
348 if kp.String() == "space" || m.textarea.Value() == "" {
349 m.isCompletionsOpen = false
350 m.currentQuery = ""
351 m.completionsStartIndex = 0
352 cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
353 } else {
354 word := m.textarea.Word()
355 if strings.HasPrefix(word, "/") {
356 // XXX: wont' work if editing in the middle of the field.
357 m.completionsStartIndex = strings.LastIndex(m.textarea.Value(), word)
358 m.currentQuery = word[1:]
359 x, y := m.completionsPosition()
360 x -= len(m.currentQuery)
361 m.isCompletionsOpen = true
362 cmds = append(cmds,
363 util.CmdHandler(completions.FilterCompletionsMsg{
364 Query: m.currentQuery,
365 Reopen: m.isCompletionsOpen,
366 X: x,
367 Y: y,
368 }),
369 )
370 } else if m.isCompletionsOpen {
371 m.isCompletionsOpen = false
372 m.currentQuery = ""
373 m.completionsStartIndex = 0
374 cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
375 }
376 }
377 }
378 }
379
380 return m, tea.Batch(cmds...)
381}
382
383func (m *editorCmp) setEditorPrompt() {
384 if perm := m.app.Permissions; perm != nil {
385 if perm.SkipRequests() {
386 m.textarea.SetPromptFunc(4, yoloPromptFunc)
387 return
388 }
389 }
390 m.textarea.SetPromptFunc(4, normalPromptFunc)
391}
392
393func (m *editorCmp) completionsPosition() (int, int) {
394 cur := m.textarea.Cursor()
395 if cur == nil {
396 return m.x, m.y + 1 // adjust for padding
397 }
398 x := cur.X + m.x
399 y := cur.Y + m.y + 1 // adjust for padding
400 return x, y
401}
402
403func (m *editorCmp) Cursor() *tea.Cursor {
404 cursor := m.textarea.Cursor()
405 if cursor != nil {
406 cursor.X = cursor.X + m.x + 1
407 cursor.Y = cursor.Y + m.y + 1 // adjust for padding
408 }
409 return cursor
410}
411
412var readyPlaceholders = [...]string{
413 "Ready!",
414 "Ready...",
415 "Ready?",
416 "Ready for instructions",
417}
418
419var workingPlaceholders = [...]string{
420 "Working!",
421 "Working...",
422 "Brrrrr...",
423 "Prrrrrrrr...",
424 "Processing...",
425 "Thinking...",
426}
427
428func (m *editorCmp) randomizePlaceholders() {
429 m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))]
430 m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))]
431}
432
433func (m *editorCmp) View() string {
434 t := styles.CurrentTheme()
435 // Update placeholder
436 if m.app.CoderAgent != nil && m.app.CoderAgent.IsBusy() {
437 m.textarea.Placeholder = m.workingPlaceholder
438 } else {
439 m.textarea.Placeholder = m.readyPlaceholder
440 }
441 if m.app.Permissions.SkipRequests() {
442 m.textarea.Placeholder = "Yolo mode!"
443 }
444 if len(m.attachments) == 0 {
445 content := t.S().Base.Padding(1).Render(
446 m.textarea.View(),
447 )
448 return content
449 }
450 content := t.S().Base.Padding(0, 1, 1, 1).Render(
451 lipgloss.JoinVertical(lipgloss.Top,
452 m.attachmentsContent(),
453 m.textarea.View(),
454 ),
455 )
456 return content
457}
458
459func (m *editorCmp) SetSize(width, height int) tea.Cmd {
460 m.width = width
461 m.height = height
462 m.textarea.SetWidth(width - 2) // adjust for padding
463 m.textarea.SetHeight(height - 2) // adjust for padding
464 return nil
465}
466
467func (m *editorCmp) GetSize() (int, int) {
468 return m.textarea.Width(), m.textarea.Height()
469}
470
471func (m *editorCmp) attachmentsContent() string {
472 var styledAttachments []string
473 t := styles.CurrentTheme()
474 attachmentStyles := t.S().Base.
475 MarginLeft(1).
476 Background(t.FgMuted).
477 Foreground(t.FgBase)
478 for i, attachment := range m.attachments {
479 var filename string
480 if len(attachment.FileName) > 10 {
481 filename = fmt.Sprintf(" %s %s...", styles.DocumentIcon, attachment.FileName[0:7])
482 } else {
483 filename = fmt.Sprintf(" %s %s", styles.DocumentIcon, attachment.FileName)
484 }
485 if m.deleteMode {
486 filename = fmt.Sprintf("%d%s", i, filename)
487 }
488 styledAttachments = append(styledAttachments, attachmentStyles.Render(filename))
489 }
490 content := lipgloss.JoinHorizontal(lipgloss.Left, styledAttachments...)
491 return content
492}
493
494func (m *editorCmp) SetPosition(x, y int) tea.Cmd {
495 m.x = x
496 m.y = y
497 return nil
498}
499
500func (m *editorCmp) startCompletions() tea.Msg {
501 files, _, _ := m.listDirResolver()(".", nil, 0)
502 slices.Sort(files)
503 completionItems := make([]completions.Completion, 0, len(files))
504 for _, file := range files {
505 file = strings.TrimPrefix(file, "./")
506 completionItems = append(completionItems, completions.Completion{
507 Title: file,
508 Value: FileCompletionItem{
509 Path: file,
510 },
511 })
512 }
513
514 x, y := m.completionsPosition()
515 return completions.OpenCompletionsMsg{
516 Completions: completionItems,
517 X: x,
518 Y: y,
519 }
520}
521
522// Blur implements Container.
523func (c *editorCmp) Blur() tea.Cmd {
524 c.textarea.Blur()
525 return nil
526}
527
528// Focus implements Container.
529func (c *editorCmp) Focus() tea.Cmd {
530 return c.textarea.Focus()
531}
532
533// IsFocused implements Container.
534func (c *editorCmp) IsFocused() bool {
535 return c.textarea.Focused()
536}
537
538// Bindings implements Container.
539func (c *editorCmp) Bindings() []key.Binding {
540 return c.keyMap.KeyBindings()
541}
542
543// TODO: most likely we do not need to have the session here
544// we need to move some functionality to the page level
545func (c *editorCmp) SetSession(session session.Session) tea.Cmd {
546 c.session = session
547 return nil
548}
549
550func (c *editorCmp) IsCompletionsOpen() bool {
551 return c.isCompletionsOpen
552}
553
554func (c *editorCmp) HasAttachments() bool {
555 return len(c.attachments) > 0
556}
557
558func normalPromptFunc(info textarea.PromptInfo) string {
559 t := styles.CurrentTheme()
560 if info.LineNumber == 0 {
561 return " > "
562 }
563 if info.Focused {
564 return t.S().Base.Foreground(t.GreenDark).Render("::: ")
565 }
566 return t.S().Muted.Render("::: ")
567}
568
569func yoloPromptFunc(info textarea.PromptInfo) string {
570 t := styles.CurrentTheme()
571 if info.LineNumber == 0 {
572 if info.Focused {
573 return fmt.Sprintf("%s ", t.YoloIconFocused)
574 } else {
575 return fmt.Sprintf("%s ", t.YoloIconBlurred)
576 }
577 }
578 if info.Focused {
579 return fmt.Sprintf("%s ", t.YoloDotsFocused)
580 }
581 return fmt.Sprintf("%s ", t.YoloDotsBlurred)
582}
583
584func newTextArea() *textarea.Model {
585 t := styles.CurrentTheme()
586 ta := textarea.New()
587 ta.SetStyles(t.S().TextArea)
588 ta.ShowLineNumbers = false
589 ta.CharLimit = -1
590 ta.SetVirtualCursor(false)
591 ta.Focus()
592 return ta
593}
594
595func newEditor(app *app.App, resolveDirLister fsext.DirectoryListerResolver) *editorCmp {
596 e := editorCmp{
597 // TODO: remove the app instance from here
598 app: app,
599 textarea: newTextArea(),
600 keyMap: DefaultEditorKeyMap(),
601 listDirResolver: resolveDirLister,
602 }
603 e.setEditorPrompt()
604
605 e.randomizePlaceholders()
606 e.textarea.Placeholder = e.readyPlaceholder
607
608 return &e
609}
610
611func New(app *app.App) Editor {
612 return newEditor(app, fsext.ResolveDirectoryLister)
613}