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", "home"),
877 ),
878 key.NewBinding(
879 key.WithKeys("G", "end"),
880 key.WithHelp("G/end", "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}