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/kujtimiihoxha/opencode/internal/app"
 12	"github.com/kujtimiihoxha/opencode/internal/session"
 13	"github.com/kujtimiihoxha/opencode/internal/tui/layout"
 14	"github.com/kujtimiihoxha/opencode/internal/tui/styles"
 15	"github.com/kujtimiihoxha/opencode/internal/tui/util"
 16)
 17
 18type editorCmp struct {
 19	app      *app.App
 20	session  session.Session
 21	textarea textarea.Model
 22}
 23
 24type FocusEditorMsg bool
 25
 26type focusedEditorKeyMaps 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 KeyMaps = focusedEditorKeyMaps{
 38	Send: key.NewBinding(
 39		key.WithKeys("ctrl+s"),
 40		key.WithHelp("ctrl+s", "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 SessionSelectedMsg:
106		if msg.ID != m.session.ID {
107			m.session = msg
108		}
109		return m, nil
110	case FocusEditorMsg:
111		if msg {
112			m.textarea.Focus()
113			return m, tea.Batch(textarea.Blink, util.CmdHandler(EditorFocusMsg(true)))
114		}
115	case tea.KeyMsg:
116		if key.Matches(msg, messageKeys.PageUp) || key.Matches(msg, messageKeys.PageDown) ||
117			key.Matches(msg, messageKeys.HalfPageUp) || key.Matches(msg, messageKeys.HalfPageDown) {
118			return m, nil
119		}
120		if key.Matches(msg, KeyMaps.OpenEditor) {
121			if m.app.CoderAgent.IsSessionBusy(m.session.ID) {
122				return m, util.ReportWarn("Agent is working, please wait...")
123			}
124			return m, openEditor()
125		}
126		// if the key does not match any binding, return
127		if m.textarea.Focused() && key.Matches(msg, KeyMaps.Send) {
128			return m, m.send()
129		}
130		
131		// Handle Enter key
132		if m.textarea.Focused() && msg.String() == "enter" {
133			value := m.textarea.Value()
134			if len(value) > 0 && value[len(value)-1] == '\\' {
135				// If the last character is a backslash, remove it and add a newline
136				m.textarea.SetValue(value[:len(value)-1] + "\n")
137				return m, nil
138			} else {
139				// Otherwise, send the message
140				return m, m.send()
141			}
142		}
143	}
144	m.textarea, cmd = m.textarea.Update(msg)
145	return m, cmd
146}
147
148func (m *editorCmp) View() string {
149	style := lipgloss.NewStyle().Padding(0, 0, 0, 1).Bold(true)
150
151	return lipgloss.JoinHorizontal(lipgloss.Top, style.Render(">"), m.textarea.View())
152}
153
154func (m *editorCmp) SetSize(width, height int) tea.Cmd {
155	m.textarea.SetWidth(width - 3) // account for the prompt and padding right
156	m.textarea.SetHeight(height)
157	return nil
158}
159
160func (m *editorCmp) GetSize() (int, int) {
161	return m.textarea.Width(), m.textarea.Height()
162}
163
164func (m *editorCmp) BindingKeys() []key.Binding {
165	bindings := []key.Binding{}
166	bindings = append(bindings, layout.KeyMapToSlice(KeyMaps)...)
167	return bindings
168}
169
170func NewEditorCmp(app *app.App) tea.Model {
171	ti := textarea.New()
172	ti.Prompt = " "
173	ti.ShowLineNumbers = false
174	ti.BlurredStyle.Base = ti.BlurredStyle.Base.Background(styles.Background)
175	ti.BlurredStyle.CursorLine = ti.BlurredStyle.CursorLine.Background(styles.Background)
176	ti.BlurredStyle.Placeholder = ti.BlurredStyle.Placeholder.Background(styles.Background)
177	ti.BlurredStyle.Text = ti.BlurredStyle.Text.Background(styles.Background)
178
179	ti.FocusedStyle.Base = ti.FocusedStyle.Base.Background(styles.Background)
180	ti.FocusedStyle.CursorLine = ti.FocusedStyle.CursorLine.Background(styles.Background)
181	ti.FocusedStyle.Placeholder = ti.FocusedStyle.Placeholder.Background(styles.Background)
182	ti.FocusedStyle.Text = ti.BlurredStyle.Text.Background(styles.Background)
183	ti.CharLimit = -1
184	ti.Focus()
185	return &editorCmp{
186		app:      app,
187		textarea: ti,
188	}
189}