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}