1package page
2
3import (
4 "context"
5 "strings"
6
7 "github.com/charmbracelet/bubbles/key"
8 tea "github.com/charmbracelet/bubbletea"
9 "github.com/charmbracelet/lipgloss"
10 "github.com/opencode-ai/opencode/internal/app"
11 "github.com/opencode-ai/opencode/internal/completions"
12 "github.com/opencode-ai/opencode/internal/message"
13 "github.com/opencode-ai/opencode/internal/session"
14 "github.com/opencode-ai/opencode/internal/tui/components/chat"
15 "github.com/opencode-ai/opencode/internal/tui/components/dialog"
16 "github.com/opencode-ai/opencode/internal/tui/layout"
17 "github.com/opencode-ai/opencode/internal/tui/util"
18)
19
20var ChatPage PageID = "chat"
21
22type chatPage struct {
23 app *app.App
24 editor layout.Container
25 messages layout.Container
26 layout layout.SplitPaneLayout
27 session session.Session
28 completionDialog dialog.CompletionDialog
29 showCompletionDialog bool
30}
31
32type ChatKeyMap struct {
33 ShowCompletionDialog key.Binding
34 NewSession key.Binding
35 Cancel key.Binding
36}
37
38var keyMap = ChatKeyMap{
39 ShowCompletionDialog: key.NewBinding(
40 key.WithKeys("@"),
41 key.WithHelp("@", "Complete"),
42 ),
43 NewSession: key.NewBinding(
44 key.WithKeys("ctrl+n"),
45 key.WithHelp("ctrl+n", "new session"),
46 ),
47 Cancel: key.NewBinding(
48 key.WithKeys("esc"),
49 key.WithHelp("esc", "cancel"),
50 ),
51}
52
53func (p *chatPage) Init() tea.Cmd {
54 cmds := []tea.Cmd{
55 p.layout.Init(),
56 p.completionDialog.Init(),
57 }
58 return tea.Batch(cmds...)
59}
60
61func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
62 var cmds []tea.Cmd
63 switch msg := msg.(type) {
64 case tea.WindowSizeMsg:
65 cmd := p.layout.SetSize(msg.Width, msg.Height)
66 cmds = append(cmds, cmd)
67 case dialog.CompletionDialogCloseMsg:
68 p.showCompletionDialog = false
69 case chat.SendMsg:
70 cmd := p.sendMessage(msg.Text, msg.Attachments)
71 if cmd != nil {
72 return p, cmd
73 }
74 case dialog.CommandRunCustomMsg:
75 // Check if the agent is busy before executing custom commands
76 if p.app.CoderAgent.IsBusy() {
77 return p, util.ReportWarn("Agent is busy, please wait before executing a command...")
78 }
79
80 // Process the command content with arguments if any
81 content := msg.Content
82 if msg.Args != nil {
83 // Replace all named arguments with their values
84 for name, value := range msg.Args {
85 placeholder := "$" + name
86 content = strings.ReplaceAll(content, placeholder, value)
87 }
88 }
89
90 // Handle custom command execution
91 cmd := p.sendMessage(content, nil)
92 if cmd != nil {
93 return p, cmd
94 }
95 case chat.SessionSelectedMsg:
96 if p.session.ID == "" {
97 cmd := p.setSidebar()
98 if cmd != nil {
99 cmds = append(cmds, cmd)
100 }
101 }
102 p.session = msg
103 case tea.KeyMsg:
104 switch {
105 case key.Matches(msg, keyMap.ShowCompletionDialog):
106 p.showCompletionDialog = true
107 // Continue sending keys to layout->chat
108 case key.Matches(msg, keyMap.NewSession):
109 p.session = session.Session{}
110 return p, tea.Batch(
111 p.clearSidebar(),
112 util.CmdHandler(chat.SessionClearedMsg{}),
113 )
114 case key.Matches(msg, keyMap.Cancel):
115 if p.session.ID != "" {
116 // Cancel the current session's generation process
117 // This allows users to interrupt long-running operations
118 p.app.CoderAgent.Cancel(p.session.ID)
119 return p, nil
120 }
121 }
122 }
123 if p.showCompletionDialog {
124 context, contextCmd := p.completionDialog.Update(msg)
125 p.completionDialog = context.(dialog.CompletionDialog)
126 cmds = append(cmds, contextCmd)
127
128 // Doesn't forward event if enter key is pressed
129 if keyMsg, ok := msg.(tea.KeyMsg); ok {
130 if keyMsg.String() == "enter" {
131 return p, tea.Batch(cmds...)
132 }
133 }
134 }
135
136 u, cmd := p.layout.Update(msg)
137 cmds = append(cmds, cmd)
138 p.layout = u.(layout.SplitPaneLayout)
139
140 return p, tea.Batch(cmds...)
141}
142
143func (p *chatPage) setSidebar() tea.Cmd {
144 sidebarContainer := layout.NewContainer(
145 chat.NewSidebarCmp(p.session, p.app.History),
146 layout.WithPadding(1, 1, 1, 1),
147 )
148 return tea.Batch(p.layout.SetRightPanel(sidebarContainer), sidebarContainer.Init())
149}
150
151func (p *chatPage) clearSidebar() tea.Cmd {
152 return p.layout.ClearRightPanel()
153}
154
155func (p *chatPage) sendMessage(text string, attachments []message.Attachment) tea.Cmd {
156 var cmds []tea.Cmd
157 if p.session.ID == "" {
158 session, err := p.app.Sessions.Create(context.Background(), "New Session")
159 if err != nil {
160 return util.ReportError(err)
161 }
162
163 p.session = session
164 cmd := p.setSidebar()
165 if cmd != nil {
166 cmds = append(cmds, cmd)
167 }
168 cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(session)))
169 }
170
171 _, err := p.app.CoderAgent.Run(context.Background(), p.session.ID, text, attachments...)
172 if err != nil {
173 return util.ReportError(err)
174 }
175 return tea.Batch(cmds...)
176}
177
178func (p *chatPage) SetSize(width, height int) tea.Cmd {
179 return p.layout.SetSize(width, height)
180}
181
182func (p *chatPage) GetSize() (int, int) {
183 return p.layout.GetSize()
184}
185
186func (p *chatPage) View() string {
187 layoutView := p.layout.View()
188
189 if p.showCompletionDialog {
190 _, layoutHeight := p.layout.GetSize()
191 editorWidth, editorHeight := p.editor.GetSize()
192
193 p.completionDialog.SetWidth(editorWidth)
194 overlay := p.completionDialog.View()
195
196 layoutView = layout.PlaceOverlay(
197 0,
198 layoutHeight-editorHeight-lipgloss.Height(overlay),
199 overlay,
200 layoutView,
201 false,
202 )
203 }
204
205 return layoutView
206}
207
208func (p *chatPage) BindingKeys() []key.Binding {
209 bindings := layout.KeyMapToSlice(keyMap)
210 bindings = append(bindings, p.messages.BindingKeys()...)
211 bindings = append(bindings, p.editor.BindingKeys()...)
212 return bindings
213}
214
215func NewChatPage(app *app.App) tea.Model {
216 cg := completions.NewFileAndFolderContextGroup()
217 completionDialog := dialog.NewCompletionDialogCmp(cg)
218
219 messagesContainer := layout.NewContainer(
220 chat.NewMessagesCmp(app),
221 layout.WithPadding(1, 1, 0, 1),
222 )
223 editorContainer := layout.NewContainer(
224 chat.NewEditorCmp(app),
225 layout.WithBorder(true, false, false, false),
226 )
227 return &chatPage{
228 app: app,
229 editor: editorContainer,
230 messages: messagesContainer,
231 completionDialog: completionDialog,
232 layout: layout.NewSplitPane(
233 layout.WithLeftPanel(messagesContainer),
234 layout.WithBottomPanel(editorContainer),
235 ),
236 }
237}