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