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