1package chat
2
3import (
4 "context"
5 "time"
6
7 "github.com/charmbracelet/bubbles/v2/help"
8 "github.com/charmbracelet/bubbles/v2/key"
9 "github.com/charmbracelet/bubbles/v2/spinner"
10 tea "github.com/charmbracelet/bubbletea/v2"
11 "github.com/charmbracelet/crush/internal/app"
12 "github.com/charmbracelet/crush/internal/config"
13 "github.com/charmbracelet/crush/internal/history"
14 "github.com/charmbracelet/crush/internal/message"
15 "github.com/charmbracelet/crush/internal/permission"
16 "github.com/charmbracelet/crush/internal/pubsub"
17 "github.com/charmbracelet/crush/internal/session"
18 "github.com/charmbracelet/crush/internal/tui/components/anim"
19 "github.com/charmbracelet/crush/internal/tui/components/chat"
20 "github.com/charmbracelet/crush/internal/tui/components/chat/editor"
21 "github.com/charmbracelet/crush/internal/tui/components/chat/header"
22 "github.com/charmbracelet/crush/internal/tui/components/chat/messages"
23 "github.com/charmbracelet/crush/internal/tui/components/chat/sidebar"
24 "github.com/charmbracelet/crush/internal/tui/components/chat/splash"
25 "github.com/charmbracelet/crush/internal/tui/components/completions"
26 "github.com/charmbracelet/crush/internal/tui/components/core"
27 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
28 "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
29 "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
30 "github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
31 "github.com/charmbracelet/crush/internal/tui/page"
32 "github.com/charmbracelet/crush/internal/tui/styles"
33 "github.com/charmbracelet/crush/internal/tui/util"
34 "github.com/charmbracelet/crush/internal/version"
35 "github.com/charmbracelet/lipgloss/v2"
36)
37
38var ChatPageID page.PageID = "chat"
39
40type (
41 ChatFocusedMsg struct {
42 Focused bool
43 }
44 CancelTimerExpiredMsg struct{}
45)
46
47type PanelType string
48
49const (
50 PanelTypeChat PanelType = "chat"
51 PanelTypeEditor PanelType = "editor"
52 PanelTypeSplash PanelType = "splash"
53)
54
55const (
56 CompactModeWidthBreakpoint = 120 // Width at which the chat page switches to compact mode
57 CompactModeHeightBreakpoint = 30 // Height at which the chat page switches to compact mode
58 EditorHeight = 5 // Height of the editor input area including padding
59 SideBarWidth = 31 // Width of the sidebar
60 SideBarDetailsPadding = 1 // Padding for the sidebar details section
61 HeaderHeight = 1 // Height of the header
62
63 // Layout constants for borders and padding
64 BorderWidth = 1 // Width of component borders
65 LeftRightBorders = 2 // Left + right border width (1 + 1)
66 TopBottomBorders = 2 // Top + bottom border width (1 + 1)
67 DetailsPositioning = 2 // Positioning adjustment for details panel
68
69 // Timing constants
70 CancelTimerDuration = 2 * time.Second // Duration before cancel timer expires
71)
72
73type ChatPage interface {
74 util.Model
75 layout.Help
76 IsChatFocused() bool
77}
78
79// cancelTimerCmd creates a command that expires the cancel timer
80func cancelTimerCmd() tea.Cmd {
81 return tea.Tick(CancelTimerDuration, func(time.Time) tea.Msg {
82 return CancelTimerExpiredMsg{}
83 })
84}
85
86type chatPage struct {
87 width, height int
88 detailsWidth, detailsHeight int
89 app *app.App
90 keyboardEnhancements tea.KeyboardEnhancementsMsg
91
92 // Layout state
93 compact bool
94 forceCompact bool
95 focusedPane PanelType
96
97 // Session
98 session session.Session
99 keyMap KeyMap
100
101 // Components
102 header header.Header
103 sidebar sidebar.Sidebar
104 chat chat.MessageListCmp
105 editor editor.Editor
106 splash splash.Splash
107
108 // Simple state flags
109 showingDetails bool
110 isCanceling bool
111 splashFullScreen bool
112 isOnboarding bool
113 isProjectInit bool
114}
115
116func New(app *app.App) ChatPage {
117 return &chatPage{
118 app: app,
119 keyMap: DefaultKeyMap(),
120 header: header.New(app.LSPClients),
121 sidebar: sidebar.New(app.History, app.LSPClients, false),
122 chat: chat.New(app),
123 editor: editor.New(app),
124 splash: splash.New(),
125 focusedPane: PanelTypeSplash,
126 }
127}
128
129func (p *chatPage) Init() tea.Cmd {
130 cfg := config.Get()
131 compact := cfg.Options.TUI.CompactMode
132 p.compact = compact
133 p.forceCompact = compact
134 p.sidebar.SetCompactMode(p.compact)
135
136 // Set splash state based on config
137 if !config.HasInitialDataConfig() {
138 // First-time setup: show model selection
139 p.splash.SetOnboarding(true)
140 p.isOnboarding = true
141 p.splashFullScreen = true
142 } else if b, _ := config.ProjectNeedsInitialization(); b {
143 // Project needs CRUSH.md initialization
144 p.splash.SetProjectInit(true)
145 p.isProjectInit = true
146 p.splashFullScreen = true
147 } else {
148 // Ready to chat: focus editor, splash in background
149 p.focusedPane = PanelTypeEditor
150 p.splashFullScreen = false
151 }
152
153 return tea.Batch(
154 p.header.Init(),
155 p.sidebar.Init(),
156 p.chat.Init(),
157 p.editor.Init(),
158 p.splash.Init(),
159 )
160}
161
162func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
163 var cmds []tea.Cmd
164 switch msg := msg.(type) {
165 case tea.KeyboardEnhancementsMsg:
166 p.keyboardEnhancements = msg
167 return p, nil
168 case tea.MouseWheelMsg:
169 if p.isMouseOverChat(msg.X, msg.Y) {
170 u, cmd := p.chat.Update(msg)
171 p.chat = u.(chat.MessageListCmp)
172 return p, cmd
173 }
174 return p, nil
175 case tea.MouseClickMsg:
176 if p.isMouseOverChat(msg.X, msg.Y) {
177 p.focusedPane = PanelTypeChat
178 p.chat.Focus()
179 p.editor.Blur()
180 } else {
181 p.focusedPane = PanelTypeEditor
182 p.editor.Focus()
183 p.chat.Blur()
184 }
185 u, cmd := p.chat.Update(msg)
186 p.chat = u.(chat.MessageListCmp)
187 return p, cmd
188 case tea.MouseMotionMsg:
189 if msg.Button == tea.MouseLeft {
190 u, cmd := p.chat.Update(msg)
191 p.chat = u.(chat.MessageListCmp)
192 return p, cmd
193 }
194 return p, nil
195 case tea.MouseReleaseMsg:
196 if msg.Button == tea.MouseLeft {
197 u, cmd := p.chat.Update(msg)
198 p.chat = u.(chat.MessageListCmp)
199 return p, cmd
200 }
201 return p, nil
202 case tea.WindowSizeMsg:
203 u, cmd := p.editor.Update(msg)
204 p.editor = u.(editor.Editor)
205 return p, tea.Batch(p.SetSize(msg.Width, msg.Height), cmd)
206 case CancelTimerExpiredMsg:
207 p.isCanceling = false
208 return p, nil
209 case editor.OpenEditorMsg:
210 u, cmd := p.editor.Update(msg)
211 p.editor = u.(editor.Editor)
212 return p, cmd
213 case chat.SendMsg:
214 return p, p.sendMessage(msg.Text, msg.Attachments)
215 case chat.SessionSelectedMsg:
216 return p, p.setSession(msg)
217 case splash.SubmitAPIKeyMsg:
218 u, cmd := p.splash.Update(msg)
219 p.splash = u.(splash.Splash)
220 cmds = append(cmds, cmd)
221 return p, tea.Batch(cmds...)
222 case commands.ToggleCompactModeMsg:
223 p.forceCompact = !p.forceCompact
224 var cmd tea.Cmd
225 if p.forceCompact {
226 p.setCompactMode(true)
227 cmd = p.updateCompactConfig(true)
228 } else if p.width >= CompactModeWidthBreakpoint && p.height >= CompactModeHeightBreakpoint {
229 p.setCompactMode(false)
230 cmd = p.updateCompactConfig(false)
231 }
232 return p, tea.Batch(p.SetSize(p.width, p.height), cmd)
233 case commands.ToggleThinkingMsg:
234 return p, p.toggleThinking()
235 case commands.OpenExternalEditorMsg:
236 u, cmd := p.editor.Update(msg)
237 p.editor = u.(editor.Editor)
238 return p, cmd
239 case pubsub.Event[session.Session]:
240 u, cmd := p.header.Update(msg)
241 p.header = u.(header.Header)
242 cmds = append(cmds, cmd)
243 u, cmd = p.sidebar.Update(msg)
244 p.sidebar = u.(sidebar.Sidebar)
245 cmds = append(cmds, cmd)
246 return p, tea.Batch(cmds...)
247 case chat.SessionClearedMsg:
248 u, cmd := p.header.Update(msg)
249 p.header = u.(header.Header)
250 cmds = append(cmds, cmd)
251 u, cmd = p.sidebar.Update(msg)
252 p.sidebar = u.(sidebar.Sidebar)
253 cmds = append(cmds, cmd)
254 u, cmd = p.chat.Update(msg)
255 p.chat = u.(chat.MessageListCmp)
256 cmds = append(cmds, cmd)
257 return p, tea.Batch(cmds...)
258 case filepicker.FilePickedMsg,
259 completions.CompletionsClosedMsg,
260 completions.SelectCompletionMsg:
261 u, cmd := p.editor.Update(msg)
262 p.editor = u.(editor.Editor)
263 cmds = append(cmds, cmd)
264 return p, tea.Batch(cmds...)
265
266 case models.APIKeyStateChangeMsg:
267 if p.focusedPane == PanelTypeSplash {
268 u, cmd := p.splash.Update(msg)
269 p.splash = u.(splash.Splash)
270 cmds = append(cmds, cmd)
271 }
272 return p, tea.Batch(cmds...)
273 case pubsub.Event[message.Message],
274 anim.StepMsg,
275 spinner.TickMsg:
276 if p.focusedPane == PanelTypeSplash {
277 u, cmd := p.splash.Update(msg)
278 p.splash = u.(splash.Splash)
279 cmds = append(cmds, cmd)
280 } else {
281 u, cmd := p.chat.Update(msg)
282 p.chat = u.(chat.MessageListCmp)
283 cmds = append(cmds, cmd)
284 }
285
286 return p, tea.Batch(cmds...)
287
288 case pubsub.Event[history.File], sidebar.SessionFilesMsg:
289 u, cmd := p.sidebar.Update(msg)
290 p.sidebar = u.(sidebar.Sidebar)
291 cmds = append(cmds, cmd)
292 return p, tea.Batch(cmds...)
293 case pubsub.Event[permission.PermissionNotification]:
294 u, cmd := p.chat.Update(msg)
295 p.chat = u.(chat.MessageListCmp)
296 cmds = append(cmds, cmd)
297 return p, tea.Batch(cmds...)
298
299 case commands.CommandRunCustomMsg:
300 if p.app.CoderAgent.IsBusy() {
301 return p, util.ReportWarn("Agent is busy, please wait before executing a command...")
302 }
303
304 cmd := p.sendMessage(msg.Content, nil)
305 if cmd != nil {
306 return p, cmd
307 }
308 case splash.OnboardingCompleteMsg:
309 p.splashFullScreen = false
310 if b, _ := config.ProjectNeedsInitialization(); b {
311 p.splash.SetProjectInit(true)
312 p.splashFullScreen = true
313 return p, p.SetSize(p.width, p.height)
314 }
315 err := p.app.InitCoderAgent()
316 if err != nil {
317 return p, util.ReportError(err)
318 }
319 p.isOnboarding = false
320 p.isProjectInit = false
321 p.focusedPane = PanelTypeEditor
322 return p, p.SetSize(p.width, p.height)
323 case commands.NewSessionsMsg:
324 if p.app.CoderAgent.IsBusy() {
325 return p, util.ReportWarn("Agent is busy, please wait before starting a new session...")
326 }
327 return p, p.newSession()
328 case tea.KeyPressMsg:
329 switch {
330 case key.Matches(msg, p.keyMap.NewSession):
331 // if we have no agent do nothing
332 if p.app.CoderAgent == nil {
333 return p, nil
334 }
335 if p.app.CoderAgent.IsBusy() {
336 return p, util.ReportWarn("Agent is busy, please wait before starting a new session...")
337 }
338 return p, p.newSession()
339 case key.Matches(msg, p.keyMap.AddAttachment):
340 agentCfg := config.Get().Agents["coder"]
341 model := config.Get().GetModelByType(agentCfg.Model)
342 if model.SupportsImages {
343 return p, util.CmdHandler(commands.OpenFilePickerMsg{})
344 } else {
345 return p, util.ReportWarn("File attachments are not supported by the current model: " + model.Name)
346 }
347 case key.Matches(msg, p.keyMap.Tab):
348 if p.session.ID == "" {
349 u, cmd := p.splash.Update(msg)
350 p.splash = u.(splash.Splash)
351 return p, cmd
352 }
353 p.changeFocus()
354 return p, nil
355 case key.Matches(msg, p.keyMap.Cancel):
356 if p.session.ID != "" && p.app.CoderAgent.IsBusy() {
357 return p, p.cancel()
358 }
359 case key.Matches(msg, p.keyMap.Details):
360 p.toggleDetails()
361 return p, nil
362 }
363
364 switch p.focusedPane {
365 case PanelTypeChat:
366 u, cmd := p.chat.Update(msg)
367 p.chat = u.(chat.MessageListCmp)
368 cmds = append(cmds, cmd)
369 case PanelTypeEditor:
370 u, cmd := p.editor.Update(msg)
371 p.editor = u.(editor.Editor)
372 cmds = append(cmds, cmd)
373 case PanelTypeSplash:
374 u, cmd := p.splash.Update(msg)
375 p.splash = u.(splash.Splash)
376 cmds = append(cmds, cmd)
377 }
378 case tea.PasteMsg:
379 switch p.focusedPane {
380 case PanelTypeEditor:
381 u, cmd := p.editor.Update(msg)
382 p.editor = u.(editor.Editor)
383 cmds = append(cmds, cmd)
384 return p, tea.Batch(cmds...)
385 case PanelTypeChat:
386 u, cmd := p.chat.Update(msg)
387 p.chat = u.(chat.MessageListCmp)
388 cmds = append(cmds, cmd)
389 return p, tea.Batch(cmds...)
390 case PanelTypeSplash:
391 u, cmd := p.splash.Update(msg)
392 p.splash = u.(splash.Splash)
393 cmds = append(cmds, cmd)
394 return p, tea.Batch(cmds...)
395 }
396 }
397 return p, tea.Batch(cmds...)
398}
399
400func (p *chatPage) Cursor() *tea.Cursor {
401 if p.header.ShowingDetails() {
402 return nil
403 }
404 switch p.focusedPane {
405 case PanelTypeEditor:
406 return p.editor.Cursor()
407 case PanelTypeSplash:
408 return p.splash.Cursor()
409 default:
410 return nil
411 }
412}
413
414func (p *chatPage) View() string {
415 var chatView string
416 t := styles.CurrentTheme()
417
418 if p.session.ID == "" {
419 splashView := p.splash.View()
420 // Full screen during onboarding or project initialization
421 if p.splashFullScreen {
422 chatView = splashView
423 } else {
424 // Show splash + editor for new message state
425 editorView := p.editor.View()
426 chatView = lipgloss.JoinVertical(
427 lipgloss.Left,
428 t.S().Base.Render(splashView),
429 editorView,
430 )
431 }
432 } else {
433 messagesView := p.chat.View()
434 editorView := p.editor.View()
435 if p.compact {
436 headerView := p.header.View()
437 chatView = lipgloss.JoinVertical(
438 lipgloss.Left,
439 headerView,
440 messagesView,
441 editorView,
442 )
443 } else {
444 sidebarView := p.sidebar.View()
445 messages := lipgloss.JoinHorizontal(
446 lipgloss.Left,
447 messagesView,
448 sidebarView,
449 )
450 chatView = lipgloss.JoinVertical(
451 lipgloss.Left,
452 messages,
453 p.editor.View(),
454 )
455 }
456 }
457
458 layers := []*lipgloss.Layer{
459 lipgloss.NewLayer(chatView).X(0).Y(0),
460 }
461
462 if p.showingDetails {
463 style := t.S().Base.
464 Width(p.detailsWidth).
465 Border(lipgloss.RoundedBorder()).
466 BorderForeground(t.BorderFocus)
467 version := t.S().Base.Foreground(t.Border).Width(p.detailsWidth - 4).AlignHorizontal(lipgloss.Right).Render(version.Version)
468 details := style.Render(
469 lipgloss.JoinVertical(
470 lipgloss.Left,
471 p.sidebar.View(),
472 version,
473 ),
474 )
475 layers = append(layers, lipgloss.NewLayer(details).X(1).Y(1))
476 }
477 canvas := lipgloss.NewCanvas(
478 layers...,
479 )
480 return canvas.Render()
481}
482
483func (p *chatPage) updateCompactConfig(compact bool) tea.Cmd {
484 return func() tea.Msg {
485 err := config.Get().SetCompactMode(compact)
486 if err != nil {
487 return util.InfoMsg{
488 Type: util.InfoTypeError,
489 Msg: "Failed to update compact mode configuration: " + err.Error(),
490 }
491 }
492 return nil
493 }
494}
495
496func (p *chatPage) toggleThinking() tea.Cmd {
497 return func() tea.Msg {
498 cfg := config.Get()
499 agentCfg := cfg.Agents["coder"]
500 currentModel := cfg.Models[agentCfg.Model]
501
502 // Toggle the thinking mode
503 currentModel.Think = !currentModel.Think
504 cfg.Models[agentCfg.Model] = currentModel
505
506 // Update the agent with the new configuration
507 if err := p.app.UpdateAgentModel(); err != nil {
508 return util.InfoMsg{
509 Type: util.InfoTypeError,
510 Msg: "Failed to update thinking mode: " + err.Error(),
511 }
512 }
513
514 status := "disabled"
515 if currentModel.Think {
516 status = "enabled"
517 }
518 return util.InfoMsg{
519 Type: util.InfoTypeInfo,
520 Msg: "Thinking mode " + status,
521 }
522 }
523}
524
525func (p *chatPage) setCompactMode(compact bool) {
526 if p.compact == compact {
527 return
528 }
529 p.compact = compact
530 if compact {
531 p.sidebar.SetCompactMode(true)
532 } else {
533 p.setShowDetails(false)
534 }
535}
536
537func (p *chatPage) handleCompactMode(newWidth int, newHeight int) {
538 if p.forceCompact {
539 return
540 }
541 if (newWidth < CompactModeWidthBreakpoint || newHeight < CompactModeHeightBreakpoint) && !p.compact {
542 p.setCompactMode(true)
543 }
544 if (newWidth >= CompactModeWidthBreakpoint && newHeight >= CompactModeHeightBreakpoint) && p.compact {
545 p.setCompactMode(false)
546 }
547}
548
549func (p *chatPage) SetSize(width, height int) tea.Cmd {
550 p.handleCompactMode(width, height)
551 p.width = width
552 p.height = height
553 var cmds []tea.Cmd
554
555 if p.session.ID == "" {
556 if p.splashFullScreen {
557 cmds = append(cmds, p.splash.SetSize(width, height))
558 } else {
559 cmds = append(cmds, p.splash.SetSize(width, height-EditorHeight))
560 cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
561 cmds = append(cmds, p.editor.SetPosition(0, height-EditorHeight))
562 }
563 } else {
564 if p.compact {
565 cmds = append(cmds, p.chat.SetSize(width, height-EditorHeight-HeaderHeight))
566 p.detailsWidth = width - DetailsPositioning
567 cmds = append(cmds, p.sidebar.SetSize(p.detailsWidth-LeftRightBorders, p.detailsHeight-TopBottomBorders))
568 cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
569 cmds = append(cmds, p.header.SetWidth(width-BorderWidth))
570 } else {
571 cmds = append(cmds, p.chat.SetSize(width-SideBarWidth, height-EditorHeight))
572 cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
573 cmds = append(cmds, p.sidebar.SetSize(SideBarWidth, height-EditorHeight))
574 }
575 cmds = append(cmds, p.editor.SetPosition(0, height-EditorHeight))
576 }
577 return tea.Batch(cmds...)
578}
579
580func (p *chatPage) newSession() tea.Cmd {
581 if p.session.ID == "" {
582 return nil
583 }
584
585 p.session = session.Session{}
586 p.focusedPane = PanelTypeEditor
587 p.editor.Focus()
588 p.chat.Blur()
589 p.isCanceling = false
590 return tea.Batch(
591 util.CmdHandler(chat.SessionClearedMsg{}),
592 p.SetSize(p.width, p.height),
593 )
594}
595
596func (p *chatPage) setSession(session session.Session) tea.Cmd {
597 if p.session.ID == session.ID {
598 return nil
599 }
600
601 var cmds []tea.Cmd
602 p.session = session
603
604 cmds = append(cmds, p.SetSize(p.width, p.height))
605 cmds = append(cmds, p.chat.SetSession(session))
606 cmds = append(cmds, p.sidebar.SetSession(session))
607 cmds = append(cmds, p.header.SetSession(session))
608 cmds = append(cmds, p.editor.SetSession(session))
609
610 return tea.Sequence(cmds...)
611}
612
613func (p *chatPage) changeFocus() {
614 if p.session.ID == "" {
615 return
616 }
617 switch p.focusedPane {
618 case PanelTypeChat:
619 p.focusedPane = PanelTypeEditor
620 p.editor.Focus()
621 p.chat.Blur()
622 case PanelTypeEditor:
623 p.focusedPane = PanelTypeChat
624 p.chat.Focus()
625 p.editor.Blur()
626 }
627}
628
629func (p *chatPage) cancel() tea.Cmd {
630 if p.isCanceling {
631 p.isCanceling = false
632 p.app.CoderAgent.Cancel(p.session.ID)
633 return nil
634 }
635
636 p.isCanceling = true
637 return cancelTimerCmd()
638}
639
640func (p *chatPage) setShowDetails(show bool) {
641 p.showingDetails = show
642 p.header.SetDetailsOpen(p.showingDetails)
643 if !p.compact {
644 p.sidebar.SetCompactMode(false)
645 }
646}
647
648func (p *chatPage) toggleDetails() {
649 if p.session.ID == "" || !p.compact {
650 return
651 }
652 p.setShowDetails(!p.showingDetails)
653}
654
655func (p *chatPage) sendMessage(text string, attachments []message.Attachment) tea.Cmd {
656 session := p.session
657 var cmds []tea.Cmd
658 if p.session.ID == "" {
659 newSession, err := p.app.Sessions.Create(context.Background(), "New Session")
660 if err != nil {
661 return util.ReportError(err)
662 }
663 session = newSession
664 cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(session)))
665 }
666 _, err := p.app.CoderAgent.Run(context.Background(), session.ID, text, attachments...)
667 if err != nil {
668 return util.ReportError(err)
669 }
670 cmds = append(cmds, p.chat.GoToBottom())
671 return tea.Batch(cmds...)
672}
673
674func (p *chatPage) Bindings() []key.Binding {
675 bindings := []key.Binding{
676 p.keyMap.NewSession,
677 p.keyMap.AddAttachment,
678 }
679 if p.app.CoderAgent != nil && p.app.CoderAgent.IsBusy() {
680 cancelBinding := p.keyMap.Cancel
681 if p.isCanceling {
682 cancelBinding = key.NewBinding(
683 key.WithKeys("esc"),
684 key.WithHelp("esc", "press again to cancel"),
685 )
686 }
687 bindings = append([]key.Binding{cancelBinding}, bindings...)
688 }
689
690 switch p.focusedPane {
691 case PanelTypeChat:
692 bindings = append([]key.Binding{
693 key.NewBinding(
694 key.WithKeys("tab"),
695 key.WithHelp("tab", "focus editor"),
696 ),
697 }, bindings...)
698 bindings = append(bindings, p.chat.Bindings()...)
699 case PanelTypeEditor:
700 bindings = append([]key.Binding{
701 key.NewBinding(
702 key.WithKeys("tab"),
703 key.WithHelp("tab", "focus chat"),
704 ),
705 }, bindings...)
706 bindings = append(bindings, p.editor.Bindings()...)
707 case PanelTypeSplash:
708 bindings = append(bindings, p.splash.Bindings()...)
709 }
710
711 return bindings
712}
713
714func (p *chatPage) Help() help.KeyMap {
715 var shortList []key.Binding
716 var fullList [][]key.Binding
717 switch {
718 case p.isOnboarding && !p.splash.IsShowingAPIKey():
719 shortList = append(shortList,
720 // Choose model
721 key.NewBinding(
722 key.WithKeys("up", "down"),
723 key.WithHelp("↑/↓", "choose"),
724 ),
725 // Accept selection
726 key.NewBinding(
727 key.WithKeys("enter", "ctrl+y"),
728 key.WithHelp("enter", "accept"),
729 ),
730 // Quit
731 key.NewBinding(
732 key.WithKeys("ctrl+c"),
733 key.WithHelp("ctrl+c", "quit"),
734 ),
735 )
736 // keep them the same
737 for _, v := range shortList {
738 fullList = append(fullList, []key.Binding{v})
739 }
740 case p.isOnboarding && p.splash.IsShowingAPIKey():
741 if p.splash.IsAPIKeyValid() {
742 shortList = append(shortList,
743 key.NewBinding(
744 key.WithKeys("enter"),
745 key.WithHelp("enter", "continue"),
746 ),
747 )
748 } else {
749 shortList = append(shortList,
750 // Go back
751 key.NewBinding(
752 key.WithKeys("esc"),
753 key.WithHelp("esc", "back"),
754 ),
755 )
756 }
757 shortList = append(shortList,
758 // Quit
759 key.NewBinding(
760 key.WithKeys("ctrl+c"),
761 key.WithHelp("ctrl+c", "quit"),
762 ),
763 )
764 // keep them the same
765 for _, v := range shortList {
766 fullList = append(fullList, []key.Binding{v})
767 }
768 case p.isProjectInit:
769 shortList = append(shortList,
770 key.NewBinding(
771 key.WithKeys("ctrl+c"),
772 key.WithHelp("ctrl+c", "quit"),
773 ),
774 )
775 // keep them the same
776 for _, v := range shortList {
777 fullList = append(fullList, []key.Binding{v})
778 }
779 default:
780 if p.editor.IsCompletionsOpen() {
781 shortList = append(shortList,
782 key.NewBinding(
783 key.WithKeys("tab", "enter"),
784 key.WithHelp("tab/enter", "complete"),
785 ),
786 key.NewBinding(
787 key.WithKeys("esc"),
788 key.WithHelp("esc", "cancel"),
789 ),
790 key.NewBinding(
791 key.WithKeys("up", "down"),
792 key.WithHelp("↑/↓", "choose"),
793 ),
794 )
795 for _, v := range shortList {
796 fullList = append(fullList, []key.Binding{v})
797 }
798 return core.NewSimpleHelp(shortList, fullList)
799 }
800 if p.app.CoderAgent != nil && p.app.CoderAgent.IsBusy() {
801 cancelBinding := key.NewBinding(
802 key.WithKeys("esc"),
803 key.WithHelp("esc", "cancel"),
804 )
805 if p.isCanceling {
806 cancelBinding = key.NewBinding(
807 key.WithKeys("esc"),
808 key.WithHelp("esc", "press again to cancel"),
809 )
810 }
811 shortList = append(shortList, cancelBinding)
812 fullList = append(fullList,
813 []key.Binding{
814 cancelBinding,
815 },
816 )
817 }
818 globalBindings := []key.Binding{}
819 // we are in a session
820 if p.session.ID != "" {
821 tabKey := key.NewBinding(
822 key.WithKeys("tab"),
823 key.WithHelp("tab", "focus chat"),
824 )
825 if p.focusedPane == PanelTypeChat {
826 tabKey = key.NewBinding(
827 key.WithKeys("tab"),
828 key.WithHelp("tab", "focus editor"),
829 )
830 }
831 shortList = append(shortList, tabKey)
832 globalBindings = append(globalBindings, tabKey)
833 }
834 commandsBinding := key.NewBinding(
835 key.WithKeys("ctrl+p"),
836 key.WithHelp("ctrl+p", "commands"),
837 )
838 helpBinding := key.NewBinding(
839 key.WithKeys("ctrl+g"),
840 key.WithHelp("ctrl+g", "more"),
841 )
842 globalBindings = append(globalBindings, commandsBinding)
843 globalBindings = append(globalBindings,
844 key.NewBinding(
845 key.WithKeys("ctrl+s"),
846 key.WithHelp("ctrl+s", "sessions"),
847 ),
848 )
849 if p.session.ID != "" {
850 globalBindings = append(globalBindings,
851 key.NewBinding(
852 key.WithKeys("ctrl+n"),
853 key.WithHelp("ctrl+n", "new sessions"),
854 ))
855 }
856 shortList = append(shortList,
857 // Commands
858 commandsBinding,
859 )
860 fullList = append(fullList, globalBindings)
861
862 switch p.focusedPane {
863 case PanelTypeChat:
864 shortList = append(shortList,
865 key.NewBinding(
866 key.WithKeys("up", "down"),
867 key.WithHelp("↑↓", "scroll"),
868 ),
869 messages.CopyKey,
870 )
871 fullList = append(fullList,
872 []key.Binding{
873 key.NewBinding(
874 key.WithKeys("up", "down"),
875 key.WithHelp("↑↓", "scroll"),
876 ),
877 key.NewBinding(
878 key.WithKeys("shift+up", "shift+down"),
879 key.WithHelp("shift+↑↓", "next/prev item"),
880 ),
881 key.NewBinding(
882 key.WithKeys("pgup", "b"),
883 key.WithHelp("b/pgup", "page up"),
884 ),
885 key.NewBinding(
886 key.WithKeys("pgdown", " ", "f"),
887 key.WithHelp("f/pgdn", "page down"),
888 ),
889 },
890 []key.Binding{
891 key.NewBinding(
892 key.WithKeys("u"),
893 key.WithHelp("u", "half page up"),
894 ),
895 key.NewBinding(
896 key.WithKeys("d"),
897 key.WithHelp("d", "half page down"),
898 ),
899 key.NewBinding(
900 key.WithKeys("g", "home"),
901 key.WithHelp("g", "home"),
902 ),
903 key.NewBinding(
904 key.WithKeys("G", "end"),
905 key.WithHelp("G", "end"),
906 ),
907 },
908 )
909 case PanelTypeEditor:
910 newLineBinding := key.NewBinding(
911 key.WithKeys("shift+enter", "ctrl+j"),
912 // "ctrl+j" is a common keybinding for newline in many editors. If
913 // the terminal supports "shift+enter", we substitute the help text
914 // to reflect that.
915 key.WithHelp("ctrl+j", "newline"),
916 )
917 if p.keyboardEnhancements.SupportsKeyDisambiguation() {
918 newLineBinding.SetHelp("shift+enter", newLineBinding.Help().Desc)
919 }
920 shortList = append(shortList, newLineBinding)
921 fullList = append(fullList,
922 []key.Binding{
923 newLineBinding,
924 key.NewBinding(
925 key.WithKeys("ctrl+f"),
926 key.WithHelp("ctrl+f", "add image"),
927 ),
928 key.NewBinding(
929 key.WithKeys("/"),
930 key.WithHelp("/", "add file"),
931 ),
932 key.NewBinding(
933 key.WithKeys("ctrl+o"),
934 key.WithHelp("ctrl+o", "open editor"),
935 ),
936 })
937
938 if p.editor.HasAttachments() {
939 fullList = append(fullList, []key.Binding{
940 key.NewBinding(
941 key.WithKeys("ctrl+r"),
942 key.WithHelp("ctrl+r+{i}", "delete attachment at index i"),
943 ),
944 key.NewBinding(
945 key.WithKeys("ctrl+r", "r"),
946 key.WithHelp("ctrl+r+r", "delete all attachments"),
947 ),
948 key.NewBinding(
949 key.WithKeys("esc"),
950 key.WithHelp("esc", "cancel delete mode"),
951 ),
952 })
953 }
954 }
955 shortList = append(shortList,
956 // Quit
957 key.NewBinding(
958 key.WithKeys("ctrl+c"),
959 key.WithHelp("ctrl+c", "quit"),
960 ),
961 // Help
962 helpBinding,
963 )
964 fullList = append(fullList, []key.Binding{
965 key.NewBinding(
966 key.WithKeys("ctrl+g"),
967 key.WithHelp("ctrl+g", "less"),
968 ),
969 })
970 }
971
972 return core.NewSimpleHelp(shortList, fullList)
973}
974
975func (p *chatPage) IsChatFocused() bool {
976 return p.focusedPane == PanelTypeChat
977}
978
979// isMouseOverChat checks if the given mouse coordinates are within the chat area bounds.
980// Returns true if the mouse is over the chat area, false otherwise.
981func (p *chatPage) isMouseOverChat(x, y int) bool {
982 // No session means no chat area
983 if p.session.ID == "" {
984 return false
985 }
986
987 var chatX, chatY, chatWidth, chatHeight int
988
989 if p.compact {
990 // In compact mode: chat area starts after header and spans full width
991 chatX = 0
992 chatY = HeaderHeight
993 chatWidth = p.width
994 chatHeight = p.height - EditorHeight - HeaderHeight
995 } else {
996 // In non-compact mode: chat area spans from left edge to sidebar
997 chatX = 0
998 chatY = 0
999 chatWidth = p.width - SideBarWidth
1000 chatHeight = p.height - EditorHeight
1001 }
1002
1003 // Check if mouse coordinates are within chat bounds
1004 return x >= chatX && x < chatX+chatWidth && y >= chatY && y < chatY+chatHeight
1005}