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