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