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