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