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 focusedPane: PanelTypeSplash,
118 }
119}
120
121func (p *chatPage) Init() tea.Cmd {
122 cfg := config.Get()
123 if config.HasInitialDataConfig() {
124 if b, _ := config.ProjectNeedsInitialization(); b {
125 p.state = ChatStateInitProject
126 } else {
127 p.state = ChatStateNewMessage
128 p.focusedPane = PanelTypeEditor
129 }
130 }
131
132 compact := cfg.Options.TUI.CompactMode
133 p.compact = compact
134 p.forceCompact = compact
135 p.sidebar.SetCompactMode(p.compact)
136 return tea.Batch(
137 p.header.Init(),
138 p.sidebar.Init(),
139 p.chat.Init(),
140 p.editor.Init(),
141 p.splash.Init(),
142 )
143}
144
145func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
146 var cmds []tea.Cmd
147 switch msg := msg.(type) {
148 case tea.WindowSizeMsg:
149 return p, p.SetSize(msg.Width, msg.Height)
150 case CancelTimerExpiredMsg:
151 p.canceling = false
152 return p, nil
153 case chat.SendMsg:
154 return p, p.sendMessage(msg.Text, msg.Attachments)
155 case chat.SessionSelectedMsg:
156 return p, p.setSession(msg)
157 case commands.ToggleCompactModeMsg:
158 p.forceCompact = !p.forceCompact
159 var cmd tea.Cmd
160 if p.forceCompact {
161 p.setCompactMode(true)
162 cmd = p.updateCompactConfig(true)
163 } else if p.width >= CompactModeBreakpoint {
164 p.setCompactMode(false)
165 cmd = p.updateCompactConfig(false)
166 }
167 return p, tea.Batch(p.SetSize(p.width, p.height), cmd)
168 case pubsub.Event[session.Session]:
169 // this needs to go to header/sidebar
170 u, cmd := p.header.Update(msg)
171 p.header = u.(header.Header)
172 cmds = append(cmds, cmd)
173 u, cmd = p.sidebar.Update(msg)
174 p.sidebar = u.(sidebar.Sidebar)
175 cmds = append(cmds, cmd)
176 return p, tea.Batch(cmds...)
177 case chat.SessionClearedMsg:
178 u, cmd := p.header.Update(msg)
179 p.header = u.(header.Header)
180 cmds = append(cmds, cmd)
181 u, cmd = p.sidebar.Update(msg)
182 p.sidebar = u.(sidebar.Sidebar)
183 cmds = append(cmds, cmd)
184 u, cmd = p.chat.Update(msg)
185 p.chat = u.(chat.MessageListCmp)
186 cmds = append(cmds, cmd)
187 return p, tea.Batch(cmds...)
188 case filepicker.FilePickedMsg,
189 completions.CompletionsClosedMsg,
190 completions.SelectCompletionMsg:
191 u, cmd := p.editor.Update(msg)
192 p.editor = u.(editor.Editor)
193 cmds = append(cmds, cmd)
194 return p, tea.Batch(cmds...)
195
196 case pubsub.Event[message.Message],
197 anim.StepMsg,
198 spinner.TickMsg:
199 // this needs to go to chat
200 u, cmd := p.chat.Update(msg)
201 p.chat = u.(chat.MessageListCmp)
202 cmds = append(cmds, cmd)
203 return p, tea.Batch(cmds...)
204
205 case pubsub.Event[history.File], sidebar.SessionFilesMsg:
206 // this needs to go to sidebar
207 u, cmd := p.sidebar.Update(msg)
208 p.sidebar = u.(sidebar.Sidebar)
209 cmds = append(cmds, cmd)
210 return p, tea.Batch(cmds...)
211
212 case commands.CommandRunCustomMsg:
213 // Check if the agent is busy before executing custom commands
214 if p.app.CoderAgent.IsBusy() {
215 return p, util.ReportWarn("Agent is busy, please wait before executing a command...")
216 }
217
218 // Handle custom command execution
219 cmd := p.sendMessage(msg.Content, nil)
220 if cmd != nil {
221 return p, cmd
222 }
223 case splash.OnboardingCompleteMsg:
224 p.state = ChatStateNewMessage
225 err := p.app.InitCoderAgent()
226 if err != nil {
227 return p, util.ReportError(err)
228 }
229 p.focusedPane = PanelTypeEditor
230 return p, p.SetSize(p.width, p.height)
231 case tea.KeyPressMsg:
232 switch {
233 case key.Matches(msg, p.keyMap.NewSession):
234 return p, p.newSession()
235 case key.Matches(msg, p.keyMap.AddAttachment):
236 agentCfg := config.Get().Agents["coder"]
237 model := config.Get().GetModelByType(agentCfg.Model)
238 if model.SupportsImages {
239 return p, util.CmdHandler(OpenFilePickerMsg{})
240 } else {
241 return p, util.ReportWarn("File attachments are not supported by the current model: " + model.Name)
242 }
243 case key.Matches(msg, p.keyMap.Tab):
244 if p.state == ChatStateOnboarding || p.state == ChatStateInitProject {
245 u, cmd := p.splash.Update(msg)
246 p.splash = u.(splash.Splash)
247 return p, cmd
248 }
249 p.changeFocus()
250 return p, nil
251 case key.Matches(msg, p.keyMap.Cancel):
252 return p, p.cancel()
253 case key.Matches(msg, p.keyMap.Details):
254 p.showDetails()
255 return p, nil
256 }
257
258 // Send the key press to the focused pane
259 switch p.focusedPane {
260 case PanelTypeChat:
261 u, cmd := p.chat.Update(msg)
262 p.chat = u.(chat.MessageListCmp)
263 cmds = append(cmds, cmd)
264 case PanelTypeEditor:
265 u, cmd := p.editor.Update(msg)
266 p.editor = u.(editor.Editor)
267 cmds = append(cmds, cmd)
268 case PanelTypeSplash:
269 u, cmd := p.splash.Update(msg)
270 p.splash = u.(splash.Splash)
271 cmds = append(cmds, cmd)
272 }
273 }
274 return p, tea.Batch(cmds...)
275}
276
277func (p *chatPage) View() tea.View {
278 var chatView tea.View
279 t := styles.CurrentTheme()
280 switch p.state {
281 case ChatStateOnboarding, ChatStateInitProject:
282 chatView = p.splash.View()
283 case ChatStateNewMessage:
284 editorView := p.editor.View()
285 chatView = tea.NewView(
286 lipgloss.JoinVertical(
287 lipgloss.Left,
288 t.S().Base.Render(
289 p.splash.View().String(),
290 ),
291 editorView.String(),
292 ),
293 )
294 chatView.SetCursor(editorView.Cursor())
295 case ChatStateInSession:
296 messagesView := p.chat.View()
297 editorView := p.editor.View()
298 if p.compact {
299 headerView := p.header.View()
300 chatView = tea.NewView(
301 lipgloss.JoinVertical(
302 lipgloss.Left,
303 headerView.String(),
304 messagesView.String(),
305 editorView.String(),
306 ),
307 )
308 chatView.SetCursor(editorView.Cursor())
309 } else {
310 sidebarView := p.sidebar.View()
311 messages := lipgloss.JoinHorizontal(
312 lipgloss.Left,
313 messagesView.String(),
314 sidebarView.String(),
315 )
316 chatView = tea.NewView(
317 lipgloss.JoinVertical(
318 lipgloss.Left,
319 messages,
320 p.editor.View().String(),
321 ),
322 )
323 chatView.SetCursor(editorView.Cursor())
324 }
325 default:
326 chatView = tea.NewView("Unknown chat state")
327 }
328
329 layers := []*lipgloss.Layer{
330 lipgloss.NewLayer(chatView.String()).X(0).Y(0),
331 }
332
333 if p.showingDetails {
334 style := t.S().Base.
335 Width(p.detailsWidth).
336 Border(lipgloss.RoundedBorder()).
337 BorderForeground(t.BorderFocus)
338 version := t.S().Subtle.Width(p.detailsWidth - 2).AlignHorizontal(lipgloss.Right).Render(version.Version)
339 details := style.Render(
340 lipgloss.JoinVertical(
341 lipgloss.Left,
342 p.sidebar.View().String(),
343 version,
344 ),
345 )
346 layers = append(layers, lipgloss.NewLayer(details).X(1).Y(1))
347 }
348 canvas := lipgloss.NewCanvas(
349 layers...,
350 )
351 view := tea.NewView(canvas.Render())
352 view.SetCursor(chatView.Cursor())
353 return view
354}
355
356func (p *chatPage) updateCompactConfig(compact bool) tea.Cmd {
357 return func() tea.Msg {
358 err := config.Get().SetCompactMode(compact)
359 if err != nil {
360 return util.InfoMsg{
361 Type: util.InfoTypeError,
362 Msg: "Failed to update compact mode configuration: " + err.Error(),
363 }
364 }
365 return nil
366 }
367}
368
369func (p *chatPage) setCompactMode(compact bool) {
370 if p.compact == compact {
371 return
372 }
373 p.compact = compact
374 if compact {
375 p.compact = true
376 p.sidebar.SetCompactMode(true)
377 } else {
378 p.compact = false
379 p.showingDetails = false
380 p.sidebar.SetCompactMode(false)
381 }
382}
383
384func (p *chatPage) handleCompactMode(newWidth int) {
385 if p.forceCompact {
386 return
387 }
388 if newWidth < CompactModeBreakpoint && !p.compact {
389 p.setCompactMode(true)
390 }
391 if newWidth >= CompactModeBreakpoint && p.compact {
392 p.setCompactMode(false)
393 }
394}
395
396func (p *chatPage) SetSize(width, height int) tea.Cmd {
397 p.handleCompactMode(width)
398 p.width = width
399 p.height = height
400 var cmds []tea.Cmd
401 switch p.state {
402 case ChatStateOnboarding, ChatStateInitProject:
403 // here we should just have the splash screen
404 cmds = append(cmds, p.splash.SetSize(width, height))
405 case ChatStateNewMessage:
406 cmds = append(cmds, p.splash.SetSize(width, height-EditorHeight))
407 cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
408 cmds = append(cmds, p.editor.SetPosition(0, height-EditorHeight))
409 case ChatStateInSession:
410 if p.compact {
411 cmds = append(cmds, p.chat.SetSize(width, height-EditorHeight-HeaderHeight))
412 // In compact mode, the sidebar is shown in the details section, the width needs to be adjusted for the padding and border
413 p.detailsWidth = width - 2 // because of position
414 cmds = append(cmds, p.sidebar.SetSize(p.detailsWidth-2, p.detailsHeight-2)) // adjust for border
415 cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
416 cmds = append(cmds, p.header.SetWidth(width-1))
417 } else {
418 cmds = append(cmds, p.chat.SetSize(width-SideBarWidth, height-EditorHeight))
419 cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
420 cmds = append(cmds, p.sidebar.SetSize(SideBarWidth, height-EditorHeight))
421 }
422 cmds = append(cmds, p.editor.SetPosition(0, height-EditorHeight))
423 }
424 return tea.Batch(cmds...)
425}
426
427func (p *chatPage) newSession() tea.Cmd {
428 if p.state != ChatStateInSession {
429 // Cannot start a new session if we are not in the session state
430 return nil
431 }
432
433 // blank session
434 p.session = session.Session{}
435 p.state = ChatStateNewMessage
436 p.focusedPane = PanelTypeEditor
437 p.canceling = false
438 // Reset the chat and editor components
439 return tea.Batch(
440 util.CmdHandler(chat.SessionClearedMsg{}),
441 p.SetSize(p.width, p.height),
442 )
443}
444
445func (p *chatPage) setSession(session session.Session) tea.Cmd {
446 if p.session.ID == session.ID {
447 return nil
448 }
449
450 var cmds []tea.Cmd
451 p.session = session
452 // We want to first resize the components
453 if p.state != ChatStateInSession {
454 p.state = ChatStateInSession
455 cmds = append(cmds, p.SetSize(p.width, p.height))
456 }
457 cmds = append(cmds, p.chat.SetSession(session))
458 cmds = append(cmds, p.sidebar.SetSession(session))
459 cmds = append(cmds, p.header.SetSession(session))
460 cmds = append(cmds, p.editor.SetSession(session))
461
462 return tea.Sequence(cmds...)
463}
464
465func (p *chatPage) changeFocus() {
466 if p.state != ChatStateInSession {
467 // Cannot change focus if we are not in the session state
468 return
469 }
470 switch p.focusedPane {
471 case PanelTypeChat:
472 p.focusedPane = PanelTypeEditor
473 p.editor.Focus()
474 p.chat.Blur()
475 case PanelTypeEditor:
476 p.focusedPane = PanelTypeChat
477 p.chat.Focus()
478 p.editor.Blur()
479 }
480}
481
482func (p *chatPage) cancel() tea.Cmd {
483 if p.state != ChatStateInSession || !p.app.CoderAgent.IsBusy() {
484 // Cannot cancel if we are not in the session state
485 return nil
486 }
487
488 // second press of cancel key will actually cancel the session
489 if p.canceling {
490 p.canceling = false
491 p.app.CoderAgent.Cancel(p.session.ID)
492 return nil
493 }
494
495 p.canceling = true
496 return cancelTimerCmd()
497}
498
499func (p *chatPage) showDetails() {
500 if p.state != ChatStateInSession || !p.compact {
501 // Cannot show details if we are not in the session state or if we are not in compact mode
502 return
503 }
504 p.showingDetails = !p.showingDetails
505 p.header.SetDetailsOpen(p.showingDetails)
506}
507
508func (p *chatPage) sendMessage(text string, attachments []message.Attachment) tea.Cmd {
509 session := p.session
510 var cmds []tea.Cmd
511 if p.state != ChatStateInSession {
512 // branch new session
513 newSession, err := p.app.Sessions.Create(context.Background(), "New Session")
514 if err != nil {
515 return util.ReportError(err)
516 }
517 session = newSession
518 cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(session)))
519 }
520 _, err := p.app.CoderAgent.Run(context.Background(), session.ID, text, attachments...)
521 if err != nil {
522 return util.ReportError(err)
523 }
524 return tea.Batch(cmds...)
525}
526
527func (p *chatPage) Bindings() []key.Binding {
528 bindings := []key.Binding{
529 p.keyMap.NewSession,
530 p.keyMap.AddAttachment,
531 }
532 if p.app.CoderAgent != nil && p.app.CoderAgent.IsBusy() {
533 cancelBinding := p.keyMap.Cancel
534 if p.canceling {
535 cancelBinding = key.NewBinding(
536 key.WithKeys("esc"),
537 key.WithHelp("esc", "press again to cancel"),
538 )
539 }
540 bindings = append([]key.Binding{cancelBinding}, bindings...)
541 }
542
543 switch p.focusedPane {
544 case PanelTypeChat:
545 bindings = append([]key.Binding{
546 key.NewBinding(
547 key.WithKeys("tab"),
548 key.WithHelp("tab", "focus editor"),
549 ),
550 }, bindings...)
551 bindings = append(bindings, p.chat.Bindings()...)
552 case PanelTypeEditor:
553 bindings = append([]key.Binding{
554 key.NewBinding(
555 key.WithKeys("tab"),
556 key.WithHelp("tab", "focus chat"),
557 ),
558 }, bindings...)
559 bindings = append(bindings, p.editor.Bindings()...)
560 case PanelTypeSplash:
561 bindings = append(bindings, p.splash.Bindings()...)
562 }
563
564 return bindings
565}