1package chat
2
3import (
4 "context"
5 "strings"
6 "time"
7
8 "github.com/charmbracelet/bubbles/v2/key"
9 tea "github.com/charmbracelet/bubbletea/v2"
10 "github.com/charmbracelet/crush/internal/app"
11 "github.com/charmbracelet/crush/internal/config"
12 "github.com/charmbracelet/crush/internal/message"
13 "github.com/charmbracelet/crush/internal/session"
14 "github.com/charmbracelet/crush/internal/tui/components/chat"
15 "github.com/charmbracelet/crush/internal/tui/components/chat/editor"
16 "github.com/charmbracelet/crush/internal/tui/components/chat/header"
17 "github.com/charmbracelet/crush/internal/tui/components/chat/sidebar"
18 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
19 "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
20 "github.com/charmbracelet/crush/internal/tui/page"
21 "github.com/charmbracelet/crush/internal/tui/styles"
22 "github.com/charmbracelet/crush/internal/tui/util"
23 "github.com/charmbracelet/crush/internal/version"
24 "github.com/charmbracelet/lipgloss/v2"
25)
26
27var ChatPageID page.PageID = "chat"
28
29const CompactModeBreakpoint = 120 // Width at which the chat page switches to compact mode
30
31type (
32 OpenFilePickerMsg struct{}
33 ChatFocusedMsg struct {
34 Focused bool // True if the chat input is focused, false otherwise
35 }
36 CancelTimerExpiredMsg struct{}
37)
38
39type ChatPage interface {
40 util.Model
41 layout.Help
42 IsChatFocused() bool
43}
44
45type chatPage struct {
46 wWidth, wHeight int // Window dimensions
47 app *app.App
48
49 layout layout.SplitPaneLayout
50
51 session session.Session
52
53 keyMap KeyMap
54
55 chatFocused bool
56
57 compactMode bool
58 forceCompactMode bool // Force compact mode regardless of window size
59 showDetails bool // Show details in the header
60 header header.Header
61 compactSidebar layout.Container
62
63 cancelPending bool // True if ESC was pressed once and waiting for second press
64}
65
66func (p *chatPage) Init() tea.Cmd {
67 return tea.Batch(
68 p.layout.Init(),
69 p.compactSidebar.Init(),
70 p.layout.FocusPanel(layout.BottomPanel), // Focus on the bottom panel (editor),
71 )
72}
73
74// cancelTimerCmd creates a command that expires the cancel timer after 2 seconds
75func (p *chatPage) cancelTimerCmd() tea.Cmd {
76 return tea.Tick(2*time.Second, func(time.Time) tea.Msg {
77 return CancelTimerExpiredMsg{}
78 })
79}
80
81func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
82 var cmds []tea.Cmd
83 switch msg := msg.(type) {
84 case CancelTimerExpiredMsg:
85 p.cancelPending = false
86 return p, nil
87 case tea.WindowSizeMsg:
88 h, cmd := p.header.Update(msg)
89 cmds = append(cmds, cmd)
90 p.header = h.(header.Header)
91 cmds = append(cmds, p.compactSidebar.SetSize(msg.Width-4, 0))
92 // the mode is only relevant when there is a session
93 if p.session.ID != "" {
94 // Only auto-switch to compact mode if not forced
95 if !p.forceCompactMode {
96 if msg.Width <= CompactModeBreakpoint && p.wWidth > CompactModeBreakpoint {
97 p.wWidth = msg.Width
98 p.wHeight = msg.Height
99 cmds = append(cmds, p.setCompactMode(true))
100 return p, tea.Batch(cmds...)
101 } else if msg.Width > CompactModeBreakpoint && p.wWidth <= CompactModeBreakpoint {
102 p.wWidth = msg.Width
103 p.wHeight = msg.Height
104 return p, p.setCompactMode(false)
105 }
106 }
107 }
108 p.wWidth = msg.Width
109 p.wHeight = msg.Height
110 layoutHeight := msg.Height
111 if p.compactMode {
112 // make space for the header
113 layoutHeight -= 1
114 }
115 cmd = p.layout.SetSize(msg.Width, layoutHeight)
116 cmds = append(cmds, cmd)
117 return p, tea.Batch(cmds...)
118
119 case chat.SendMsg:
120 cmd := p.sendMessage(msg.Text, msg.Attachments)
121 if cmd != nil {
122 return p, cmd
123 }
124 case commands.ToggleCompactModeMsg:
125 // Only allow toggling if window width is larger than compact breakpoint
126 if p.wWidth > CompactModeBreakpoint {
127 p.forceCompactMode = !p.forceCompactMode
128 // If force compact mode is enabled, switch to compact mode
129 // If force compact mode is disabled, switch based on window size
130 if p.forceCompactMode {
131 return p, p.setCompactMode(true)
132 } else {
133 // Return to auto mode based on window size
134 shouldBeCompact := p.wWidth <= CompactModeBreakpoint
135 return p, p.setCompactMode(shouldBeCompact)
136 }
137 }
138 case commands.CommandRunCustomMsg:
139 // Check if the agent is busy before executing custom commands
140 if p.app.CoderAgent.IsBusy() {
141 return p, util.ReportWarn("Agent is busy, please wait before executing a command...")
142 }
143
144 // Handle custom command execution
145 cmd := p.sendMessage(msg.Content, nil)
146 if cmd != nil {
147 return p, cmd
148 }
149 case chat.SessionSelectedMsg:
150 if p.session.ID == "" {
151 cmd := p.setMessages()
152 if cmd != nil {
153 cmds = append(cmds, cmd)
154 }
155 }
156 needsModeChange := p.session.ID == ""
157 p.session = msg
158 p.header.SetSession(msg)
159 if needsModeChange && (p.wWidth <= CompactModeBreakpoint || p.forceCompactMode) {
160 cmds = append(cmds, p.setCompactMode(true))
161 }
162 case tea.KeyPressMsg:
163 switch {
164 case key.Matches(msg, p.keyMap.NewSession):
165 p.session = session.Session{}
166 return p, tea.Batch(
167 p.clearMessages(),
168 util.CmdHandler(chat.SessionClearedMsg{}),
169 p.setCompactMode(false),
170 p.layout.FocusPanel(layout.BottomPanel),
171 util.CmdHandler(ChatFocusedMsg{Focused: false}),
172 )
173 case key.Matches(msg, p.keyMap.AddAttachment):
174 agentCfg := config.Get().Agents["coder"]
175 model := config.Get().GetModelByType(agentCfg.Model)
176 if model.SupportsImages {
177 return p, util.CmdHandler(OpenFilePickerMsg{})
178 } else {
179 return p, util.ReportWarn("File attachments are not supported by the current model: " + model.Model)
180 }
181 case key.Matches(msg, p.keyMap.Tab):
182 if p.session.ID == "" {
183 return p, nil
184 }
185 p.chatFocused = !p.chatFocused
186 if p.chatFocused {
187 cmds = append(cmds, p.layout.FocusPanel(layout.LeftPanel))
188 cmds = append(cmds, util.CmdHandler(ChatFocusedMsg{Focused: true}))
189 } else {
190 cmds = append(cmds, p.layout.FocusPanel(layout.BottomPanel))
191 cmds = append(cmds, util.CmdHandler(ChatFocusedMsg{Focused: false}))
192 }
193 return p, tea.Batch(cmds...)
194 case key.Matches(msg, p.keyMap.Cancel):
195 if p.session.ID != "" {
196 if p.cancelPending {
197 // Second ESC press - actually cancel the session
198 p.cancelPending = false
199 p.app.CoderAgent.Cancel(p.session.ID)
200 return p, nil
201 } else {
202 // First ESC press - start the timer
203 p.cancelPending = true
204 return p, p.cancelTimerCmd()
205 }
206 }
207 case key.Matches(msg, p.keyMap.Details):
208 if p.session.ID == "" || !p.compactMode {
209 return p, nil // No session to show details for
210 }
211 p.showDetails = !p.showDetails
212 p.header.SetDetailsOpen(p.showDetails)
213 if p.showDetails {
214 return p, tea.Batch()
215 }
216
217 return p, nil
218 }
219 }
220 u, cmd := p.layout.Update(msg)
221 cmds = append(cmds, cmd)
222 p.layout = u.(layout.SplitPaneLayout)
223 h, cmd := p.header.Update(msg)
224 p.header = h.(header.Header)
225 cmds = append(cmds, cmd)
226 s, cmd := p.compactSidebar.Update(msg)
227 p.compactSidebar = s.(layout.Container)
228 cmds = append(cmds, cmd)
229 return p, tea.Batch(cmds...)
230}
231
232func (p *chatPage) setMessages() tea.Cmd {
233 messagesContainer := layout.NewContainer(
234 chat.NewMessagesListCmp(p.app),
235 layout.WithPadding(1, 1, 0, 1),
236 )
237 return tea.Batch(p.layout.SetLeftPanel(messagesContainer), messagesContainer.Init())
238}
239
240func (p *chatPage) setSidebar() tea.Cmd {
241 sidebarContainer := sidebarCmp(p.app, false, p.session)
242 sidebarContainer.Init()
243 return p.layout.SetRightPanel(sidebarContainer)
244}
245
246func (p *chatPage) clearMessages() tea.Cmd {
247 return p.layout.ClearLeftPanel()
248}
249
250func (p *chatPage) setCompactMode(compact bool) tea.Cmd {
251 p.compactMode = compact
252 var cmds []tea.Cmd
253 if compact {
254 // add offset for the header
255 p.layout.SetOffset(0, 1)
256 // make space for the header
257 cmds = append(cmds, p.layout.SetSize(p.wWidth, p.wHeight-1))
258 // remove the sidebar
259 cmds = append(cmds, p.layout.ClearRightPanel())
260 return tea.Batch(cmds...)
261 } else {
262 // remove the offset for the header
263 p.layout.SetOffset(0, 0)
264 // restore the original size
265 cmds = append(cmds, p.layout.SetSize(p.wWidth, p.wHeight))
266 // set the sidebar
267 cmds = append(cmds, p.setSidebar())
268 l, cmd := p.layout.Update(chat.SessionSelectedMsg(p.session))
269 p.layout = l.(layout.SplitPaneLayout)
270 cmds = append(cmds, cmd)
271
272 return tea.Batch(cmds...)
273 }
274}
275
276func (p *chatPage) sendMessage(text string, attachments []message.Attachment) tea.Cmd {
277 var cmds []tea.Cmd
278 if p.session.ID == "" {
279 session, err := p.app.Sessions.Create(context.Background(), "New Session")
280 if err != nil {
281 return util.ReportError(err)
282 }
283
284 p.session = session
285 cmd := p.setMessages()
286 if cmd != nil {
287 cmds = append(cmds, cmd)
288 }
289 cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(session)))
290 }
291
292 _, err := p.app.CoderAgent.Run(context.Background(), p.session.ID, text, attachments...)
293 if err != nil {
294 return util.ReportError(err)
295 }
296 return tea.Batch(cmds...)
297}
298
299func (p *chatPage) SetSize(width, height int) tea.Cmd {
300 return p.layout.SetSize(width, height)
301}
302
303func (p *chatPage) GetSize() (int, int) {
304 return p.layout.GetSize()
305}
306
307func (p *chatPage) View() string {
308 if !p.compactMode || p.session.ID == "" {
309 // If not in compact mode or there is no session, we don't show the header
310 return p.layout.View()
311 }
312 layoutView := p.layout.View()
313 chatView := strings.Join(
314 []string{
315 p.header.View(),
316 layoutView,
317 }, "\n",
318 )
319 layers := []*lipgloss.Layer{
320 lipgloss.NewLayer(chatView).X(0).Y(0),
321 }
322 if p.showDetails {
323 t := styles.CurrentTheme()
324 style := t.S().Base.
325 Border(lipgloss.RoundedBorder()).
326 BorderForeground(t.BorderFocus)
327 version := t.S().Subtle.Padding(0, 1).AlignHorizontal(lipgloss.Right).Width(p.wWidth - 4).Render(version.Version)
328 details := style.Render(
329 lipgloss.JoinVertical(
330 lipgloss.Left,
331 p.compactSidebar.View(),
332 version,
333 ),
334 )
335 layers = append(layers, lipgloss.NewLayer(details).X(1).Y(1))
336 }
337 canvas := lipgloss.NewCanvas(
338 layers...,
339 )
340 return canvas.Render()
341}
342
343func (p *chatPage) Cursor() *tea.Cursor {
344 if v, ok := p.layout.(util.Cursor); ok {
345 return v.Cursor()
346 }
347 return nil
348}
349
350func (p *chatPage) Bindings() []key.Binding {
351 bindings := []key.Binding{
352 p.keyMap.NewSession,
353 p.keyMap.AddAttachment,
354 }
355 if p.app.CoderAgent.IsBusy() {
356 cancelBinding := p.keyMap.Cancel
357 if p.cancelPending {
358 cancelBinding = key.NewBinding(
359 key.WithKeys("esc"),
360 key.WithHelp("esc", "press again to cancel"),
361 )
362 }
363 bindings = append([]key.Binding{cancelBinding}, bindings...)
364 }
365
366 if p.chatFocused {
367 bindings = append([]key.Binding{
368 key.NewBinding(
369 key.WithKeys("tab"),
370 key.WithHelp("tab", "focus editor"),
371 ),
372 }, bindings...)
373 } else {
374 bindings = append([]key.Binding{
375 key.NewBinding(
376 key.WithKeys("tab"),
377 key.WithHelp("tab", "focus chat"),
378 ),
379 }, bindings...)
380 }
381
382 bindings = append(bindings, p.layout.Bindings()...)
383 return bindings
384}
385
386func sidebarCmp(app *app.App, compact bool, session session.Session) layout.Container {
387 padding := layout.WithPadding(1, 1, 1, 1)
388 if compact {
389 padding = layout.WithPadding(0, 1, 1, 1)
390 }
391 sidebar := sidebar.NewSidebarCmp(app.History, app.LSPClients, compact)
392 if session.ID != "" {
393 sidebar.SetSession(session)
394 }
395
396 return layout.NewContainer(
397 sidebar,
398 padding,
399 )
400}
401
402func NewChatPage(app *app.App) ChatPage {
403 editorContainer := layout.NewContainer(
404 editor.NewEditorCmp(app),
405 )
406 return &chatPage{
407 app: app,
408 layout: layout.NewSplitPane(
409 layout.WithRightPanel(sidebarCmp(app, false, session.Session{})),
410 layout.WithBottomPanel(editorContainer),
411 layout.WithFixedBottomHeight(5),
412 layout.WithFixedRightWidth(31),
413 ),
414 compactSidebar: sidebarCmp(app, true, session.Session{}),
415 keyMap: DefaultKeyMap(),
416 header: header.New(app.LSPClients),
417 }
418}
419
420// IsChatFocused returns whether the chat messages are focused (true) or editor is focused (false)
421func (p *chatPage) IsChatFocused() bool {
422 return p.chatFocused
423}