editor.go

  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}