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