1package chat
2
3import (
4 "fmt"
5 "os"
6 "os/exec"
7 "slices"
8 "strings"
9 "unicode"
10
11 "github.com/charmbracelet/bubbles/key"
12 "github.com/charmbracelet/bubbles/textarea"
13 tea "github.com/charmbracelet/bubbletea"
14 "github.com/charmbracelet/lipgloss"
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()) //nolint:gosec
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.KeyMsg:
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 if m.deleteMode && len(msg.Runes) > 0 && unicode.IsDigit(msg.Runes[0]) {
176 num := int(msg.Runes[0] - '0')
177 m.deleteMode = false
178 if num < 10 && len(m.attachments) > num {
179 if num == 0 {
180 m.attachments = m.attachments[num+1:]
181 } else {
182 m.attachments = slices.Delete(m.attachments, num, num+1)
183 }
184 return m, nil
185 }
186 }
187 if key.Matches(msg, messageKeys.PageUp) || key.Matches(msg, messageKeys.PageDown) ||
188 key.Matches(msg, messageKeys.HalfPageUp) || key.Matches(msg, messageKeys.HalfPageDown) {
189 return m, nil
190 }
191 if key.Matches(msg, editorMaps.OpenEditor) {
192 if m.app.CoderAgent.IsSessionBusy(m.session.ID) {
193 return m, util.ReportWarn("Agent is working, please wait...")
194 }
195 return m, m.openEditor()
196 }
197 if key.Matches(msg, DeleteKeyMaps.Escape) {
198 m.deleteMode = false
199 return m, nil
200 }
201 // Hanlde Enter key
202 if m.textarea.Focused() && key.Matches(msg, editorMaps.Send) {
203 value := m.textarea.Value()
204 if len(value) > 0 && value[len(value)-1] == '\\' {
205 // If the last character is a backslash, remove it and add a newline
206 m.textarea.SetValue(value[:len(value)-1] + "\n")
207 return m, nil
208 } else {
209 // Otherwise, send the message
210 return m, m.send()
211 }
212 }
213
214 }
215 m.textarea, cmd = m.textarea.Update(msg)
216 return m, cmd
217}
218
219func (m *editorCmp) View() string {
220 t := theme.CurrentTheme()
221
222 // Style the prompt with theme colors
223 style := lipgloss.NewStyle().
224 Padding(0, 0, 0, 1).
225 Bold(true).
226 Foreground(t.Primary())
227
228 if len(m.attachments) == 0 {
229 return lipgloss.JoinHorizontal(lipgloss.Top, style.Render(">"), m.textarea.View())
230 }
231 m.textarea.SetHeight(m.height - 1)
232 return lipgloss.JoinVertical(lipgloss.Top,
233 m.attachmentsContent(),
234 lipgloss.JoinHorizontal(lipgloss.Top, style.Render(">"),
235 m.textarea.View()),
236 )
237}
238
239func (m *editorCmp) SetSize(width, height int) tea.Cmd {
240 m.width = width
241 m.height = height
242 m.textarea.SetWidth(width - 3) // account for the prompt and padding right
243 m.textarea.SetHeight(height)
244 m.textarea.SetWidth(width)
245 return nil
246}
247
248func (m *editorCmp) GetSize() (int, int) {
249 return m.textarea.Width(), m.textarea.Height()
250}
251
252func (m *editorCmp) attachmentsContent() string {
253 var styledAttachments []string
254 t := theme.CurrentTheme()
255 attachmentStyles := styles.BaseStyle().
256 MarginLeft(1).
257 Background(t.TextMuted()).
258 Foreground(t.Text())
259 for i, attachment := range m.attachments {
260 var filename string
261 if len(attachment.FileName) > 10 {
262 filename = fmt.Sprintf(" %s %s...", styles.DocumentIcon, attachment.FileName[0:7])
263 } else {
264 filename = fmt.Sprintf(" %s %s", styles.DocumentIcon, attachment.FileName)
265 }
266 if m.deleteMode {
267 filename = fmt.Sprintf("%d%s", i, filename)
268 }
269 styledAttachments = append(styledAttachments, attachmentStyles.Render(filename))
270 }
271 content := lipgloss.JoinHorizontal(lipgloss.Left, styledAttachments...)
272 return content
273}
274
275func (m *editorCmp) BindingKeys() []key.Binding {
276 bindings := []key.Binding{}
277 bindings = append(bindings, layout.KeyMapToSlice(editorMaps)...)
278 bindings = append(bindings, layout.KeyMapToSlice(DeleteKeyMaps)...)
279 return bindings
280}
281
282func CreateTextArea(existing *textarea.Model) textarea.Model {
283 t := theme.CurrentTheme()
284 bgColor := t.Background()
285 textColor := t.Text()
286 textMutedColor := t.TextMuted()
287
288 ta := textarea.New()
289 ta.BlurredStyle.Base = styles.BaseStyle().Background(bgColor).Foreground(textColor)
290 ta.BlurredStyle.CursorLine = styles.BaseStyle().Background(bgColor)
291 ta.BlurredStyle.Placeholder = styles.BaseStyle().Background(bgColor).Foreground(textMutedColor)
292 ta.BlurredStyle.Text = styles.BaseStyle().Background(bgColor).Foreground(textColor)
293 ta.FocusedStyle.Base = styles.BaseStyle().Background(bgColor).Foreground(textColor)
294 ta.FocusedStyle.CursorLine = styles.BaseStyle().Background(bgColor)
295 ta.FocusedStyle.Placeholder = styles.BaseStyle().Background(bgColor).Foreground(textMutedColor)
296 ta.FocusedStyle.Text = styles.BaseStyle().Background(bgColor).Foreground(textColor)
297
298 ta.Prompt = " "
299 ta.ShowLineNumbers = false
300 ta.CharLimit = -1
301
302 if existing != nil {
303 ta.SetValue(existing.Value())
304 ta.SetWidth(existing.Width())
305 ta.SetHeight(existing.Height())
306 }
307
308 ta.Focus()
309 return ta
310}
311
312func NewEditorCmp(app *app.App) tea.Model {
313 ta := CreateTextArea(nil)
314 return &editorCmp{
315 app: app,
316 textarea: ta,
317 }
318}