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