1package editor
2
3import (
4 "fmt"
5 "os"
6 "os/exec"
7 "slices"
8 "strings"
9 "unicode"
10
11 "github.com/charmbracelet/bubbles/v2/key"
12 "github.com/charmbracelet/bubbles/v2/textarea"
13 tea "github.com/charmbracelet/bubbletea/v2"
14 "github.com/charmbracelet/crush/internal/app"
15 "github.com/charmbracelet/crush/internal/fileutil"
16 "github.com/charmbracelet/crush/internal/logging"
17 "github.com/charmbracelet/crush/internal/message"
18 "github.com/charmbracelet/crush/internal/session"
19 "github.com/charmbracelet/crush/internal/tui/components/chat"
20 "github.com/charmbracelet/crush/internal/tui/components/completions"
21 "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
22 "github.com/charmbracelet/crush/internal/tui/layout"
23 "github.com/charmbracelet/crush/internal/tui/styles"
24 "github.com/charmbracelet/crush/internal/tui/util"
25 "github.com/charmbracelet/lipgloss/v2"
26)
27
28type FileCompletionItem struct {
29 Path string // The file path
30}
31
32type editorCmp struct {
33 width int
34 height int
35 x, y int
36 app *app.App
37 session session.Session
38 textarea textarea.Model
39 attachments []message.Attachment
40 deleteMode bool
41
42 keyMap EditorKeyMap
43
44 // File path completions
45 currentQuery string
46 completionsStartIndex int
47 isCompletionsOpen bool
48}
49
50type DeleteAttachmentKeyMaps struct {
51 AttachmentDeleteMode key.Binding
52 Escape key.Binding
53 DeleteAllAttachments key.Binding
54}
55
56var DeleteKeyMaps = DeleteAttachmentKeyMaps{
57 AttachmentDeleteMode: key.NewBinding(
58 key.WithKeys("ctrl+r"),
59 key.WithHelp("ctrl+r+{i}", "delete attachment at index i"),
60 ),
61 Escape: key.NewBinding(
62 key.WithKeys("esc"),
63 key.WithHelp("esc", "cancel delete mode"),
64 ),
65 DeleteAllAttachments: key.NewBinding(
66 key.WithKeys("r"),
67 key.WithHelp("ctrl+r+r", "delete all attchments"),
68 ),
69}
70
71const (
72 maxAttachments = 5
73)
74
75func (m *editorCmp) openEditor() tea.Cmd {
76 editor := os.Getenv("EDITOR")
77 if editor == "" {
78 editor = "nvim"
79 }
80
81 tmpfile, err := os.CreateTemp("", "msg_*.md")
82 if err != nil {
83 return util.ReportError(err)
84 }
85 tmpfile.Close()
86 c := exec.Command(editor, tmpfile.Name())
87 c.Stdin = os.Stdin
88 c.Stdout = os.Stdout
89 c.Stderr = os.Stderr
90 return tea.ExecProcess(c, func(err error) tea.Msg {
91 if err != nil {
92 return util.ReportError(err)
93 }
94 content, err := os.ReadFile(tmpfile.Name())
95 if err != nil {
96 return util.ReportError(err)
97 }
98 if len(content) == 0 {
99 return util.ReportWarn("Message is empty")
100 }
101 os.Remove(tmpfile.Name())
102 attachments := m.attachments
103 m.attachments = nil
104 return chat.SendMsg{
105 Text: string(content),
106 Attachments: attachments,
107 }
108 })
109}
110
111func (m *editorCmp) Init() tea.Cmd {
112 return nil
113}
114
115func (m *editorCmp) send() tea.Cmd {
116 if m.app.CoderAgent.IsSessionBusy(m.session.ID) {
117 return util.ReportWarn("Agent is working, please wait...")
118 }
119
120 value := m.textarea.Value()
121 m.textarea.Reset()
122 attachments := m.attachments
123
124 m.attachments = nil
125 if value == "" {
126 return nil
127 }
128 return tea.Batch(
129 util.CmdHandler(chat.SendMsg{
130 Text: value,
131 Attachments: attachments,
132 }),
133 )
134}
135
136func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
137 var cmd tea.Cmd
138 var cmds []tea.Cmd
139 switch msg := msg.(type) {
140 case chat.SessionSelectedMsg:
141 if msg.ID != m.session.ID {
142 m.session = msg
143 }
144 return m, nil
145 case filepicker.FilePickedMsg:
146 if len(m.attachments) >= maxAttachments {
147 logging.ErrorPersist(fmt.Sprintf("cannot add more than %d images", maxAttachments))
148 return m, cmd
149 }
150 m.attachments = append(m.attachments, msg.Attachment)
151 return m, nil
152 case completions.CompletionsClosedMsg:
153 m.isCompletionsOpen = false
154 m.currentQuery = ""
155 m.completionsStartIndex = 0
156 case completions.SelectCompletionMsg:
157 if !m.isCompletionsOpen {
158 return m, nil
159 }
160 if item, ok := msg.Value.(FileCompletionItem); ok {
161 // If the selected item is a file, insert its path into the textarea
162 value := m.textarea.Value()
163 value = value[:m.completionsStartIndex]
164 if len(value) > 0 && value[len(value)-1] != ' ' {
165 value += " "
166 }
167 value += item.Path
168 m.textarea.SetValue(value)
169 m.isCompletionsOpen = false
170 m.currentQuery = ""
171 m.completionsStartIndex = 0
172 return m, nil
173 }
174 case tea.KeyPressMsg:
175 switch {
176 // Completions
177 case msg.String() == "/" && !m.isCompletionsOpen:
178 m.isCompletionsOpen = true
179 m.currentQuery = ""
180 cmds = append(cmds, m.startCompletions)
181 m.completionsStartIndex = len(m.textarea.Value())
182 case msg.String() == "space" && m.isCompletionsOpen:
183 m.isCompletionsOpen = false
184 m.currentQuery = ""
185 m.completionsStartIndex = 0
186 cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
187 case m.isCompletionsOpen && m.textarea.Cursor().X <= m.completionsStartIndex:
188 cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
189 case msg.String() == "backspace" && m.isCompletionsOpen:
190 if len(m.currentQuery) > 0 {
191 m.currentQuery = m.currentQuery[:len(m.currentQuery)-1]
192 cmds = append(cmds, util.CmdHandler(completions.FilterCompletionsMsg{
193 Query: m.currentQuery,
194 }))
195 } else {
196 m.isCompletionsOpen = false
197 m.currentQuery = ""
198 m.completionsStartIndex = 0
199 cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
200 }
201 default:
202 if m.isCompletionsOpen {
203 m.currentQuery += msg.String()
204 cmds = append(cmds, util.CmdHandler(completions.FilterCompletionsMsg{
205 Query: m.currentQuery,
206 }))
207 }
208 }
209 if key.Matches(msg, DeleteKeyMaps.AttachmentDeleteMode) {
210 m.deleteMode = true
211 return m, nil
212 }
213 if key.Matches(msg, DeleteKeyMaps.DeleteAllAttachments) && m.deleteMode {
214 m.deleteMode = false
215 m.attachments = nil
216 return m, nil
217 }
218 rune := msg.Code
219 if m.deleteMode && unicode.IsDigit(rune) {
220 num := int(rune - '0')
221 m.deleteMode = false
222 if num < 10 && len(m.attachments) > num {
223 if num == 0 {
224 m.attachments = m.attachments[num+1:]
225 } else {
226 m.attachments = slices.Delete(m.attachments, num, num+1)
227 }
228 return m, nil
229 }
230 }
231 if key.Matches(msg, m.keyMap.OpenEditor) {
232 if m.app.CoderAgent.IsSessionBusy(m.session.ID) {
233 return m, util.ReportWarn("Agent is working, please wait...")
234 }
235 return m, m.openEditor()
236 }
237 if key.Matches(msg, DeleteKeyMaps.Escape) {
238 m.deleteMode = false
239 return m, nil
240 }
241 // Hanlde Enter key
242 if m.textarea.Focused() && key.Matches(msg, m.keyMap.Send) {
243 value := m.textarea.Value()
244 if len(value) > 0 && value[len(value)-1] == '\\' {
245 // If the last character is a backslash, remove it and add a newline
246 m.textarea.SetValue(value[:len(value)-1] + "\n")
247 return m, nil
248 } else {
249 // Otherwise, send the message
250 return m, m.send()
251 }
252 }
253 }
254 m.textarea, cmd = m.textarea.Update(msg)
255 cmds = append(cmds, cmd)
256 return m, tea.Batch(cmds...)
257}
258
259func (m *editorCmp) View() tea.View {
260 t := styles.CurrentTheme()
261 cursor := m.textarea.Cursor()
262 if cursor != nil {
263 cursor.X = cursor.X + m.x + 1
264 cursor.Y = cursor.Y + m.y + 1 // adjust for padding
265 }
266 if len(m.attachments) == 0 {
267 content := t.S().Base.Padding(1).Render(
268 m.textarea.View(),
269 )
270 view := tea.NewView(content)
271 view.SetCursor(cursor)
272 return view
273 }
274 content := t.S().Base.Padding(0, 1, 1, 1).Render(
275 lipgloss.JoinVertical(lipgloss.Top,
276 m.attachmentsContent(),
277 m.textarea.View(),
278 ),
279 )
280 view := tea.NewView(content)
281 view.SetCursor(cursor)
282 return view
283}
284
285func (m *editorCmp) SetSize(width, height int) tea.Cmd {
286 m.width = width
287 m.height = height
288 m.textarea.SetWidth(width - 2) // adjust for padding
289 m.textarea.SetHeight(height - 2) // adjust for padding
290 return nil
291}
292
293func (m *editorCmp) GetSize() (int, int) {
294 return m.textarea.Width(), m.textarea.Height()
295}
296
297func (m *editorCmp) attachmentsContent() string {
298 var styledAttachments []string
299 t := styles.CurrentTheme()
300 attachmentStyles := t.S().Base.
301 MarginLeft(1).
302 Background(t.FgMuted).
303 Foreground(t.FgBase)
304 for i, attachment := range m.attachments {
305 var filename string
306 if len(attachment.FileName) > 10 {
307 filename = fmt.Sprintf(" %s %s...", styles.DocumentIcon, attachment.FileName[0:7])
308 } else {
309 filename = fmt.Sprintf(" %s %s", styles.DocumentIcon, attachment.FileName)
310 }
311 if m.deleteMode {
312 filename = fmt.Sprintf("%d%s", i, filename)
313 }
314 styledAttachments = append(styledAttachments, attachmentStyles.Render(filename))
315 }
316 content := lipgloss.JoinHorizontal(lipgloss.Left, styledAttachments...)
317 return content
318}
319
320func (m *editorCmp) BindingKeys() []key.Binding {
321 bindings := []key.Binding{}
322 bindings = append(bindings, layout.KeyMapToSlice(m.keyMap)...)
323 bindings = append(bindings, layout.KeyMapToSlice(DeleteKeyMaps)...)
324 return bindings
325}
326
327func (m *editorCmp) SetPosition(x, y int) tea.Cmd {
328 m.x = x
329 m.y = y
330 return nil
331}
332
333func (m *editorCmp) startCompletions() tea.Msg {
334 files, _, _ := fileutil.ListDirectory(".", []string{}, 0)
335 completionItems := make([]completions.Completion, 0, len(files))
336 for _, file := range files {
337 file = strings.TrimPrefix(file, "./")
338 completionItems = append(completionItems, completions.Completion{
339 Title: file,
340 Value: FileCompletionItem{
341 Path: file,
342 },
343 })
344 }
345
346 x := m.textarea.Cursor().X + m.x + 1
347 y := m.textarea.Cursor().Y + m.y + 1
348 return completions.OpenCompletionsMsg{
349 Completions: completionItems,
350 X: x,
351 Y: y,
352 }
353}
354
355// Blur implements Container.
356func (c *editorCmp) Blur() tea.Cmd {
357 c.textarea.Blur()
358 return nil
359}
360
361// Focus implements Container.
362func (c *editorCmp) Focus() tea.Cmd {
363 return c.textarea.Focus()
364}
365
366// IsFocused implements Container.
367func (c *editorCmp) IsFocused() bool {
368 return c.textarea.Focused()
369}
370
371func NewEditorCmp(app *app.App) util.Model {
372 t := styles.CurrentTheme()
373 ta := textarea.New()
374 ta.SetStyles(t.S().TextArea)
375 ta.SetPromptFunc(4, func(lineIndex int, focused bool) string {
376 if lineIndex == 0 {
377 return " > "
378 }
379 if focused {
380 return t.S().Base.Foreground(t.GreenDark).Render("::: ")
381 } else {
382 return t.S().Muted.Render("::: ")
383 }
384 })
385 ta.ShowLineNumbers = false
386 ta.CharLimit = -1
387 ta.Placeholder = "Tell me more about this project..."
388 ta.SetVirtualCursor(false)
389 ta.Focus()
390
391 return &editorCmp{
392 app: app,
393 textarea: ta,
394 keyMap: DefaultEditorKeyMap(),
395 }
396}