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