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