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