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.KeyMsg:
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.KeyMsg); 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() string {
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 layoutView = layout.PlaceOverlay(
199 0,
200 layoutHeight-editorHeight-lipgloss.Height(overlay),
201 overlay,
202 layoutView,
203 false,
204 )
205 }
206
207 return layoutView
208}
209
210func (p *chatPage) BindingKeys() []key.Binding {
211 bindings := layout.KeyMapToSlice(keyMap)
212 bindings = append(bindings, p.messages.BindingKeys()...)
213 bindings = append(bindings, p.editor.BindingKeys()...)
214 return bindings
215}
216
217func NewChatPage(app *app.App) util.Model {
218 cg := completions.NewFileAndFolderContextGroup()
219 completionDialog := dialog.NewCompletionDialogCmp(cg)
220
221 messagesContainer := layout.NewContainer(
222 chat.NewMessagesListCmp(app),
223 layout.WithPadding(1, 1, 0, 1),
224 )
225 editorContainer := layout.NewContainer(
226 chat.NewEditorCmp(app),
227 layout.WithBorder(true, false, false, false),
228 )
229 return &chatPage{
230 app: app,
231 editor: editorContainer,
232 messages: messagesContainer,
233 completionDialog: completionDialog,
234 layout: layout.NewSplitPane(
235 layout.WithLeftPanel(messagesContainer),
236 layout.WithBottomPanel(editorContainer),
237 ),
238 }
239}