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 agentCfg := config.Get().Agents["coder"]
174 model := config.Get().GetModelByType(agentCfg.Model)
175 if model.SupportsImages {
176 return p, util.CmdHandler(OpenFilePickerMsg{})
177 } else {
178 return p, util.ReportWarn("File attachments are not supported by the current model: " + model.Name)
179 }
180 case key.Matches(msg, p.keyMap.Tab):
181 if p.session.ID == "" {
182 return p, nil
183 }
184 p.chatFocused = !p.chatFocused
185 if p.chatFocused {
186 cmds = append(cmds, p.layout.FocusPanel(layout.LeftPanel))
187 cmds = append(cmds, util.CmdHandler(ChatFocusedMsg{Focused: true}))
188 } else {
189 cmds = append(cmds, p.layout.FocusPanel(layout.BottomPanel))
190 cmds = append(cmds, util.CmdHandler(ChatFocusedMsg{Focused: false}))
191 }
192 return p, tea.Batch(cmds...)
193 case key.Matches(msg, p.keyMap.Cancel):
194 if p.session.ID != "" {
195 if p.cancelPending {
196 // Second ESC press - actually cancel the session
197 p.cancelPending = false
198 p.app.CoderAgent.Cancel(p.session.ID)
199 return p, nil
200 } else {
201 // First ESC press - start the timer
202 p.cancelPending = true
203 return p, p.cancelTimerCmd()
204 }
205 }
206 case key.Matches(msg, p.keyMap.Details):
207 if p.session.ID == "" || !p.compactMode {
208 return p, nil // No session to show details for
209 }
210 p.showDetails = !p.showDetails
211 p.header.SetDetailsOpen(p.showDetails)
212 if p.showDetails {
213 return p, tea.Batch()
214 }
215
216 return p, nil
217 }
218 }
219 u, cmd := p.layout.Update(msg)
220 cmds = append(cmds, cmd)
221 p.layout = u.(layout.SplitPaneLayout)
222 h, cmd := p.header.Update(msg)
223 p.header = h.(header.Header)
224 cmds = append(cmds, cmd)
225 s, cmd := p.compactSidebar.Update(msg)
226 p.compactSidebar = s.(layout.Container)
227 cmds = append(cmds, cmd)
228 return p, tea.Batch(cmds...)
229}
230
231func (p *chatPage) setMessages() tea.Cmd {
232 messagesContainer := layout.NewContainer(
233 chat.NewMessagesListCmp(p.app),
234 layout.WithPadding(1, 1, 0, 1),
235 )
236 return tea.Batch(p.layout.SetLeftPanel(messagesContainer), messagesContainer.Init())
237}
238
239func (p *chatPage) setSidebar() tea.Cmd {
240 sidebarContainer := sidebarCmp(p.app, false, p.session)
241 sidebarContainer.Init()
242 return p.layout.SetRightPanel(sidebarContainer)
243}
244
245func (p *chatPage) clearMessages() tea.Cmd {
246 return p.layout.ClearLeftPanel()
247}
248
249func (p *chatPage) setCompactMode(compact bool) tea.Cmd {
250 p.compactMode = compact
251 var cmds []tea.Cmd
252 if compact {
253 // add offset for the header
254 p.layout.SetOffset(0, 1)
255 // make space for the header
256 cmds = append(cmds, p.layout.SetSize(p.wWidth, p.wHeight-1))
257 // remove the sidebar
258 cmds = append(cmds, p.layout.ClearRightPanel())
259 return tea.Batch(cmds...)
260 } else {
261 // remove the offset for the header
262 p.layout.SetOffset(0, 0)
263 // restore the original size
264 cmds = append(cmds, p.layout.SetSize(p.wWidth, p.wHeight))
265 // set the sidebar
266 cmds = append(cmds, p.setSidebar())
267 l, cmd := p.layout.Update(chat.SessionSelectedMsg(p.session))
268 p.layout = l.(layout.SplitPaneLayout)
269 cmds = append(cmds, cmd)
270
271 return tea.Batch(cmds...)
272 }
273}
274
275func (p *chatPage) sendMessage(text string, attachments []message.Attachment) tea.Cmd {
276 var cmds []tea.Cmd
277 if p.session.ID == "" {
278 session, err := p.app.Sessions.Create(context.Background(), "New Session")
279 if err != nil {
280 return util.ReportError(err)
281 }
282
283 p.session = session
284 cmd := p.setMessages()
285 if cmd != nil {
286 cmds = append(cmds, cmd)
287 }
288 cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(session)))
289 }
290
291 _, err := p.app.CoderAgent.Run(context.Background(), p.session.ID, text, attachments...)
292 if err != nil {
293 return util.ReportError(err)
294 }
295 return tea.Batch(cmds...)
296}
297
298func (p *chatPage) SetSize(width, height int) tea.Cmd {
299 return p.layout.SetSize(width, height)
300}
301
302func (p *chatPage) GetSize() (int, int) {
303 return p.layout.GetSize()
304}
305
306func (p *chatPage) View() string {
307 if !p.compactMode || p.session.ID == "" {
308 // If not in compact mode or there is no session, we don't show the header
309 return p.layout.View()
310 }
311 layoutView := p.layout.View()
312 chatView := strings.Join(
313 []string{
314 p.header.View(),
315 layoutView,
316 }, "\n",
317 )
318 layers := []*lipgloss.Layer{
319 lipgloss.NewLayer(chatView).X(0).Y(0),
320 }
321 if p.showDetails {
322 t := styles.CurrentTheme()
323 style := t.S().Base.
324 Border(lipgloss.RoundedBorder()).
325 BorderForeground(t.BorderFocus)
326 version := t.S().Subtle.Padding(0, 1).AlignHorizontal(lipgloss.Right).Width(p.wWidth - 4).Render(version.Version)
327 details := style.Render(
328 lipgloss.JoinVertical(
329 lipgloss.Left,
330 p.compactSidebar.View(),
331 version,
332 ),
333 )
334 layers = append(layers, lipgloss.NewLayer(details).X(1).Y(1))
335 }
336 canvas := lipgloss.NewCanvas(
337 layers...,
338 )
339 return canvas.Render()
340}
341
342func (p *chatPage) Cursor() *tea.Cursor {
343 if v, ok := p.layout.(util.Cursor); ok {
344 return v.Cursor()
345 }
346 return nil
347}
348
349func (p *chatPage) Bindings() []key.Binding {
350 bindings := []key.Binding{
351 p.keyMap.NewSession,
352 p.keyMap.AddAttachment,
353 }
354 if p.app.CoderAgent.IsBusy() {
355 cancelBinding := p.keyMap.Cancel
356 if p.cancelPending {
357 cancelBinding = key.NewBinding(
358 key.WithKeys("esc"),
359 key.WithHelp("esc", "press again to cancel"),
360 )
361 }
362 bindings = append([]key.Binding{cancelBinding}, bindings...)
363 }
364
365 if p.chatFocused {
366 bindings = append([]key.Binding{
367 key.NewBinding(
368 key.WithKeys("tab"),
369 key.WithHelp("tab", "focus editor"),
370 ),
371 }, bindings...)
372 } else {
373 bindings = append([]key.Binding{
374 key.NewBinding(
375 key.WithKeys("tab"),
376 key.WithHelp("tab", "focus chat"),
377 ),
378 }, bindings...)
379 }
380
381 bindings = append(bindings, p.layout.Bindings()...)
382 return bindings
383}
384
385func sidebarCmp(app *app.App, compact bool, session session.Session) layout.Container {
386 padding := layout.WithPadding(1, 1, 1, 1)
387 if compact {
388 padding = layout.WithPadding(0, 1, 1, 1)
389 }
390 sidebar := sidebar.NewSidebarCmp(app.History, app.LSPClients, compact)
391 if session.ID != "" {
392 sidebar.SetSession(session)
393 }
394
395 return layout.NewContainer(
396 sidebar,
397 padding,
398 )
399}
400
401func NewChatPage(app *app.App) ChatPage {
402 editorContainer := layout.NewContainer(
403 editor.NewEditorCmp(app),
404 )
405 return &chatPage{
406 app: app,
407 layout: layout.NewSplitPane(
408 layout.WithRightPanel(sidebarCmp(app, false, session.Session{})),
409 layout.WithBottomPanel(editorContainer),
410 layout.WithFixedBottomHeight(5),
411 layout.WithFixedRightWidth(31),
412 ),
413 compactSidebar: sidebarCmp(app, true, session.Session{}),
414 keyMap: DefaultKeyMap(),
415 header: header.New(app.LSPClients),
416 }
417}