1package chat
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/lipgloss/v2"
15 "github.com/opencode-ai/opencode/internal/app"
16 "github.com/opencode-ai/opencode/internal/logging"
17 "github.com/opencode-ai/opencode/internal/message"
18 "github.com/opencode-ai/opencode/internal/session"
19 "github.com/opencode-ai/opencode/internal/tui/components/dialog"
20 "github.com/opencode-ai/opencode/internal/tui/layout"
21 "github.com/opencode-ai/opencode/internal/tui/styles"
22 "github.com/opencode-ai/opencode/internal/tui/theme"
23 "github.com/opencode-ai/opencode/internal/tui/util"
24)
25
26type editorCmp struct {
27 width int
28 height int
29 x, y int
30 app *app.App
31 session session.Session
32 textarea textarea.Model
33 attachments []message.Attachment
34 deleteMode bool
35}
36
37type EditorKeyMaps struct {
38 Send key.Binding
39 OpenEditor key.Binding
40}
41
42type bluredEditorKeyMaps struct {
43 Send key.Binding
44 Focus key.Binding
45 OpenEditor key.Binding
46}
47type DeleteAttachmentKeyMaps struct {
48 AttachmentDeleteMode key.Binding
49 Escape key.Binding
50 DeleteAllAttachments key.Binding
51}
52
53var editorMaps = EditorKeyMaps{
54 Send: key.NewBinding(
55 key.WithKeys("enter", "ctrl+s"),
56 key.WithHelp("enter", "send message"),
57 ),
58 OpenEditor: key.NewBinding(
59 key.WithKeys("ctrl+e"),
60 key.WithHelp("ctrl+e", "open editor"),
61 ),
62}
63
64var DeleteKeyMaps = DeleteAttachmentKeyMaps{
65 AttachmentDeleteMode: key.NewBinding(
66 key.WithKeys("ctrl+r"),
67 key.WithHelp("ctrl+r+{i}", "delete attachment at index i"),
68 ),
69 Escape: key.NewBinding(
70 key.WithKeys("esc"),
71 key.WithHelp("esc", "cancel delete mode"),
72 ),
73 DeleteAllAttachments: key.NewBinding(
74 key.WithKeys("r"),
75 key.WithHelp("ctrl+r+r", "delete all attchments"),
76 ),
77}
78
79const (
80 maxAttachments = 5
81)
82
83func (m *editorCmp) openEditor() tea.Cmd {
84 editor := os.Getenv("EDITOR")
85 if editor == "" {
86 editor = "nvim"
87 }
88
89 tmpfile, err := os.CreateTemp("", "msg_*.md")
90 if err != nil {
91 return util.ReportError(err)
92 }
93 tmpfile.Close()
94 c := exec.Command(editor, tmpfile.Name())
95 c.Stdin = os.Stdin
96 c.Stdout = os.Stdout
97 c.Stderr = os.Stderr
98 return tea.ExecProcess(c, func(err error) tea.Msg {
99 if err != nil {
100 return util.ReportError(err)
101 }
102 content, err := os.ReadFile(tmpfile.Name())
103 if err != nil {
104 return util.ReportError(err)
105 }
106 if len(content) == 0 {
107 return util.ReportWarn("Message is empty")
108 }
109 os.Remove(tmpfile.Name())
110 attachments := m.attachments
111 m.attachments = nil
112 return SendMsg{
113 Text: string(content),
114 Attachments: attachments,
115 }
116 })
117}
118
119func (m *editorCmp) Init() tea.Cmd {
120 return nil
121}
122
123func (m *editorCmp) send() tea.Cmd {
124 if m.app.CoderAgent.IsSessionBusy(m.session.ID) {
125 return util.ReportWarn("Agent is working, please wait...")
126 }
127
128 value := m.textarea.Value()
129 m.textarea.Reset()
130 attachments := m.attachments
131
132 m.attachments = nil
133 if value == "" {
134 return nil
135 }
136 return tea.Batch(
137 util.CmdHandler(SendMsg{
138 Text: value,
139 Attachments: attachments,
140 }),
141 )
142}
143
144func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
145 var cmd tea.Cmd
146 switch msg := msg.(type) {
147 case dialog.ThemeChangedMsg:
148 m.textarea = CreateTextArea(&m.textarea)
149 case dialog.CompletionSelectedMsg:
150 existingValue := m.textarea.Value()
151 modifiedValue := strings.Replace(existingValue, msg.SearchString, msg.CompletionValue, 1)
152
153 m.textarea.SetValue(modifiedValue)
154 return m, nil
155 case SessionSelectedMsg:
156 if msg.ID != m.session.ID {
157 m.session = msg
158 }
159 return m, nil
160 case dialog.AttachmentAddedMsg:
161 if len(m.attachments) >= maxAttachments {
162 logging.ErrorPersist(fmt.Sprintf("cannot add more than %d images", maxAttachments))
163 return m, cmd
164 }
165 m.attachments = append(m.attachments, msg.Attachment)
166 case tea.KeyPressMsg:
167 if key.Matches(msg, DeleteKeyMaps.AttachmentDeleteMode) {
168 m.deleteMode = true
169 return m, nil
170 }
171 if key.Matches(msg, DeleteKeyMaps.DeleteAllAttachments) && m.deleteMode {
172 m.deleteMode = false
173 m.attachments = nil
174 return m, nil
175 }
176 rune := msg.Code
177 if m.deleteMode && unicode.IsDigit(rune) {
178 num := int(rune - '0')
179 m.deleteMode = false
180 if num < 10 && len(m.attachments) > num {
181 if num == 0 {
182 m.attachments = m.attachments[num+1:]
183 } else {
184 m.attachments = slices.Delete(m.attachments, num, num+1)
185 }
186 return m, nil
187 }
188 }
189 if key.Matches(msg, editorMaps.OpenEditor) {
190 if m.app.CoderAgent.IsSessionBusy(m.session.ID) {
191 return m, util.ReportWarn("Agent is working, please wait...")
192 }
193 return m, m.openEditor()
194 }
195 if key.Matches(msg, DeleteKeyMaps.Escape) {
196 m.deleteMode = false
197 return m, nil
198 }
199 // Hanlde Enter key
200 if m.textarea.Focused() && key.Matches(msg, editorMaps.Send) {
201 value := m.textarea.Value()
202 if len(value) > 0 && value[len(value)-1] == '\\' {
203 // If the last character is a backslash, remove it and add a newline
204 m.textarea.SetValue(value[:len(value)-1] + "\n")
205 return m, nil
206 } else {
207 // Otherwise, send the message
208 return m, m.send()
209 }
210 }
211 }
212 m.textarea, cmd = m.textarea.Update(msg)
213 return m, cmd
214}
215
216func (m *editorCmp) View() tea.View {
217 t := theme.CurrentTheme()
218
219 // Style the prompt with theme colors
220 style := lipgloss.NewStyle().
221 Padding(0, 0, 0, 1).
222 Bold(true).
223 Foreground(t.Primary())
224
225 cursor := m.textarea.Cursor()
226 cursor.X = m.textarea.Cursor().X + m.x + 2
227 cursor.Y = m.textarea.Cursor().Y + m.y + 1
228 if len(m.attachments) == 0 {
229 view := tea.NewView(lipgloss.JoinHorizontal(lipgloss.Top, style.Render(">"), m.textarea.View()))
230 view.SetCursor(cursor)
231 return view
232 }
233 m.textarea.SetHeight(m.height - 1)
234 view := tea.NewView(lipgloss.JoinVertical(lipgloss.Top,
235 m.attachmentsContent(),
236 lipgloss.JoinHorizontal(lipgloss.Top, style.Render(">"),
237 m.textarea.View(),
238 ),
239 ))
240 view.SetCursor(cursor)
241 return view
242}
243
244func (m *editorCmp) SetSize(width, height int) tea.Cmd {
245 m.width = width
246 m.height = height
247 m.textarea.SetWidth(width - 3) // account for the prompt and padding right
248 m.textarea.SetHeight(height)
249 return nil
250}
251
252func (m *editorCmp) GetSize() (int, int) {
253 return m.textarea.Width(), m.textarea.Height()
254}
255
256func (m *editorCmp) attachmentsContent() string {
257 var styledAttachments []string
258 t := theme.CurrentTheme()
259 attachmentStyles := styles.BaseStyle().
260 MarginLeft(1).
261 Background(t.TextMuted()).
262 Foreground(t.Text())
263 for i, attachment := range m.attachments {
264 var filename string
265 if len(attachment.FileName) > 10 {
266 filename = fmt.Sprintf(" %s %s...", styles.DocumentIcon, attachment.FileName[0:7])
267 } else {
268 filename = fmt.Sprintf(" %s %s", styles.DocumentIcon, attachment.FileName)
269 }
270 if m.deleteMode {
271 filename = fmt.Sprintf("%d%s", i, filename)
272 }
273 styledAttachments = append(styledAttachments, attachmentStyles.Render(filename))
274 }
275 content := lipgloss.JoinHorizontal(lipgloss.Left, styledAttachments...)
276 return content
277}
278
279func (m *editorCmp) BindingKeys() []key.Binding {
280 bindings := []key.Binding{}
281 bindings = append(bindings, layout.KeyMapToSlice(editorMaps)...)
282 bindings = append(bindings, layout.KeyMapToSlice(DeleteKeyMaps)...)
283 return bindings
284}
285
286func (m *editorCmp) SetPosition(x, y int) tea.Cmd {
287 m.x = x
288 m.y = y
289 return nil
290}
291
292func CreateTextArea(existing *textarea.Model) textarea.Model {
293 t := theme.CurrentTheme()
294 bgColor := t.Background()
295 textColor := t.Text()
296 textMutedColor := t.TextMuted()
297
298 ta := textarea.New()
299 s := textarea.DefaultDarkStyles()
300 b := s.Blurred
301 b.Base = styles.BaseStyle().Background(bgColor).Foreground(textColor)
302 b.CursorLine = styles.BaseStyle().Background(bgColor)
303 b.Placeholder = styles.BaseStyle().Background(bgColor).Foreground(textMutedColor)
304 b.Text = styles.BaseStyle().Background(bgColor).Foreground(textColor)
305
306 f := s.Focused
307 f.Base = styles.BaseStyle().Background(bgColor).Foreground(textColor)
308 f.CursorLine = styles.BaseStyle().Background(bgColor)
309 f.Placeholder = styles.BaseStyle().Background(bgColor).Foreground(textMutedColor)
310 f.Text = styles.BaseStyle().Background(bgColor).Foreground(textColor)
311
312 s.Focused = f
313 s.Blurred = b
314 ta.SetStyles(s)
315
316 ta.Prompt = " "
317 ta.ShowLineNumbers = false
318 ta.CharLimit = -1
319 ta.SetVirtualCursor(false)
320
321 if existing != nil {
322 ta.SetValue(existing.Value())
323 ta.SetWidth(existing.Width())
324 ta.SetHeight(existing.Height())
325 }
326
327 ta.Focus()
328 return ta
329}
330
331func NewEditorCmp(app *app.App) util.Model {
332 ta := CreateTextArea(nil)
333 return &editorCmp{
334 app: app,
335 textarea: ta,
336 }
337}