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),
121 sidebar: sidebar.New(app, false),
122 chat: chat.New(app),
123 editor: editor.New(app),
124 splash: splash.New(app.Config()),
125 focusedPane: PanelTypeSplash,
126 }
127}
128
129func (p *chatPage) Init() tea.Cmd {
130 compact := p.app.Config().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(p.app.Config()) {
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(p.app.Config()); 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 pubsub.Event[session.Session]:
208 u, cmd := p.header.Update(msg)
209 p.header = u.(header.Header)
210 cmds = append(cmds, cmd)
211 u, cmd = p.sidebar.Update(msg)
212 p.sidebar = u.(sidebar.Sidebar)
213 cmds = append(cmds, cmd)
214 return p, tea.Batch(cmds...)
215 case chat.SessionClearedMsg:
216 u, cmd := p.header.Update(msg)
217 p.header = u.(header.Header)
218 cmds = append(cmds, cmd)
219 u, cmd = p.sidebar.Update(msg)
220 p.sidebar = u.(sidebar.Sidebar)
221 cmds = append(cmds, cmd)
222 u, cmd = p.chat.Update(msg)
223 p.chat = u.(chat.MessageListCmp)
224 cmds = append(cmds, cmd)
225 return p, tea.Batch(cmds...)
226 case filepicker.FilePickedMsg,
227 completions.CompletionsClosedMsg,
228 completions.SelectCompletionMsg:
229 u, cmd := p.editor.Update(msg)
230 p.editor = u.(editor.Editor)
231 cmds = append(cmds, cmd)
232 return p, tea.Batch(cmds...)
233
234 case models.APIKeyStateChangeMsg:
235 if p.focusedPane == PanelTypeSplash {
236 u, cmd := p.splash.Update(msg)
237 p.splash = u.(splash.Splash)
238 cmds = append(cmds, cmd)
239 }
240 return p, tea.Batch(cmds...)
241 case pubsub.Event[message.Message],
242 anim.StepMsg,
243 spinner.TickMsg:
244 if p.focusedPane == PanelTypeSplash {
245 u, cmd := p.splash.Update(msg)
246 p.splash = u.(splash.Splash)
247 cmds = append(cmds, cmd)
248 } else {
249 u, cmd := p.chat.Update(msg)
250 p.chat = u.(chat.MessageListCmp)
251 cmds = append(cmds, cmd)
252 }
253 return p, tea.Batch(cmds...)
254
255 case pubsub.Event[history.File], sidebar.SessionFilesMsg:
256 u, cmd := p.sidebar.Update(msg)
257 p.sidebar = u.(sidebar.Sidebar)
258 cmds = append(cmds, cmd)
259 return p, tea.Batch(cmds...)
260 case pubsub.Event[permission.PermissionNotification]:
261 u, cmd := p.chat.Update(msg)
262 p.chat = u.(chat.MessageListCmp)
263 cmds = append(cmds, cmd)
264 return p, tea.Batch(cmds...)
265
266 case commands.CommandRunCustomMsg:
267 if p.app.CoderAgent.IsBusy() {
268 return p, util.ReportWarn("Agent is busy, please wait before executing a command...")
269 }
270
271 cmd := p.sendMessage(msg.Content, nil)
272 if cmd != nil {
273 return p, cmd
274 }
275 case splash.OnboardingCompleteMsg:
276 p.splashFullScreen = false
277 if b, _ := config.ProjectNeedsInitialization(p.app.Config()); b {
278 p.splash.SetProjectInit(true)
279 p.splashFullScreen = true
280 return p, p.SetSize(p.width, p.height)
281 }
282 err := p.app.InitCoderAgent()
283 if err != nil {
284 return p, util.ReportError(err)
285 }
286 p.isOnboarding = false
287 p.isProjectInit = false
288 p.focusedPane = PanelTypeEditor
289 return p, p.SetSize(p.width, p.height)
290 case tea.KeyPressMsg:
291 switch {
292 case key.Matches(msg, p.keyMap.NewSession):
293 if p.app.CoderAgent.IsBusy() {
294 return p, util.ReportWarn("Agent is busy, please wait before starting a new session...")
295 }
296 return p, p.newSession()
297 case key.Matches(msg, p.keyMap.AddAttachment):
298 model := p.app.CoderAgent.Model()
299 if model.SupportsImages {
300 return p, util.CmdHandler(OpenFilePickerMsg{})
301 } else {
302 return p, util.ReportWarn("File attachments are not supported by the current model: " + model.Name)
303 }
304 case key.Matches(msg, p.keyMap.Tab):
305 if p.session.ID == "" {
306 u, cmd := p.splash.Update(msg)
307 p.splash = u.(splash.Splash)
308 return p, cmd
309 }
310 p.changeFocus()
311 return p, nil
312 case key.Matches(msg, p.keyMap.Cancel):
313 if p.session.ID != "" && p.app.CoderAgent.IsBusy() {
314 return p, p.cancel()
315 }
316 case key.Matches(msg, p.keyMap.Details):
317 p.toggleDetails()
318 return p, nil
319 }
320
321 switch p.focusedPane {
322 case PanelTypeChat:
323 u, cmd := p.chat.Update(msg)
324 p.chat = u.(chat.MessageListCmp)
325 cmds = append(cmds, cmd)
326 case PanelTypeEditor:
327 u, cmd := p.editor.Update(msg)
328 p.editor = u.(editor.Editor)
329 cmds = append(cmds, cmd)
330 case PanelTypeSplash:
331 u, cmd := p.splash.Update(msg)
332 p.splash = u.(splash.Splash)
333 cmds = append(cmds, cmd)
334 }
335 case tea.PasteMsg:
336 switch p.focusedPane {
337 case PanelTypeEditor:
338 u, cmd := p.editor.Update(msg)
339 p.editor = u.(editor.Editor)
340 cmds = append(cmds, cmd)
341 return p, tea.Batch(cmds...)
342 case PanelTypeChat:
343 u, cmd := p.chat.Update(msg)
344 p.chat = u.(chat.MessageListCmp)
345 cmds = append(cmds, cmd)
346 return p, tea.Batch(cmds...)
347 case PanelTypeSplash:
348 u, cmd := p.splash.Update(msg)
349 p.splash = u.(splash.Splash)
350 cmds = append(cmds, cmd)
351 return p, tea.Batch(cmds...)
352 }
353 }
354 return p, tea.Batch(cmds...)
355}
356
357func (p *chatPage) Cursor() *tea.Cursor {
358 if p.header.ShowingDetails() {
359 return nil
360 }
361 switch p.focusedPane {
362 case PanelTypeEditor:
363 return p.editor.Cursor()
364 case PanelTypeSplash:
365 return p.splash.Cursor()
366 default:
367 return nil
368 }
369}
370
371func (p *chatPage) View() string {
372 var chatView string
373 t := styles.CurrentTheme()
374
375 if p.session.ID == "" {
376 splashView := p.splash.View()
377 // Full screen during onboarding or project initialization
378 if p.splashFullScreen {
379 chatView = splashView
380 } else {
381 // Show splash + editor for new message state
382 editorView := p.editor.View()
383 chatView = lipgloss.JoinVertical(
384 lipgloss.Left,
385 t.S().Base.Render(splashView),
386 editorView,
387 )
388 }
389 } else {
390 messagesView := p.chat.View()
391 editorView := p.editor.View()
392 if p.compact {
393 headerView := p.header.View()
394 chatView = lipgloss.JoinVertical(
395 lipgloss.Left,
396 headerView,
397 messagesView,
398 editorView,
399 )
400 } else {
401 sidebarView := p.sidebar.View()
402 messages := lipgloss.JoinHorizontal(
403 lipgloss.Left,
404 messagesView,
405 sidebarView,
406 )
407 chatView = lipgloss.JoinVertical(
408 lipgloss.Left,
409 messages,
410 p.editor.View(),
411 )
412 }
413 }
414
415 layers := []*lipgloss.Layer{
416 lipgloss.NewLayer(chatView).X(0).Y(0),
417 }
418
419 if p.showingDetails {
420 style := t.S().Base.
421 Width(p.detailsWidth).
422 Border(lipgloss.RoundedBorder()).
423 BorderForeground(t.BorderFocus)
424 version := t.S().Base.Foreground(t.Border).Width(p.detailsWidth - 4).AlignHorizontal(lipgloss.Right).Render(version.Version)
425 details := style.Render(
426 lipgloss.JoinVertical(
427 lipgloss.Left,
428 p.sidebar.View(),
429 version,
430 ),
431 )
432 layers = append(layers, lipgloss.NewLayer(details).X(1).Y(1))
433 }
434 canvas := lipgloss.NewCanvas(
435 layers...,
436 )
437 return canvas.Render()
438}
439
440func (p *chatPage) updateCompactConfig(compact bool) tea.Cmd {
441 return func() tea.Msg {
442 err := p.app.Config().SetCompactMode(compact)
443 if err != nil {
444 return util.InfoMsg{
445 Type: util.InfoTypeError,
446 Msg: "Failed to update compact mode configuration: " + err.Error(),
447 }
448 }
449 return nil
450 }
451}
452
453func (p *chatPage) toggleThinking() tea.Cmd {
454 return func() tea.Msg {
455 currentModel := p.app.CoderAgent.ModelConfig()
456
457 // Toggle the thinking mode
458 currentModel.Think = !currentModel.Think
459 p.app.Config().Models[config.SelectedModelTypeLarge] = 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 key.NewBinding(
825 key.WithKeys("c", "y"),
826 key.WithHelp("c/y", "copy"),
827 ),
828 )
829 fullList = append(fullList,
830 []key.Binding{
831 key.NewBinding(
832 key.WithKeys("up", "down"),
833 key.WithHelp("↑↓", "scroll"),
834 ),
835 key.NewBinding(
836 key.WithKeys("shift+up", "shift+down"),
837 key.WithHelp("shift+↑↓", "next/prev item"),
838 ),
839 key.NewBinding(
840 key.WithKeys("pgup", "b"),
841 key.WithHelp("b/pgup", "page up"),
842 ),
843 key.NewBinding(
844 key.WithKeys("pgdown", " ", "f"),
845 key.WithHelp("f/pgdn", "page down"),
846 ),
847 },
848 []key.Binding{
849 key.NewBinding(
850 key.WithKeys("u"),
851 key.WithHelp("u", "half page up"),
852 ),
853 key.NewBinding(
854 key.WithKeys("d"),
855 key.WithHelp("d", "half page down"),
856 ),
857 key.NewBinding(
858 key.WithKeys("g", "home"),
859 key.WithHelp("g", "home"),
860 ),
861 key.NewBinding(
862 key.WithKeys("G", "end"),
863 key.WithHelp("G", "end"),
864 ),
865 },
866 )
867 case PanelTypeEditor:
868 newLineBinding := key.NewBinding(
869 key.WithKeys("shift+enter", "ctrl+j"),
870 // "ctrl+j" is a common keybinding for newline in many editors. If
871 // the terminal supports "shift+enter", we substitute the help text
872 // to reflect that.
873 key.WithHelp("ctrl+j", "newline"),
874 )
875 if p.keyboardEnhancements.SupportsKeyDisambiguation() {
876 newLineBinding.SetHelp("shift+enter", newLineBinding.Help().Desc)
877 }
878 shortList = append(shortList, newLineBinding)
879 fullList = append(fullList,
880 []key.Binding{
881 newLineBinding,
882 key.NewBinding(
883 key.WithKeys("ctrl+f"),
884 key.WithHelp("ctrl+f", "add image"),
885 ),
886 key.NewBinding(
887 key.WithKeys("/"),
888 key.WithHelp("/", "add file"),
889 ),
890 key.NewBinding(
891 key.WithKeys("ctrl+v"),
892 key.WithHelp("ctrl+v", "open editor"),
893 ),
894 key.NewBinding(
895 key.WithKeys("ctrl+r"),
896 key.WithHelp("ctrl+r+{i}", "delete attachment at index i"),
897 ),
898 key.NewBinding(
899 key.WithKeys("ctrl+r", "r"),
900 key.WithHelp("ctrl+r+r", "delete all attachments"),
901 ),
902 key.NewBinding(
903 key.WithKeys("esc"),
904 key.WithHelp("esc", "cancel delete mode"),
905 ),
906 })
907 }
908 shortList = append(shortList,
909 // Quit
910 key.NewBinding(
911 key.WithKeys("ctrl+c"),
912 key.WithHelp("ctrl+c", "quit"),
913 ),
914 // Help
915 helpBinding,
916 )
917 fullList = append(fullList, []key.Binding{
918 key.NewBinding(
919 key.WithKeys("ctrl+g"),
920 key.WithHelp("ctrl+g", "less"),
921 ),
922 })
923 }
924
925 return core.NewSimpleHelp(shortList, fullList)
926}
927
928func (p *chatPage) IsChatFocused() bool {
929 return p.focusedPane == PanelTypeChat
930}
931
932// isMouseOverChat checks if the given mouse coordinates are within the chat area bounds.
933// Returns true if the mouse is over the chat area, false otherwise.
934func (p *chatPage) isMouseOverChat(x, y int) bool {
935 // No session means no chat area
936 if p.session.ID == "" {
937 return false
938 }
939
940 var chatX, chatY, chatWidth, chatHeight int
941
942 if p.compact {
943 // In compact mode: chat area starts after header and spans full width
944 chatX = 0
945 chatY = HeaderHeight
946 chatWidth = p.width
947 chatHeight = p.height - EditorHeight - HeaderHeight
948 } else {
949 // In non-compact mode: chat area spans from left edge to sidebar
950 chatX = 0
951 chatY = 0
952 chatWidth = p.width - SideBarWidth
953 chatHeight = p.height - EditorHeight
954 }
955
956 // Check if mouse coordinates are within chat bounds
957 return x >= chatX && x < chatX+chatWidth && y >= chatY && y < chatY+chatHeight
958}