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