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/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}