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