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