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