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