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