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	}
216	m.textarea, cmd = m.textarea.Update(msg)
217	return m, cmd
218}
219
220func (m *editorCmp) View() string {
221	t := theme.CurrentTheme()
222
223	// Style the prompt with theme colors
224	style := lipgloss.NewStyle().
225		Padding(0, 0, 0, 1).
226		Bold(true).
227		Foreground(t.Primary())
228
229	if len(m.attachments) == 0 {
230		return lipgloss.JoinHorizontal(lipgloss.Top, style.Render(">"), m.textarea.View())
231	}
232	m.textarea.SetHeight(m.height - 1)
233	return lipgloss.JoinVertical(lipgloss.Top,
234		m.attachmentsContent(),
235		lipgloss.JoinHorizontal(lipgloss.Top, style.Render(">"),
236			m.textarea.View()),
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	m.textarea.SetWidth(width)
246	return nil
247}
248
249func (m *editorCmp) GetSize() (int, int) {
250	return m.textarea.Width(), m.textarea.Height()
251}
252
253func (m *editorCmp) attachmentsContent() string {
254	var styledAttachments []string
255	t := theme.CurrentTheme()
256	attachmentStyles := styles.BaseStyle().
257		MarginLeft(1).
258		Background(t.TextMuted()).
259		Foreground(t.Text())
260	for i, attachment := range m.attachments {
261		var filename string
262		if len(attachment.FileName) > 10 {
263			filename = fmt.Sprintf(" %s %s...", styles.DocumentIcon, attachment.FileName[0:7])
264		} else {
265			filename = fmt.Sprintf(" %s %s", styles.DocumentIcon, attachment.FileName)
266		}
267		if m.deleteMode {
268			filename = fmt.Sprintf("%d%s", i, filename)
269		}
270		styledAttachments = append(styledAttachments, attachmentStyles.Render(filename))
271	}
272	content := lipgloss.JoinHorizontal(lipgloss.Left, styledAttachments...)
273	return content
274}
275
276func (m *editorCmp) BindingKeys() []key.Binding {
277	bindings := []key.Binding{}
278	bindings = append(bindings, layout.KeyMapToSlice(editorMaps)...)
279	bindings = append(bindings, layout.KeyMapToSlice(DeleteKeyMaps)...)
280	return bindings
281}
282
283func CreateTextArea(existing *textarea.Model) textarea.Model {
284	t := theme.CurrentTheme()
285	bgColor := t.Background()
286	textColor := t.Text()
287	textMutedColor := t.TextMuted()
288
289	ta := textarea.New()
290	s := textarea.DefaultDarkStyles()
291	b := s.Blurred
292	b.Base = styles.BaseStyle().Background(bgColor).Foreground(textColor)
293	b.CursorLine = styles.BaseStyle().Background(bgColor)
294	b.Placeholder = styles.BaseStyle().Background(bgColor).Foreground(textMutedColor)
295	b.Text = styles.BaseStyle().Background(bgColor).Foreground(textColor)
296
297	f := s.Focused
298	f.Base = styles.BaseStyle().Background(bgColor).Foreground(textColor)
299	f.CursorLine = styles.BaseStyle().Background(bgColor)
300	f.Placeholder = styles.BaseStyle().Background(bgColor).Foreground(textMutedColor)
301	f.Text = styles.BaseStyle().Background(bgColor).Foreground(textColor)
302
303	s.Focused = f
304	s.Blurred = b
305	ta.Styles = s
306
307	ta.Prompt = " "
308	ta.ShowLineNumbers = false
309	ta.CharLimit = -1
310
311	if existing != nil {
312		ta.SetValue(existing.Value())
313		ta.SetWidth(existing.Width())
314		ta.SetHeight(existing.Height())
315	}
316
317	ta.Focus()
318	return ta
319}
320
321func NewEditorCmp(app *app.App) util.Model {
322	ta := CreateTextArea(nil)
323	return &editorCmp{
324		app:      app,
325		textarea: ta,
326	}
327}