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