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