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