1package chat
2
3import (
4 "context"
5 "time"
6
7 "github.com/charmbracelet/bubbles/v2/key"
8 "github.com/charmbracelet/bubbles/v2/spinner"
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/history"
13 "github.com/charmbracelet/crush/internal/message"
14 "github.com/charmbracelet/crush/internal/pubsub"
15 "github.com/charmbracelet/crush/internal/session"
16 "github.com/charmbracelet/crush/internal/tui/components/anim"
17 "github.com/charmbracelet/crush/internal/tui/components/chat"
18 "github.com/charmbracelet/crush/internal/tui/components/chat/editor"
19 "github.com/charmbracelet/crush/internal/tui/components/chat/header"
20 "github.com/charmbracelet/crush/internal/tui/components/chat/sidebar"
21 "github.com/charmbracelet/crush/internal/tui/components/chat/splash"
22 "github.com/charmbracelet/crush/internal/tui/components/completions"
23 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
24 "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
25 "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
26 "github.com/charmbracelet/crush/internal/tui/page"
27 "github.com/charmbracelet/crush/internal/tui/styles"
28 "github.com/charmbracelet/crush/internal/tui/util"
29 "github.com/charmbracelet/crush/internal/version"
30 "github.com/charmbracelet/lipgloss/v2"
31)
32
33var ChatPageID page.PageID = "chat"
34
35type (
36 OpenFilePickerMsg struct{}
37 ChatFocusedMsg struct {
38 Focused bool // True if the chat input is focused, false otherwise
39 }
40 CancelTimerExpiredMsg struct{}
41)
42
43type ChatState string
44
45const (
46 ChatStateOnboarding ChatState = "onboarding"
47 ChatStateInitProject ChatState = "init_project"
48 ChatStateNewMessage ChatState = "new_message"
49 ChatStateInSession ChatState = "in_session"
50)
51
52type PanelType string
53
54const (
55 PanelTypeChat PanelType = "chat"
56 PanelTypeEditor PanelType = "editor"
57 PanelTypeSplash PanelType = "splash"
58)
59
60const (
61 CompactModeBreakpoint = 120 // Width at which the chat page switches to compact mode
62 EditorHeight = 5 // Height of the editor input area including padding
63 SideBarWidth = 31 // Width of the sidebar
64 SideBarDetailsPadding = 1 // Padding for the sidebar details section
65 HeaderHeight = 1 // Height of the header
66)
67
68type ChatPage interface {
69 util.Model
70 layout.Help
71}
72
73// cancelTimerCmd creates a command that expires the cancel timer after 2 seconds
74func cancelTimerCmd() tea.Cmd {
75 return tea.Tick(2*time.Second, func(time.Time) tea.Msg {
76 return CancelTimerExpiredMsg{}
77 })
78}
79
80type chatPage struct {
81 width, height int
82 detailsWidth, detailsHeight int
83 app *app.App
84 state ChatState
85 session session.Session
86 keyMap KeyMap
87 focusedPane PanelType
88 // Compact mode
89 compact bool
90 header header.Header
91 showingDetails bool
92
93 sidebar sidebar.Sidebar
94 chat chat.MessageListCmp
95 editor editor.Editor
96 splash splash.Splash
97 canceling bool
98
99 // This will force the compact mode even in big screens
100 // usually triggered by the user command
101 // this will also be set when the user config is set to compact mode
102 forceCompact bool
103}
104
105func New(app *app.App) ChatPage {
106 return &chatPage{
107 app: app,
108 state: ChatStateOnboarding,
109
110 keyMap: DefaultKeyMap(),
111
112 header: header.New(app.LSPClients),
113 sidebar: sidebar.New(app.History, app.LSPClients, false),
114 chat: chat.New(app),
115 editor: editor.New(app),
116 splash: splash.New(),
117 }
118}
119
120func (p *chatPage) Init() tea.Cmd {
121 cfg := config.Get()
122 if cfg.IsReady() {
123 if b, _ := config.ProjectNeedsInitialization(); b {
124 p.state = ChatStateInitProject
125 } else {
126 p.state = ChatStateNewMessage
127 p.focusedPane = PanelTypeEditor
128 }
129 }
130
131 compact := cfg.Options.TUI.CompactMode
132 p.compact = compact
133 p.forceCompact = compact
134 p.sidebar.SetCompactMode(p.compact)
135 return tea.Batch(
136 p.header.Init(),
137 p.sidebar.Init(),
138 p.chat.Init(),
139 p.editor.Init(),
140 p.splash.Init(),
141 )
142}
143
144func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
145 var cmds []tea.Cmd
146 switch msg := msg.(type) {
147 case tea.WindowSizeMsg:
148 return p, p.SetSize(msg.Width, msg.Height)
149 case CancelTimerExpiredMsg:
150 p.canceling = false
151 return p, nil
152 case chat.SendMsg:
153 return p, p.sendMessage(msg.Text, msg.Attachments)
154 case chat.SessionSelectedMsg:
155 return p, p.setSession(msg)
156 case commands.ToggleCompactModeMsg:
157 p.forceCompact = !p.forceCompact
158 if p.forceCompact {
159 p.setCompactMode(true)
160 } else if p.width >= CompactModeBreakpoint {
161 p.setCompactMode(false)
162 }
163 return p, p.SetSize(p.width, p.height)
164 case pubsub.Event[session.Session]:
165 // this needs to go to header/sidebar
166 u, cmd := p.header.Update(msg)
167 p.header = u.(header.Header)
168 cmds = append(cmds, cmd)
169 u, cmd = p.sidebar.Update(msg)
170 p.sidebar = u.(sidebar.Sidebar)
171 cmds = append(cmds, cmd)
172 return p, tea.Batch(cmds...)
173 case chat.SessionClearedMsg:
174 u, cmd := p.header.Update(msg)
175 p.header = u.(header.Header)
176 cmds = append(cmds, cmd)
177 u, cmd = p.sidebar.Update(msg)
178 p.sidebar = u.(sidebar.Sidebar)
179 cmds = append(cmds, cmd)
180 u, cmd = p.chat.Update(msg)
181 p.chat = u.(chat.MessageListCmp)
182 cmds = append(cmds, cmd)
183 return p, tea.Batch(cmds...)
184 case filepicker.FilePickedMsg,
185 completions.CompletionsClosedMsg,
186 completions.SelectCompletionMsg:
187 u, cmd := p.editor.Update(msg)
188 p.editor = u.(editor.Editor)
189 cmds = append(cmds, cmd)
190 return p, tea.Batch(cmds...)
191
192 case pubsub.Event[message.Message],
193 anim.StepMsg,
194 spinner.TickMsg:
195 // this needs to go to chat
196 u, cmd := p.chat.Update(msg)
197 p.chat = u.(chat.MessageListCmp)
198 cmds = append(cmds, cmd)
199 return p, tea.Batch(cmds...)
200
201 case pubsub.Event[history.File], sidebar.SessionFilesMsg:
202 // this needs to go to sidebar
203 u, cmd := p.sidebar.Update(msg)
204 p.sidebar = u.(sidebar.Sidebar)
205 cmds = append(cmds, cmd)
206 return p, tea.Batch(cmds...)
207
208 case commands.CommandRunCustomMsg:
209 // Check if the agent is busy before executing custom commands
210 if p.app.CoderAgent.IsBusy() {
211 return p, util.ReportWarn("Agent is busy, please wait before executing a command...")
212 }
213
214 // Handle custom command execution
215 cmd := p.sendMessage(msg.Content, nil)
216 if cmd != nil {
217 return p, cmd
218 }
219 case tea.KeyPressMsg:
220 switch {
221 case key.Matches(msg, p.keyMap.NewSession):
222 return p, p.newSession()
223 case key.Matches(msg, p.keyMap.AddAttachment):
224 agentCfg := config.Get().Agents["coder"]
225 model := config.Get().GetModelByType(agentCfg.Model)
226 if model.SupportsImages {
227 return p, util.CmdHandler(OpenFilePickerMsg{})
228 } else {
229 return p, util.ReportWarn("File attachments are not supported by the current model: " + model.Name)
230 }
231 case key.Matches(msg, p.keyMap.Tab):
232 p.changeFocus()
233 return p, nil
234 case key.Matches(msg, p.keyMap.Cancel):
235 return p, p.cancel()
236 case key.Matches(msg, p.keyMap.Details):
237 p.showDetails()
238 return p, nil
239 }
240
241 // Send the key press to the focused pane
242 switch p.focusedPane {
243 case PanelTypeChat:
244 u, cmd := p.chat.Update(msg)
245 p.chat = u.(chat.MessageListCmp)
246 cmds = append(cmds, cmd)
247 case PanelTypeEditor:
248 u, cmd := p.editor.Update(msg)
249 p.editor = u.(editor.Editor)
250 cmds = append(cmds, cmd)
251 }
252 }
253 return p, tea.Batch(cmds...)
254}
255
256func (p *chatPage) View() tea.View {
257 var chatView tea.View
258 t := styles.CurrentTheme()
259 switch p.state {
260 case ChatStateOnboarding, ChatStateInitProject:
261 chatView = tea.NewView(
262 t.S().Base.Render(
263 p.splash.View().String(),
264 ),
265 )
266 case ChatStateNewMessage:
267 editorView := p.editor.View()
268 chatView = tea.NewView(
269 lipgloss.JoinVertical(
270 lipgloss.Left,
271 t.S().Base.Render(
272 p.splash.View().String(),
273 ),
274 editorView.String(),
275 ),
276 )
277 chatView.SetCursor(editorView.Cursor())
278 case ChatStateInSession:
279 messagesView := p.chat.View()
280 editorView := p.editor.View()
281 if p.compact {
282 headerView := p.header.View()
283 chatView = tea.NewView(
284 lipgloss.JoinVertical(
285 lipgloss.Left,
286 headerView.String(),
287 messagesView.String(),
288 editorView.String(),
289 ),
290 )
291 chatView.SetCursor(editorView.Cursor())
292 } else {
293 sidebarView := p.sidebar.View()
294 messages := lipgloss.JoinHorizontal(
295 lipgloss.Left,
296 messagesView.String(),
297 sidebarView.String(),
298 )
299 chatView = tea.NewView(
300 lipgloss.JoinVertical(
301 lipgloss.Left,
302 messages,
303 p.editor.View().String(),
304 ),
305 )
306 chatView.SetCursor(editorView.Cursor())
307 }
308 default:
309 chatView = tea.NewView("Unknown chat state")
310 }
311
312 layers := []*lipgloss.Layer{
313 lipgloss.NewLayer(chatView.String()).X(0).Y(0),
314 }
315
316 if p.showingDetails {
317 style := t.S().Base.
318 Width(p.detailsWidth).
319 Border(lipgloss.RoundedBorder()).
320 BorderForeground(t.BorderFocus)
321 version := t.S().Subtle.Width(p.detailsWidth - 2).AlignHorizontal(lipgloss.Right).Render(version.Version)
322 details := style.Render(
323 lipgloss.JoinVertical(
324 lipgloss.Left,
325 p.sidebar.View().String(),
326 version,
327 ),
328 )
329 layers = append(layers, lipgloss.NewLayer(details).X(1).Y(1))
330 }
331 canvas := lipgloss.NewCanvas(
332 layers...,
333 )
334 view := tea.NewView(canvas.Render())
335 view.SetCursor(chatView.Cursor())
336 return view
337}
338
339func (p *chatPage) setCompactMode(compact bool) {
340 if p.compact == compact {
341 return
342 }
343 p.compact = compact
344 if compact {
345 p.compact = true
346 p.sidebar.SetCompactMode(true)
347 } else {
348 p.compact = false
349 p.showingDetails = false
350 p.sidebar.SetCompactMode(false)
351 }
352}
353
354func (p *chatPage) handleCompactMode(newWidth int) {
355 if p.forceCompact {
356 return
357 }
358 if newWidth < CompactModeBreakpoint && !p.compact {
359 p.setCompactMode(true)
360 }
361 if newWidth >= CompactModeBreakpoint && p.compact {
362 p.setCompactMode(false)
363 }
364}
365
366func (p *chatPage) SetSize(width, height int) tea.Cmd {
367 p.handleCompactMode(width)
368 p.width = width
369 p.height = height
370 var cmds []tea.Cmd
371 switch p.state {
372 case ChatStateOnboarding, ChatStateInitProject:
373 // here we should just have the splash screen
374 cmds = append(cmds, p.splash.SetSize(width, height))
375 case ChatStateNewMessage:
376 cmds = append(cmds, p.splash.SetSize(width, height-EditorHeight))
377 cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
378 cmds = append(cmds, p.editor.SetPosition(0, height-EditorHeight))
379 case ChatStateInSession:
380 if p.compact {
381 cmds = append(cmds, p.chat.SetSize(width, height-EditorHeight-HeaderHeight))
382 // In compact mode, the sidebar is shown in the details section, the width needs to be adjusted for the padding and border
383 p.detailsWidth = width - 2 // because of position
384 cmds = append(cmds, p.sidebar.SetSize(p.detailsWidth-2, p.detailsHeight-2)) // adjust for border
385 cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
386 cmds = append(cmds, p.header.SetWidth(width-1))
387 } else {
388 cmds = append(cmds, p.chat.SetSize(width-SideBarWidth, height-EditorHeight))
389 cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
390 cmds = append(cmds, p.sidebar.SetSize(SideBarWidth, height-EditorHeight))
391 }
392 cmds = append(cmds, p.editor.SetPosition(0, height-EditorHeight))
393 }
394 return tea.Batch(cmds...)
395}
396
397func (p *chatPage) newSession() tea.Cmd {
398 if p.state != ChatStateInSession {
399 // Cannot start a new session if we are not in the session state
400 return nil
401 }
402 // blank session
403 p.session = session.Session{}
404 p.state = ChatStateNewMessage
405 p.focusedPane = PanelTypeEditor
406 p.canceling = false
407 // Reset the chat and editor components
408 return tea.Batch(
409 util.CmdHandler(chat.SessionClearedMsg{}),
410 p.SetSize(p.width, p.height),
411 )
412}
413
414func (p *chatPage) setSession(session session.Session) tea.Cmd {
415 if p.session.ID == session.ID {
416 return nil
417 }
418
419 var cmds []tea.Cmd
420 p.session = session
421 // We want to first resize the components
422 if p.state != ChatStateInSession {
423 p.state = ChatStateInSession
424 cmds = append(cmds, p.SetSize(p.width, p.height))
425 }
426 cmds = append(cmds, p.chat.SetSession(session))
427 cmds = append(cmds, p.sidebar.SetSession(session))
428 cmds = append(cmds, p.header.SetSession(session))
429 cmds = append(cmds, p.editor.SetSession(session))
430
431 return tea.Sequence(cmds...)
432}
433
434func (p *chatPage) changeFocus() {
435 if p.state != ChatStateInSession {
436 // Cannot change focus if we are not in the session state
437 return
438 }
439 switch p.focusedPane {
440 case PanelTypeChat:
441 p.focusedPane = PanelTypeEditor
442 case PanelTypeEditor:
443 p.focusedPane = PanelTypeChat
444 }
445}
446
447func (p *chatPage) cancel() tea.Cmd {
448 if p.state != ChatStateInSession || !p.app.CoderAgent.IsBusy() {
449 // Cannot cancel if we are not in the session state
450 return nil
451 }
452
453 // second press of cancel key will actually cancel the session
454 if p.canceling {
455 p.canceling = false
456 p.app.CoderAgent.Cancel(p.session.ID)
457 return nil
458 }
459
460 p.canceling = true
461 return cancelTimerCmd()
462}
463
464func (p *chatPage) showDetails() {
465 if p.state != ChatStateInSession || !p.compact {
466 // Cannot show details if we are not in the session state or if we are not in compact mode
467 return
468 }
469 p.showingDetails = !p.showingDetails
470 p.header.SetDetailsOpen(p.showingDetails)
471}
472
473func (p *chatPage) sendMessage(text string, attachments []message.Attachment) tea.Cmd {
474 session := p.session
475 var cmds []tea.Cmd
476 if p.state != ChatStateInSession {
477 // branch new session
478 newSession, err := p.app.Sessions.Create(context.Background(), "New Session")
479 if err != nil {
480 return util.ReportError(err)
481 }
482 session = newSession
483 cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(session)))
484 }
485 _, err := p.app.CoderAgent.Run(context.Background(), session.ID, text, attachments...)
486 if err != nil {
487 return util.ReportError(err)
488 }
489 return tea.Batch(cmds...)
490}
491
492func (p *chatPage) Bindings() []key.Binding {
493 bindings := []key.Binding{
494 p.keyMap.NewSession,
495 p.keyMap.AddAttachment,
496 }
497 if p.app.CoderAgent.IsBusy() {
498 cancelBinding := p.keyMap.Cancel
499 if p.canceling {
500 cancelBinding = key.NewBinding(
501 key.WithKeys("esc"),
502 key.WithHelp("esc", "press again to cancel"),
503 )
504 }
505 bindings = append([]key.Binding{cancelBinding}, bindings...)
506 }
507
508 switch p.focusedPane {
509 case PanelTypeChat:
510 bindings = append([]key.Binding{
511 key.NewBinding(
512 key.WithKeys("tab"),
513 key.WithHelp("tab", "focus editor"),
514 ),
515 }, bindings...)
516 bindings = append(bindings, p.chat.Bindings()...)
517 case PanelTypeEditor:
518 bindings = append([]key.Binding{
519 key.NewBinding(
520 key.WithKeys("tab"),
521 key.WithHelp("tab", "focus chat"),
522 ),
523 }, bindings...)
524 bindings = append(bindings, p.editor.Bindings()...)
525 case PanelTypeSplash:
526 bindings = append(bindings, p.splash.Bindings()...)
527 }
528
529 return bindings
530}