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