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