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