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