editor.go

  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}