1package editor
2
3import (
4 "fmt"
5 "math/rand"
6 "net/http"
7 "os"
8 "os/exec"
9 "path/filepath"
10 "runtime"
11 "slices"
12 "strings"
13 "unicode"
14
15 "github.com/charmbracelet/bubbles/v2/key"
16 "github.com/charmbracelet/bubbles/v2/textarea"
17 tea "github.com/charmbracelet/bubbletea/v2"
18 "github.com/charmbracelet/crush/internal/app"
19 "github.com/charmbracelet/crush/internal/fsext"
20 "github.com/charmbracelet/crush/internal/message"
21 "github.com/charmbracelet/crush/internal/session"
22 "github.com/charmbracelet/crush/internal/tui/components/chat"
23 "github.com/charmbracelet/crush/internal/tui/components/completions"
24 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
25 "github.com/charmbracelet/crush/internal/tui/components/dialogs"
26 "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
27 "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
28 "github.com/charmbracelet/crush/internal/tui/components/dialogs/quit"
29 "github.com/charmbracelet/crush/internal/tui/styles"
30 "github.com/charmbracelet/crush/internal/tui/util"
31 "github.com/charmbracelet/lipgloss/v2"
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 Cursor() *tea.Cursor
45}
46
47type FileCompletionItem struct {
48 Path string // The file path
49}
50
51type editorCmp struct {
52 width int
53 height int
54 x, y int
55 app *app.App
56 session session.Session
57 textarea textarea.Model
58 attachments []message.Attachment
59 deleteMode bool
60 readyPlaceholder string
61 workingPlaceholder string
62
63 keyMap EditorKeyMap
64
65 // File path completions
66 currentQuery string
67 completionsStartIndex int
68 isCompletionsOpen bool
69}
70
71var DeleteKeyMaps = DeleteAttachmentKeyMaps{
72 AttachmentDeleteMode: key.NewBinding(
73 key.WithKeys("ctrl+r"),
74 key.WithHelp("ctrl+r+{i}", "delete attachment at index i"),
75 ),
76 Escape: key.NewBinding(
77 key.WithKeys("esc"),
78 key.WithHelp("esc", "cancel delete mode"),
79 ),
80 DeleteAllAttachments: key.NewBinding(
81 key.WithKeys("r"),
82 key.WithHelp("ctrl+r+r", "delete all attachments"),
83 ),
84}
85
86const (
87 maxAttachments = 5
88)
89
90type OpenEditorMsg struct {
91 Text string
92}
93
94func (m *editorCmp) openEditor(value string) tea.Cmd {
95 editor := os.Getenv("EDITOR")
96 if editor == "" {
97 // Use platform-appropriate default editor
98 if runtime.GOOS == "windows" {
99 editor = "notepad"
100 } else {
101 editor = "nvim"
102 }
103 }
104
105 tmpfile, err := os.CreateTemp("", "msg_*.md")
106 if err != nil {
107 return util.ReportError(err)
108 }
109 defer tmpfile.Close() //nolint:errcheck
110 if _, err := tmpfile.WriteString(value); err != nil {
111 return util.ReportError(err)
112 }
113 c := exec.Command(editor, tmpfile.Name())
114 c.Stdin = os.Stdin
115 c.Stdout = os.Stdout
116 c.Stderr = os.Stderr
117 return tea.ExecProcess(c, func(err error) tea.Msg {
118 if err != nil {
119 return util.ReportError(err)
120 }
121 content, err := os.ReadFile(tmpfile.Name())
122 if err != nil {
123 return util.ReportError(err)
124 }
125 if len(content) == 0 {
126 return util.ReportWarn("Message is empty")
127 }
128 os.Remove(tmpfile.Name())
129 return OpenEditorMsg{
130 Text: strings.TrimSpace(string(content)),
131 }
132 })
133}
134
135func (m *editorCmp) Init() tea.Cmd {
136 return nil
137}
138
139func (m *editorCmp) send() tea.Cmd {
140 if m.app.CoderAgent == nil {
141 return util.ReportError(fmt.Errorf("coder agent is not initialized"))
142 }
143 if m.app.CoderAgent.IsSessionBusy(m.session.ID) {
144 return util.ReportWarn("Agent is working, please wait...")
145 }
146
147 value := m.textarea.Value()
148 value = strings.TrimSpace(value)
149
150 switch value {
151 case "exit", "quit":
152 m.textarea.Reset()
153 return util.CmdHandler(dialogs.OpenDialogMsg{Model: quit.NewQuitDialog()})
154 }
155
156 m.textarea.Reset()
157 attachments := m.attachments
158
159 m.attachments = nil
160 if value == "" {
161 return nil
162 }
163
164 // Change the placeholder when sending a new message.
165 m.randomizePlaceholders()
166
167 return tea.Batch(
168 util.CmdHandler(chat.SendMsg{
169 Text: value,
170 Attachments: attachments,
171 }),
172 )
173}
174
175func (m *editorCmp) repositionCompletions() tea.Msg {
176 x, y := m.completionsPosition()
177 return completions.RepositionCompletionsMsg{X: x, Y: y}
178}
179
180func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
181 var cmd tea.Cmd
182 var cmds []tea.Cmd
183 switch msg := msg.(type) {
184 case tea.WindowSizeMsg:
185 return m, m.repositionCompletions
186 case filepicker.FilePickedMsg:
187 if len(m.attachments) >= maxAttachments {
188 return m, util.ReportError(fmt.Errorf("cannot add more than %d images", maxAttachments))
189 }
190 m.attachments = append(m.attachments, msg.Attachment)
191 return m, nil
192 case completions.CompletionsOpenedMsg:
193 m.isCompletionsOpen = true
194 case completions.CompletionsClosedMsg:
195 m.isCompletionsOpen = false
196 m.currentQuery = ""
197 m.completionsStartIndex = 0
198 case completions.SelectCompletionMsg:
199 if !m.isCompletionsOpen {
200 return m, nil
201 }
202 if item, ok := msg.Value.(FileCompletionItem); ok {
203 word := m.textarea.Word()
204 // If the selected item is a file, insert its path into the textarea
205 value := m.textarea.Value()
206 value = value[:m.completionsStartIndex] + // Remove the current query
207 item.Path + // Insert the file path
208 value[m.completionsStartIndex+len(word):] // Append the rest of the value
209 // XXX: This will always move the cursor to the end of the textarea.
210 m.textarea.SetValue(value)
211 m.textarea.MoveToEnd()
212 if !msg.Insert {
213 m.isCompletionsOpen = false
214 m.currentQuery = ""
215 m.completionsStartIndex = 0
216 }
217 }
218
219 case commands.OpenExternalEditorMsg:
220 if m.app.CoderAgent.IsSessionBusy(m.session.ID) {
221 return m, util.ReportWarn("Agent is working, please wait...")
222 }
223 return m, m.openEditor(m.textarea.Value())
224 case OpenEditorMsg:
225 m.textarea.SetValue(msg.Text)
226 m.textarea.MoveToEnd()
227 case tea.PasteMsg:
228 path := strings.ReplaceAll(string(msg), "\\ ", " ")
229 // try to get an image
230 path, err := filepath.Abs(path)
231 if err != nil {
232 m.textarea, cmd = m.textarea.Update(msg)
233 return m, cmd
234 }
235 isAllowedType := false
236 for _, ext := range filepicker.AllowedTypes {
237 if strings.HasSuffix(path, ext) {
238 isAllowedType = true
239 break
240 }
241 }
242 if !isAllowedType {
243 m.textarea, cmd = m.textarea.Update(msg)
244 return m, cmd
245 }
246 tooBig, _ := filepicker.IsFileTooBig(path, filepicker.MaxAttachmentSize)
247 if tooBig {
248 m.textarea, cmd = m.textarea.Update(msg)
249 return m, cmd
250 }
251
252 content, err := os.ReadFile(path)
253 if err != nil {
254 m.textarea, cmd = m.textarea.Update(msg)
255 return m, cmd
256 }
257 mimeBufferSize := min(512, len(content))
258 mimeType := http.DetectContentType(content[:mimeBufferSize])
259 fileName := filepath.Base(path)
260 attachment := message.Attachment{FilePath: path, FileName: fileName, MimeType: mimeType, Content: content}
261 return m, util.CmdHandler(filepicker.FilePickedMsg{
262 Attachment: attachment,
263 })
264
265 case tea.KeyPressMsg:
266 cur := m.textarea.Cursor()
267 curIdx := m.textarea.Width()*cur.Y + cur.X
268 switch {
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.CoderAgent.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 len(value) > 0 && value[len(value)-1] == '\\' {
320 // If the last character is a backslash, remove it and add a newline
321 m.textarea.SetValue(value[:len(value)-1])
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) completionsPosition() (int, int) {
371 cur := m.textarea.Cursor()
372 if cur == nil {
373 return m.x, m.y + 1 // adjust for padding
374 }
375 x := cur.X + m.x
376 y := cur.Y + m.y + 1 // adjust for padding
377 return x, y
378}
379
380func (m *editorCmp) Cursor() *tea.Cursor {
381 cursor := m.textarea.Cursor()
382 if cursor != nil {
383 cursor.X = cursor.X + m.x + 1
384 cursor.Y = cursor.Y + m.y + 1 // adjust for padding
385 }
386 return cursor
387}
388
389var readyPlaceholders = [...]string{
390 "Ready!",
391 "Ready...",
392 "Ready?",
393 "Ready for instructions",
394}
395
396var workingPlaceholders = [...]string{
397 "Working!",
398 "Working...",
399 "Brrrrr...",
400 "Prrrrrrrr...",
401 "Processing...",
402 "Thinking...",
403}
404
405func (m *editorCmp) randomizePlaceholders() {
406 m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))]
407 m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))]
408}
409
410func (m *editorCmp) View() string {
411 t := styles.CurrentTheme()
412 // Update placeholder
413 if m.app.CoderAgent.IsBusy() {
414 m.textarea.Placeholder = m.workingPlaceholder
415 } else {
416 m.textarea.Placeholder = m.readyPlaceholder
417 }
418 if len(m.attachments) == 0 {
419 content := t.S().Base.Padding(1).Render(
420 m.textarea.View(),
421 )
422 return content
423 }
424 content := t.S().Base.Padding(0, 1, 1, 1).Render(
425 lipgloss.JoinVertical(lipgloss.Top,
426 m.attachmentsContent(),
427 m.textarea.View(),
428 ),
429 )
430 return content
431}
432
433func (m *editorCmp) SetSize(width, height int) tea.Cmd {
434 m.width = width
435 m.height = height
436 m.textarea.SetWidth(width - 2) // adjust for padding
437 m.textarea.SetHeight(height - 2) // adjust for padding
438 return nil
439}
440
441func (m *editorCmp) GetSize() (int, int) {
442 return m.textarea.Width(), m.textarea.Height()
443}
444
445func (m *editorCmp) attachmentsContent() string {
446 var styledAttachments []string
447 t := styles.CurrentTheme()
448 attachmentStyles := t.S().Base.
449 MarginLeft(1).
450 Background(t.FgMuted).
451 Foreground(t.FgBase)
452 for i, attachment := range m.attachments {
453 var filename string
454 if len(attachment.FileName) > 10 {
455 filename = fmt.Sprintf(" %s %s...", styles.DocumentIcon, attachment.FileName[0:7])
456 } else {
457 filename = fmt.Sprintf(" %s %s", styles.DocumentIcon, attachment.FileName)
458 }
459 if m.deleteMode {
460 filename = fmt.Sprintf("%d%s", i, filename)
461 }
462 styledAttachments = append(styledAttachments, attachmentStyles.Render(filename))
463 }
464 content := lipgloss.JoinHorizontal(lipgloss.Left, styledAttachments...)
465 return content
466}
467
468func (m *editorCmp) SetPosition(x, y int) tea.Cmd {
469 m.x = x
470 m.y = y
471 return nil
472}
473
474func (m *editorCmp) startCompletions() tea.Msg {
475 files, _, _ := fsext.ListDirectory(".", []string{}, 0)
476 completionItems := make([]completions.Completion, 0, len(files))
477 for _, file := range files {
478 file = strings.TrimPrefix(file, "./")
479 completionItems = append(completionItems, completions.Completion{
480 Title: file,
481 Value: FileCompletionItem{
482 Path: file,
483 },
484 })
485 }
486
487 x, y := m.completionsPosition()
488 return completions.OpenCompletionsMsg{
489 Completions: completionItems,
490 X: x,
491 Y: y,
492 }
493}
494
495// Blur implements Container.
496func (c *editorCmp) Blur() tea.Cmd {
497 c.textarea.Blur()
498 return nil
499}
500
501// Focus implements Container.
502func (c *editorCmp) Focus() tea.Cmd {
503 return c.textarea.Focus()
504}
505
506// IsFocused implements Container.
507func (c *editorCmp) IsFocused() bool {
508 return c.textarea.Focused()
509}
510
511// Bindings implements Container.
512func (c *editorCmp) Bindings() []key.Binding {
513 return c.keyMap.KeyBindings()
514}
515
516// TODO: most likely we do not need to have the session here
517// we need to move some functionality to the page level
518func (c *editorCmp) SetSession(session session.Session) tea.Cmd {
519 c.session = session
520 return nil
521}
522
523func (c *editorCmp) IsCompletionsOpen() bool {
524 return c.isCompletionsOpen
525}
526
527func (c *editorCmp) HasAttachments() bool {
528 return len(c.attachments) > 0
529}
530
531func New(app *app.App) Editor {
532 t := styles.CurrentTheme()
533 ta := textarea.New()
534 ta.SetStyles(t.S().TextArea)
535 ta.SetPromptFunc(4, func(info textarea.PromptInfo) string {
536 if info.LineNumber == 0 {
537 return " > "
538 }
539 if info.Focused {
540 return t.S().Base.Foreground(t.GreenDark).Render("::: ")
541 } else {
542 return t.S().Muted.Render("::: ")
543 }
544 })
545 ta.ShowLineNumbers = false
546 ta.CharLimit = -1
547 ta.SetVirtualCursor(false)
548 ta.Focus()
549
550 e := &editorCmp{
551 // TODO: remove the app instance from here
552 app: app,
553 textarea: ta,
554 keyMap: DefaultEditorKeyMap(),
555 }
556
557 e.randomizePlaceholders()
558 e.textarea.Placeholder = e.readyPlaceholder
559
560 return e
561}