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