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