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