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