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
39 }
40 CancelTimerExpiredMsg struct{}
41)
42
43type PanelType string
44
45const (
46 PanelTypeChat PanelType = "chat"
47 PanelTypeEditor PanelType = "editor"
48 PanelTypeSplash PanelType = "splash"
49)
50
51const (
52 CompactModeBreakpoint = 120 // Width at which the chat page switches to compact mode
53 EditorHeight = 5 // Height of the editor input area including padding
54 SideBarWidth = 31 // Width of the sidebar
55 SideBarDetailsPadding = 1 // Padding for the sidebar details section
56 HeaderHeight = 1 // Height of the header
57
58 // Layout constants for borders and padding
59 BorderWidth = 1 // Width of component borders
60 LeftRightBorders = 2 // Left + right border width (1 + 1)
61 TopBottomBorders = 2 // Top + bottom border width (1 + 1)
62 DetailsPositioning = 2 // Positioning adjustment for details panel
63
64 // Timing constants
65 CancelTimerDuration = 2 * time.Second // Duration before cancel timer expires
66)
67
68type ChatPage interface {
69 util.Model
70 layout.Help
71 IsChatFocused() bool
72}
73
74// cancelTimerCmd creates a command that expires the cancel timer
75func cancelTimerCmd() tea.Cmd {
76 return tea.Tick(CancelTimerDuration, func(time.Time) tea.Msg {
77 return CancelTimerExpiredMsg{}
78 })
79}
80
81type chatPage struct {
82 width, height int
83 detailsWidth, detailsHeight int
84 app *app.App
85
86 // Layout state
87 compact bool
88 forceCompact bool
89 focusedPane PanelType
90
91 // Session
92 session session.Session
93 keyMap KeyMap
94
95 // Components
96 header header.Header
97 sidebar sidebar.Sidebar
98 chat chat.MessageListCmp
99 editor editor.Editor
100 splash splash.Splash
101
102 // Simple state flags
103 showingDetails bool
104 isCanceling bool
105 splashFullScreen bool
106}
107
108func New(app *app.App) ChatPage {
109 return &chatPage{
110 app: app,
111 keyMap: DefaultKeyMap(),
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 compact := cfg.Options.TUI.CompactMode
124 p.compact = compact
125 p.forceCompact = compact
126 p.sidebar.SetCompactMode(p.compact)
127
128 // Set splash state based on config
129 if !config.HasInitialDataConfig() {
130 // First-time setup: show model selection
131 p.splash.SetOnboarding(true)
132 p.splashFullScreen = true
133 } else if b, _ := config.ProjectNeedsInitialization(); b {
134 // Project needs CRUSH.md initialization
135 p.splash.SetProjectInit(true)
136 p.splashFullScreen = true
137 } else {
138 // Ready to chat: focus editor, splash in background
139 p.focusedPane = PanelTypeEditor
140 p.splashFullScreen = false
141 }
142
143 return tea.Batch(
144 p.header.Init(),
145 p.sidebar.Init(),
146 p.chat.Init(),
147 p.editor.Init(),
148 p.splash.Init(),
149 )
150}
151
152func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
153 var cmds []tea.Cmd
154 switch msg := msg.(type) {
155 case tea.WindowSizeMsg:
156 return p, p.SetSize(msg.Width, msg.Height)
157 case CancelTimerExpiredMsg:
158 p.isCanceling = false
159 return p, nil
160 case chat.SendMsg:
161 return p, p.sendMessage(msg.Text, msg.Attachments)
162 case chat.SessionSelectedMsg:
163 return p, p.setSession(msg)
164 case commands.ToggleCompactModeMsg:
165 p.forceCompact = !p.forceCompact
166 var cmd tea.Cmd
167 if p.forceCompact {
168 p.setCompactMode(true)
169 cmd = p.updateCompactConfig(true)
170 } else if p.width >= CompactModeBreakpoint {
171 p.setCompactMode(false)
172 cmd = p.updateCompactConfig(false)
173 }
174 return p, tea.Batch(p.SetSize(p.width, p.height), cmd)
175 case pubsub.Event[session.Session]:
176 u, cmd := p.header.Update(msg)
177 p.header = u.(header.Header)
178 cmds = append(cmds, cmd)
179 u, cmd = p.sidebar.Update(msg)
180 p.sidebar = u.(sidebar.Sidebar)
181 cmds = append(cmds, cmd)
182 return p, tea.Batch(cmds...)
183 case chat.SessionClearedMsg:
184 u, cmd := p.header.Update(msg)
185 p.header = u.(header.Header)
186 cmds = append(cmds, cmd)
187 u, cmd = p.sidebar.Update(msg)
188 p.sidebar = u.(sidebar.Sidebar)
189 cmds = append(cmds, cmd)
190 u, cmd = p.chat.Update(msg)
191 p.chat = u.(chat.MessageListCmp)
192 cmds = append(cmds, cmd)
193 return p, tea.Batch(cmds...)
194 case filepicker.FilePickedMsg,
195 completions.CompletionsClosedMsg,
196 completions.SelectCompletionMsg:
197 u, cmd := p.editor.Update(msg)
198 p.editor = u.(editor.Editor)
199 cmds = append(cmds, cmd)
200 return p, tea.Batch(cmds...)
201
202 case pubsub.Event[message.Message],
203 anim.StepMsg,
204 spinner.TickMsg:
205 u, cmd := p.chat.Update(msg)
206 p.chat = u.(chat.MessageListCmp)
207 cmds = append(cmds, cmd)
208 return p, tea.Batch(cmds...)
209
210 case pubsub.Event[history.File], sidebar.SessionFilesMsg:
211 u, cmd := p.sidebar.Update(msg)
212 p.sidebar = u.(sidebar.Sidebar)
213 cmds = append(cmds, cmd)
214 return p, tea.Batch(cmds...)
215
216 case commands.CommandRunCustomMsg:
217 if p.app.CoderAgent.IsBusy() {
218 return p, util.ReportWarn("Agent is busy, please wait before executing a command...")
219 }
220
221 cmd := p.sendMessage(msg.Content, nil)
222 if cmd != nil {
223 return p, cmd
224 }
225 case splash.OnboardingCompleteMsg:
226 p.splashFullScreen = false
227 if b, _ := config.ProjectNeedsInitialization(); b {
228 p.splash.SetProjectInit(true)
229 p.splashFullScreen = true
230 return p, p.SetSize(p.width, p.height)
231 }
232 err := p.app.InitCoderAgent()
233 if err != nil {
234 return p, util.ReportError(err)
235 }
236 p.focusedPane = PanelTypeEditor
237 return p, p.SetSize(p.width, p.height)
238 case tea.KeyPressMsg:
239 switch {
240 case key.Matches(msg, p.keyMap.NewSession):
241 return p, p.newSession()
242 case key.Matches(msg, p.keyMap.AddAttachment):
243 agentCfg := config.Get().Agents["coder"]
244 model := config.Get().GetModelByType(agentCfg.Model)
245 if model.SupportsImages {
246 return p, util.CmdHandler(OpenFilePickerMsg{})
247 } else {
248 return p, util.ReportWarn("File attachments are not supported by the current model: " + model.Model)
249 }
250 case key.Matches(msg, p.keyMap.Tab):
251 if p.session.ID == "" {
252 u, cmd := p.splash.Update(msg)
253 p.splash = u.(splash.Splash)
254 return p, cmd
255 }
256 p.changeFocus()
257 return p, nil
258 case key.Matches(msg, p.keyMap.Cancel):
259 if p.session.ID != "" && p.app.CoderAgent.IsBusy() {
260 return p, p.cancel()
261 }
262 case key.Matches(msg, p.keyMap.Details):
263 p.showDetails()
264 return p, nil
265 }
266
267 switch p.focusedPane {
268 case PanelTypeChat:
269 u, cmd := p.chat.Update(msg)
270 p.chat = u.(chat.MessageListCmp)
271 cmds = append(cmds, cmd)
272 case PanelTypeEditor:
273 u, cmd := p.editor.Update(msg)
274 p.editor = u.(editor.Editor)
275 cmds = append(cmds, cmd)
276 case PanelTypeSplash:
277 u, cmd := p.splash.Update(msg)
278 p.splash = u.(splash.Splash)
279 cmds = append(cmds, cmd)
280 }
281 case tea.PasteMsg:
282 switch p.focusedPane {
283 case PanelTypeEditor:
284 u, cmd := p.editor.Update(msg)
285 p.editor = u.(editor.Editor)
286 cmds = append(cmds, cmd)
287 return p, tea.Batch(cmds...)
288 case PanelTypeChat:
289 u, cmd := p.chat.Update(msg)
290 p.chat = u.(chat.MessageListCmp)
291 cmds = append(cmds, cmd)
292 return p, tea.Batch(cmds...)
293 case PanelTypeSplash:
294 u, cmd := p.splash.Update(msg)
295 p.splash = u.(splash.Splash)
296 cmds = append(cmds, cmd)
297 return p, tea.Batch(cmds...)
298 }
299 }
300 return p, tea.Batch(cmds...)
301}
302
303func (p *chatPage) Cursor() *tea.Cursor {
304 switch p.focusedPane {
305 case PanelTypeEditor:
306 return p.editor.Cursor()
307 case PanelTypeSplash:
308 return p.splash.Cursor()
309 default:
310 return nil
311 }
312}
313
314func (p *chatPage) View() string {
315 var chatView string
316 t := styles.CurrentTheme()
317
318 if p.session.ID == "" {
319 splashView := p.splash.View()
320 // Full screen during onboarding or project initialization
321 if p.splashFullScreen {
322 chatView = splashView
323 } else {
324 // Show splash + editor for new message state
325 editorView := p.editor.View()
326 chatView = lipgloss.JoinVertical(
327 lipgloss.Left,
328 t.S().Base.Render(splashView),
329 editorView,
330 )
331 }
332 } else {
333 messagesView := p.chat.View()
334 editorView := p.editor.View()
335 if p.compact {
336 headerView := p.header.View()
337 chatView = lipgloss.JoinVertical(
338 lipgloss.Left,
339 headerView,
340 messagesView,
341 editorView,
342 )
343 } else {
344 sidebarView := p.sidebar.View()
345 messages := lipgloss.JoinHorizontal(
346 lipgloss.Left,
347 messagesView,
348 sidebarView,
349 )
350 chatView = lipgloss.JoinVertical(
351 lipgloss.Left,
352 messages,
353 p.editor.View(),
354 )
355 }
356 }
357
358 layers := []*lipgloss.Layer{
359 lipgloss.NewLayer(chatView).X(0).Y(0),
360 }
361
362 if p.showingDetails {
363 style := t.S().Base.
364 Width(p.detailsWidth).
365 Border(lipgloss.RoundedBorder()).
366 BorderForeground(t.BorderFocus)
367 version := t.S().Subtle.Width(p.detailsWidth - 2).AlignHorizontal(lipgloss.Right).Render(version.Version)
368 details := style.Render(
369 lipgloss.JoinVertical(
370 lipgloss.Left,
371 p.sidebar.View(),
372 version,
373 ),
374 )
375 layers = append(layers, lipgloss.NewLayer(details).X(1).Y(1))
376 }
377 canvas := lipgloss.NewCanvas(
378 layers...,
379 )
380 return canvas.Render()
381}
382
383func (p *chatPage) updateCompactConfig(compact bool) tea.Cmd {
384 return func() tea.Msg {
385 err := config.Get().SetCompactMode(compact)
386 if err != nil {
387 return util.InfoMsg{
388 Type: util.InfoTypeError,
389 Msg: "Failed to update compact mode configuration: " + err.Error(),
390 }
391 }
392 return nil
393 }
394}
395
396func (p *chatPage) setCompactMode(compact bool) {
397 if p.compact == compact {
398 return
399 }
400 p.compact = compact
401 if compact {
402 p.compact = true
403 p.sidebar.SetCompactMode(true)
404 } else {
405 p.compact = false
406 p.showingDetails = false
407 p.sidebar.SetCompactMode(false)
408 }
409}
410
411func (p *chatPage) handleCompactMode(newWidth int) {
412 if p.forceCompact {
413 return
414 }
415 if newWidth < CompactModeBreakpoint && !p.compact {
416 p.setCompactMode(true)
417 }
418 if newWidth >= CompactModeBreakpoint && p.compact {
419 p.setCompactMode(false)
420 }
421}
422
423func (p *chatPage) SetSize(width, height int) tea.Cmd {
424 p.handleCompactMode(width)
425 p.width = width
426 p.height = height
427 var cmds []tea.Cmd
428
429 if p.session.ID == "" {
430 if p.splashFullScreen {
431 cmds = append(cmds, p.splash.SetSize(width, height))
432 } else {
433 cmds = append(cmds, p.splash.SetSize(width, height-EditorHeight))
434 cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
435 cmds = append(cmds, p.editor.SetPosition(0, height-EditorHeight))
436 }
437 } else {
438 if p.compact {
439 cmds = append(cmds, p.chat.SetSize(width, height-EditorHeight-HeaderHeight))
440 p.detailsWidth = width - DetailsPositioning
441 cmds = append(cmds, p.sidebar.SetSize(p.detailsWidth-LeftRightBorders, p.detailsHeight-TopBottomBorders))
442 cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
443 cmds = append(cmds, p.header.SetWidth(width-BorderWidth))
444 } else {
445 cmds = append(cmds, p.chat.SetSize(width-SideBarWidth, height-EditorHeight))
446 cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
447 cmds = append(cmds, p.sidebar.SetSize(SideBarWidth, height-EditorHeight))
448 }
449 cmds = append(cmds, p.editor.SetPosition(0, height-EditorHeight))
450 }
451 return tea.Batch(cmds...)
452}
453
454func (p *chatPage) newSession() tea.Cmd {
455 if p.session.ID == "" {
456 return nil
457 }
458
459 p.session = session.Session{}
460 p.focusedPane = PanelTypeEditor
461 p.isCanceling = false
462 return tea.Batch(
463 util.CmdHandler(chat.SessionClearedMsg{}),
464 p.SetSize(p.width, p.height),
465 )
466}
467
468func (p *chatPage) setSession(session session.Session) tea.Cmd {
469 if p.session.ID == session.ID {
470 return nil
471 }
472
473 var cmds []tea.Cmd
474 p.session = session
475
476 cmds = append(cmds, p.SetSize(p.width, p.height))
477 cmds = append(cmds, p.chat.SetSession(session))
478 cmds = append(cmds, p.sidebar.SetSession(session))
479 cmds = append(cmds, p.header.SetSession(session))
480 cmds = append(cmds, p.editor.SetSession(session))
481
482 return tea.Sequence(cmds...)
483}
484
485func (p *chatPage) changeFocus() {
486 if p.session.ID == "" {
487 return
488 }
489 switch p.focusedPane {
490 case PanelTypeChat:
491 p.focusedPane = PanelTypeEditor
492 p.editor.Focus()
493 p.chat.Blur()
494 case PanelTypeEditor:
495 p.focusedPane = PanelTypeChat
496 p.chat.Focus()
497 p.editor.Blur()
498 }
499}
500
501func (p *chatPage) cancel() tea.Cmd {
502 if p.isCanceling {
503 p.isCanceling = false
504 p.app.CoderAgent.Cancel(p.session.ID)
505 return nil
506 }
507
508 p.isCanceling = true
509 return cancelTimerCmd()
510}
511
512func (p *chatPage) showDetails() {
513 if p.session.ID == "" || !p.compact {
514 return
515 }
516 p.showingDetails = !p.showingDetails
517 p.header.SetDetailsOpen(p.showingDetails)
518}
519
520func (p *chatPage) sendMessage(text string, attachments []message.Attachment) tea.Cmd {
521 session := p.session
522 var cmds []tea.Cmd
523 if p.session.ID == "" {
524 newSession, err := p.app.Sessions.Create(context.Background(), "New Session")
525 if err != nil {
526 return util.ReportError(err)
527 }
528 session = newSession
529 cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(session)))
530 }
531 _, err := p.app.CoderAgent.Run(context.Background(), session.ID, text, attachments...)
532 if err != nil {
533 return util.ReportError(err)
534 }
535 return tea.Batch(cmds...)
536}
537
538func (p *chatPage) Bindings() []key.Binding {
539 bindings := []key.Binding{
540 p.keyMap.NewSession,
541 p.keyMap.AddAttachment,
542 }
543 if p.app.CoderAgent != nil && p.app.CoderAgent.IsBusy() {
544 cancelBinding := p.keyMap.Cancel
545 if p.isCanceling {
546 cancelBinding = key.NewBinding(
547 key.WithKeys("esc"),
548 key.WithHelp("esc", "press again to cancel"),
549 )
550 }
551 bindings = append([]key.Binding{cancelBinding}, bindings...)
552 }
553
554 switch p.focusedPane {
555 case PanelTypeChat:
556 bindings = append([]key.Binding{
557 key.NewBinding(
558 key.WithKeys("tab"),
559 key.WithHelp("tab", "focus editor"),
560 ),
561 }, bindings...)
562 bindings = append(bindings, p.chat.Bindings()...)
563 case PanelTypeEditor:
564 bindings = append([]key.Binding{
565 key.NewBinding(
566 key.WithKeys("tab"),
567 key.WithHelp("tab", "focus chat"),
568 ),
569 }, bindings...)
570 bindings = append(bindings, p.editor.Bindings()...)
571 case PanelTypeSplash:
572 bindings = append(bindings, p.splash.Bindings()...)
573 }
574
575 return bindings
576}
577
578func (p *chatPage) IsChatFocused() bool {
579 return p.focusedPane == PanelTypeChat
580}