editor.go

  1package chat
  2
  3import (
  4	"os"
  5	"os/exec"
  6
  7	"github.com/charmbracelet/bubbles/key"
  8	"github.com/charmbracelet/bubbles/textarea"
  9	tea "github.com/charmbracelet/bubbletea"
 10	"github.com/charmbracelet/lipgloss"
 11	"github.com/opencode-ai/opencode/internal/app"
 12	"github.com/opencode-ai/opencode/internal/session"
 13	"github.com/opencode-ai/opencode/internal/tui/components/dialog"
 14	"github.com/opencode-ai/opencode/internal/tui/layout"
 15	"github.com/opencode-ai/opencode/internal/tui/styles"
 16	"github.com/opencode-ai/opencode/internal/tui/theme"
 17	"github.com/opencode-ai/opencode/internal/tui/util"
 18)
 19
 20type editorCmp struct {
 21	app      *app.App
 22	session  session.Session
 23	textarea textarea.Model
 24}
 25
 26type EditorKeyMaps struct {
 27	Send       key.Binding
 28	OpenEditor key.Binding
 29}
 30
 31type bluredEditorKeyMaps struct {
 32	Send       key.Binding
 33	Focus      key.Binding
 34	OpenEditor key.Binding
 35}
 36
 37var editorMaps = EditorKeyMaps{
 38	Send: key.NewBinding(
 39		key.WithKeys("enter", "ctrl+s"),
 40		key.WithHelp("enter", "send message"),
 41	),
 42	OpenEditor: key.NewBinding(
 43		key.WithKeys("ctrl+e"),
 44		key.WithHelp("ctrl+e", "open editor"),
 45	),
 46}
 47
 48func openEditor() tea.Cmd {
 49	editor := os.Getenv("EDITOR")
 50	if editor == "" {
 51		editor = "nvim"
 52	}
 53
 54	tmpfile, err := os.CreateTemp("", "msg_*.md")
 55	if err != nil {
 56		return util.ReportError(err)
 57	}
 58	tmpfile.Close()
 59	c := exec.Command(editor, tmpfile.Name()) //nolint:gosec
 60	c.Stdin = os.Stdin
 61	c.Stdout = os.Stdout
 62	c.Stderr = os.Stderr
 63	return tea.ExecProcess(c, func(err error) tea.Msg {
 64		if err != nil {
 65			return util.ReportError(err)
 66		}
 67		content, err := os.ReadFile(tmpfile.Name())
 68		if err != nil {
 69			return util.ReportError(err)
 70		}
 71		if len(content) == 0 {
 72			return util.ReportWarn("Message is empty")
 73		}
 74		os.Remove(tmpfile.Name())
 75		return SendMsg{
 76			Text: string(content),
 77		}
 78	})
 79}
 80
 81func (m *editorCmp) Init() tea.Cmd {
 82	return textarea.Blink
 83}
 84
 85func (m *editorCmp) send() tea.Cmd {
 86	if m.app.CoderAgent.IsSessionBusy(m.session.ID) {
 87		return util.ReportWarn("Agent is working, please wait...")
 88	}
 89
 90	value := m.textarea.Value()
 91	m.textarea.Reset()
 92	if value == "" {
 93		return nil
 94	}
 95	return tea.Batch(
 96		util.CmdHandler(SendMsg{
 97			Text: value,
 98		}),
 99	)
100}
101
102func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
103	var cmd tea.Cmd
104	switch msg := msg.(type) {
105	case dialog.ThemeChangedMsg:
106		m.textarea = CreateTextArea(&m.textarea)
107		return m, nil
108	case SessionSelectedMsg:
109		if msg.ID != m.session.ID {
110			m.session = msg
111		}
112		return m, nil
113	case tea.KeyMsg:
114		if key.Matches(msg, messageKeys.PageUp) || key.Matches(msg, messageKeys.PageDown) ||
115			key.Matches(msg, messageKeys.HalfPageUp) || key.Matches(msg, messageKeys.HalfPageDown) {
116			return m, nil
117		}
118		if key.Matches(msg, editorMaps.OpenEditor) {
119			if m.app.CoderAgent.IsSessionBusy(m.session.ID) {
120				return m, util.ReportWarn("Agent is working, please wait...")
121			}
122			return m, openEditor()
123		}
124		// Handle Enter key
125		if m.textarea.Focused() && key.Matches(msg, editorMaps.Send) {
126			value := m.textarea.Value()
127			if len(value) > 0 && value[len(value)-1] == '\\' {
128				// If the last character is a backslash, remove it and add a newline
129				m.textarea.SetValue(value[:len(value)-1] + "\n")
130				return m, nil
131			} else {
132				// Otherwise, send the message
133				return m, m.send()
134			}
135		}
136	}
137	m.textarea, cmd = m.textarea.Update(msg)
138	return m, cmd
139}
140
141func (m *editorCmp) View() string {
142	t := theme.CurrentTheme()
143
144	// Style the prompt with theme colors
145	style := lipgloss.NewStyle().
146		Padding(0, 0, 0, 1).
147		Bold(true).
148		Foreground(t.Primary())
149
150	return lipgloss.JoinHorizontal(lipgloss.Top, style.Render(">"), m.textarea.View())
151}
152
153func (m *editorCmp) SetSize(width, height int) tea.Cmd {
154	m.textarea.SetWidth(width - 3) // account for the prompt and padding right
155	m.textarea.SetHeight(height)
156	return nil
157}
158
159func (m *editorCmp) GetSize() (int, int) {
160	return m.textarea.Width(), m.textarea.Height()
161}
162
163func (m *editorCmp) BindingKeys() []key.Binding {
164	bindings := []key.Binding{}
165	bindings = append(bindings, layout.KeyMapToSlice(editorMaps)...)
166	return bindings
167}
168
169func CreateTextArea(existing *textarea.Model) textarea.Model {
170	t := theme.CurrentTheme()
171	bgColor := t.Background()
172	textColor := t.Text()
173	textMutedColor := t.TextMuted()
174
175	ta := textarea.New()
176	ta.BlurredStyle.Base = styles.BaseStyle().Background(bgColor).Foreground(textColor)
177	ta.BlurredStyle.CursorLine = styles.BaseStyle().Background(bgColor)
178	ta.BlurredStyle.Placeholder = styles.BaseStyle().Background(bgColor).Foreground(textMutedColor)
179	ta.BlurredStyle.Text = styles.BaseStyle().Background(bgColor).Foreground(textColor)
180	ta.FocusedStyle.Base = styles.BaseStyle().Background(bgColor).Foreground(textColor)
181	ta.FocusedStyle.CursorLine = styles.BaseStyle().Background(bgColor)
182	ta.FocusedStyle.Placeholder = styles.BaseStyle().Background(bgColor).Foreground(textMutedColor)
183	ta.FocusedStyle.Text = styles.BaseStyle().Background(bgColor).Foreground(textColor)
184
185	ta.Prompt = " "
186	ta.ShowLineNumbers = false
187	ta.CharLimit = -1
188
189	if existing != nil {
190		ta.SetValue(existing.Value())
191		ta.SetWidth(existing.Width())
192		ta.SetHeight(existing.Height())
193	}
194
195	ta.Focus()
196	return ta
197}
198
199func NewEditorCmp(app *app.App) tea.Model {
200	ta := CreateTextArea(nil)
201
202	return &editorCmp{
203		app:      app,
204		textarea: ta,
205	}
206}
207