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 EditorKeyMaps struct {
25 Send key.Binding
26 OpenEditor key.Binding
27}
28
29type bluredEditorKeyMaps struct {
30 Send key.Binding
31 Focus key.Binding
32 OpenEditor key.Binding
33}
34
35var editorMaps = EditorKeyMaps{
36 Send: key.NewBinding(
37 key.WithKeys("enter", "ctrl+s"),
38 key.WithHelp("enter", "send message"),
39 ),
40 OpenEditor: key.NewBinding(
41 key.WithKeys("ctrl+e"),
42 key.WithHelp("ctrl+e", "open editor"),
43 ),
44}
45
46func openEditor() tea.Cmd {
47 editor := os.Getenv("EDITOR")
48 if editor == "" {
49 editor = "nvim"
50 }
51
52 tmpfile, err := os.CreateTemp("", "msg_*.md")
53 if err != nil {
54 return util.ReportError(err)
55 }
56 tmpfile.Close()
57 c := exec.Command(editor, tmpfile.Name()) //nolint:gosec
58 c.Stdin = os.Stdin
59 c.Stdout = os.Stdout
60 c.Stderr = os.Stderr
61 return tea.ExecProcess(c, func(err error) tea.Msg {
62 if err != nil {
63 return util.ReportError(err)
64 }
65 content, err := os.ReadFile(tmpfile.Name())
66 if err != nil {
67 return util.ReportError(err)
68 }
69 if len(content) == 0 {
70 return util.ReportWarn("Message is empty")
71 }
72 os.Remove(tmpfile.Name())
73 return SendMsg{
74 Text: string(content),
75 }
76 })
77}
78
79func (m *editorCmp) Init() tea.Cmd {
80 return textarea.Blink
81}
82
83func (m *editorCmp) send() tea.Cmd {
84 if m.app.CoderAgent.IsSessionBusy(m.session.ID) {
85 return util.ReportWarn("Agent is working, please wait...")
86 }
87
88 value := m.textarea.Value()
89 m.textarea.Reset()
90 if value == "" {
91 return nil
92 }
93 return tea.Batch(
94 util.CmdHandler(SendMsg{
95 Text: value,
96 }),
97 )
98}
99
100func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
101 var cmd tea.Cmd
102 switch msg := msg.(type) {
103 case SessionSelectedMsg:
104 if msg.ID != m.session.ID {
105 m.session = msg
106 }
107 return m, nil
108 case tea.KeyMsg:
109 if key.Matches(msg, messageKeys.PageUp) || key.Matches(msg, messageKeys.PageDown) ||
110 key.Matches(msg, messageKeys.HalfPageUp) || key.Matches(msg, messageKeys.HalfPageDown) {
111 return m, nil
112 }
113 if key.Matches(msg, editorMaps.OpenEditor) {
114 if m.app.CoderAgent.IsSessionBusy(m.session.ID) {
115 return m, util.ReportWarn("Agent is working, please wait...")
116 }
117 return m, openEditor()
118 }
119 // if the key does not match any binding, return
120 if m.textarea.Focused() && key.Matches(msg, editorMaps.Send) {
121 return m, m.send()
122 }
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 style := lipgloss.NewStyle().Padding(0, 0, 0, 1).Bold(true)
143
144 return lipgloss.JoinHorizontal(lipgloss.Top, style.Render(">"), m.textarea.View())
145}
146
147func (m *editorCmp) SetSize(width, height int) tea.Cmd {
148 m.textarea.SetWidth(width - 3) // account for the prompt and padding right
149 m.textarea.SetHeight(height)
150 return nil
151}
152
153func (m *editorCmp) GetSize() (int, int) {
154 return m.textarea.Width(), m.textarea.Height()
155}
156
157func (m *editorCmp) BindingKeys() []key.Binding {
158 bindings := []key.Binding{}
159 bindings = append(bindings, layout.KeyMapToSlice(editorMaps)...)
160 return bindings
161}
162
163func NewEditorCmp(app *app.App) tea.Model {
164 ti := textarea.New()
165 ti.Prompt = " "
166 ti.ShowLineNumbers = false
167 ti.BlurredStyle.Base = ti.BlurredStyle.Base.Background(styles.Background)
168 ti.BlurredStyle.CursorLine = ti.BlurredStyle.CursorLine.Background(styles.Background)
169 ti.BlurredStyle.Placeholder = ti.BlurredStyle.Placeholder.Background(styles.Background)
170 ti.BlurredStyle.Text = ti.BlurredStyle.Text.Background(styles.Background)
171
172 ti.FocusedStyle.Base = ti.FocusedStyle.Base.Background(styles.Background)
173 ti.FocusedStyle.CursorLine = ti.FocusedStyle.CursorLine.Background(styles.Background)
174 ti.FocusedStyle.Placeholder = ti.FocusedStyle.Placeholder.Background(styles.Background)
175 ti.FocusedStyle.Text = ti.BlurredStyle.Text.Background(styles.Background)
176 ti.CharLimit = -1
177 ti.Focus()
178 return &editorCmp{
179 app: app,
180 textarea: ti,
181 }
182}