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