editor.go

  1package repl
  2
  3import (
  4	"strings"
  5
  6	"github.com/charmbracelet/bubbles/key"
  7	tea "github.com/charmbracelet/bubbletea"
  8	"github.com/charmbracelet/lipgloss"
  9	"github.com/kujtimiihoxha/termai/internal/app"
 10	"github.com/kujtimiihoxha/termai/internal/llm/agent"
 11	"github.com/kujtimiihoxha/termai/internal/tui/layout"
 12	"github.com/kujtimiihoxha/termai/internal/tui/styles"
 13	"github.com/kujtimiihoxha/termai/internal/tui/util"
 14	"github.com/kujtimiihoxha/vimtea"
 15	"golang.org/x/net/context"
 16)
 17
 18type EditorCmp interface {
 19	tea.Model
 20	layout.Focusable
 21	layout.Sizeable
 22	layout.Bordered
 23	layout.Bindings
 24}
 25
 26type editorCmp struct {
 27	app           *app.App
 28	editor        vimtea.Editor
 29	editorMode    vimtea.EditorMode
 30	sessionID     string
 31	focused       bool
 32	width         int
 33	height        int
 34	cancelMessage context.CancelFunc
 35}
 36
 37type editorKeyMap struct {
 38	SendMessage    key.Binding
 39	SendMessageI   key.Binding
 40	CancelMessage  key.Binding
 41	InsertMode     key.Binding
 42	NormaMode      key.Binding
 43	VisualMode     key.Binding
 44	VisualLineMode key.Binding
 45}
 46
 47var editorKeyMapValue = editorKeyMap{
 48	SendMessage: key.NewBinding(
 49		key.WithKeys("enter"),
 50		key.WithHelp("enter", "send message normal mode"),
 51	),
 52	SendMessageI: key.NewBinding(
 53		key.WithKeys("ctrl+s"),
 54		key.WithHelp("ctrl+s", "send message insert mode"),
 55	),
 56	CancelMessage: key.NewBinding(
 57		key.WithKeys("ctrl+x"),
 58		key.WithHelp("ctrl+x", "cancel current message"),
 59	),
 60	InsertMode: key.NewBinding(
 61		key.WithKeys("i"),
 62		key.WithHelp("i", "insert mode"),
 63	),
 64	NormaMode: key.NewBinding(
 65		key.WithKeys("esc"),
 66		key.WithHelp("esc", "normal mode"),
 67	),
 68	VisualMode: key.NewBinding(
 69		key.WithKeys("v"),
 70		key.WithHelp("v", "visual mode"),
 71	),
 72	VisualLineMode: key.NewBinding(
 73		key.WithKeys("V"),
 74		key.WithHelp("V", "visual line mode"),
 75	),
 76}
 77
 78func (m *editorCmp) Init() tea.Cmd {
 79	return m.editor.Init()
 80}
 81
 82func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 83	switch msg := msg.(type) {
 84	case vimtea.EditorModeMsg:
 85		m.editorMode = msg.Mode
 86	case SelectedSessionMsg:
 87		if msg.SessionID != m.sessionID {
 88			m.sessionID = msg.SessionID
 89		}
 90	}
 91	if m.IsFocused() {
 92		switch msg := msg.(type) {
 93		case tea.KeyMsg:
 94			switch {
 95			case key.Matches(msg, editorKeyMapValue.SendMessage):
 96				if m.editorMode == vimtea.ModeNormal {
 97					return m, m.Send()
 98				}
 99			case key.Matches(msg, editorKeyMapValue.SendMessageI):
100				if m.editorMode == vimtea.ModeInsert {
101					return m, m.Send()
102				}
103			case key.Matches(msg, editorKeyMapValue.CancelMessage):
104				return m, m.Cancel()
105			}
106		}
107		u, cmd := m.editor.Update(msg)
108		m.editor = u.(vimtea.Editor)
109		return m, cmd
110	}
111	return m, nil
112}
113
114func (m *editorCmp) Blur() tea.Cmd {
115	m.focused = false
116	return nil
117}
118
119func (m *editorCmp) BorderText() map[layout.BorderPosition]string {
120	title := "New Message"
121	if m.focused {
122		title = lipgloss.NewStyle().Foreground(styles.Primary).Render(title)
123	}
124	return map[layout.BorderPosition]string{
125		layout.BottomLeftBorder: title,
126	}
127}
128
129func (m *editorCmp) Focus() tea.Cmd {
130	m.focused = true
131	return m.editor.Tick()
132}
133
134func (m *editorCmp) GetSize() (int, int) {
135	return m.width, m.height
136}
137
138func (m *editorCmp) IsFocused() bool {
139	return m.focused
140}
141
142func (m *editorCmp) SetSize(width int, height int) {
143	m.width = width
144	m.height = height
145	m.editor.SetSize(width, height)
146}
147
148func (m *editorCmp) Cancel() tea.Cmd {
149	if m.cancelMessage == nil {
150		return util.ReportWarn("No message to cancel")
151	}
152
153	m.cancelMessage()
154	m.cancelMessage = nil
155	return util.ReportWarn("Message cancelled")
156}
157
158func (m *editorCmp) Send() tea.Cmd {
159	if m.cancelMessage != nil {
160		return util.ReportWarn("Assistant is still working on the previous message")
161	}
162
163	messages, err := m.app.Messages.List(context.Background(), m.sessionID)
164	if err != nil {
165		return util.ReportError(err)
166	}
167	if hasUnfinishedMessages(messages) {
168		return util.ReportWarn("Assistant is still working on the previous message")
169	}
170
171	a, err := agent.NewCoderAgent(m.app)
172	if err != nil {
173		return util.ReportError(err)
174	}
175
176	content := strings.Join(m.editor.GetBuffer().Lines(), "\n")
177	if len(content) == 0 {
178		return util.ReportWarn("Message is empty")
179	}
180	ctx, cancel := context.WithCancel(context.Background())
181	m.cancelMessage = cancel
182	go func() {
183		defer cancel()
184		a.Generate(ctx, m.sessionID, content)
185		m.cancelMessage = nil
186	}()
187
188	return m.editor.Reset()
189}
190
191func (m *editorCmp) View() string {
192	return m.editor.View()
193}
194
195func (m *editorCmp) BindingKeys() []key.Binding {
196	return layout.KeyMapToSlice(editorKeyMapValue)
197}
198
199func NewEditorCmp(app *app.App) EditorCmp {
200	editor := vimtea.NewEditor(
201		vimtea.WithFileName("message.md"),
202	)
203	return &editorCmp{
204		app:    app,
205		editor: editor,
206	}
207}