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