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