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