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