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	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.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, messageKeys.PageUp) || key.Matches(msg, messageKeys.PageDown) ||
189			key.Matches(msg, messageKeys.HalfPageUp) || key.Matches(msg, messageKeys.HalfPageDown) {
190			return m, nil
191		}
192		if key.Matches(msg, editorMaps.OpenEditor) {
193			if m.app.CoderAgent.IsSessionBusy(m.session.ID) {
194				return m, util.ReportWarn("Agent is working, please wait...")
195			}
196			return m, m.openEditor()
197		}
198		if key.Matches(msg, DeleteKeyMaps.Escape) {
199			m.deleteMode = false
200			return m, nil
201		}
202		// Hanlde Enter key
203		if m.textarea.Focused() && key.Matches(msg, editorMaps.Send) {
204			value := m.textarea.Value()
205			if len(value) > 0 && value[len(value)-1] == '\\' {
206				// If the last character is a backslash, remove it and add a newline
207				m.textarea.SetValue(value[:len(value)-1] + "\n")
208				return m, nil
209			} else {
210				// Otherwise, send the message
211				return m, m.send()
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}
239
240func (m *editorCmp) SetSize(width, height int) tea.Cmd {
241	m.width = width
242	m.height = height
243	m.textarea.SetWidth(width - 3) // account for the prompt and padding right
244	m.textarea.SetHeight(height)
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	s := textarea.DefaultDarkStyles()
290	b := s.Blurred
291	b.Base = styles.BaseStyle().Background(bgColor).Foreground(textColor)
292	b.CursorLine = styles.BaseStyle().Background(bgColor)
293	b.Placeholder = styles.BaseStyle().Background(bgColor).Foreground(textMutedColor)
294	b.Text = styles.BaseStyle().Background(bgColor).Foreground(textColor)
295
296	f := s.Focused
297	f.Base = styles.BaseStyle().Background(bgColor).Foreground(textColor)
298	f.CursorLine = styles.BaseStyle().Background(bgColor)
299	f.Placeholder = styles.BaseStyle().Background(bgColor).Foreground(textMutedColor)
300	f.Text = styles.BaseStyle().Background(bgColor).Foreground(textColor)
301
302	s.Focused = f
303	s.Blurred = b
304	ta.Styles = s
305
306	ta.Prompt = " "
307	ta.ShowLineNumbers = false
308	ta.CharLimit = -1
309
310	if existing != nil {
311		ta.SetValue(existing.Value())
312		ta.SetWidth(existing.Width())
313		ta.SetHeight(existing.Height())
314	}
315
316	ta.Focus()
317	return ta
318}
319
320func NewEditorCmp(app *app.App) util.Model {
321	ta := CreateTextArea(nil)
322	return &editorCmp{
323		app:      app,
324		textarea: ta,
325	}
326}