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 if p.app.CoderAgent != nil && p.app.CoderAgent.QueuedPrompts(p.session.ID) > 0 {
657 p.app.CoderAgent.ClearQueue(p.session.ID)
658 return nil
659 }
660 p.isCanceling = true
661 return cancelTimerCmd()
662}
663
664func (p *chatPage) setShowDetails(show bool) {
665 p.showingDetails = show
666 p.header.SetDetailsOpen(p.showingDetails)
667 if !p.compact {
668 p.sidebar.SetCompactMode(false)
669 }
670}
671
672func (p *chatPage) toggleDetails() {
673 if p.session.ID == "" || !p.compact {
674 return
675 }
676 p.setShowDetails(!p.showingDetails)
677}
678
679func (p *chatPage) sendMessage(text string, attachments []message.Attachment) tea.Cmd {
680 session := p.session
681 var cmds []tea.Cmd
682 if p.session.ID == "" {
683 newSession, err := p.app.Sessions.Create(context.Background(), "New Session")
684 if err != nil {
685 return util.ReportError(err)
686 }
687 session = newSession
688 cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(session)))
689 }
690 _, err := p.app.CoderAgent.Run(context.Background(), session.ID, text, attachments...)
691 if err != nil {
692 return util.ReportError(err)
693 }
694 cmds = append(cmds, p.chat.GoToBottom())
695 return tea.Batch(cmds...)
696}
697
698func (p *chatPage) Bindings() []key.Binding {
699 bindings := []key.Binding{
700 p.keyMap.NewSession,
701 p.keyMap.AddAttachment,
702 }
703 if p.app.CoderAgent != nil && p.app.CoderAgent.IsBusy() {
704 cancelBinding := p.keyMap.Cancel
705 if p.isCanceling {
706 cancelBinding = key.NewBinding(
707 key.WithKeys("esc"),
708 key.WithHelp("esc", "press again to cancel"),
709 )
710 }
711 bindings = append([]key.Binding{cancelBinding}, bindings...)
712 }
713
714 switch p.focusedPane {
715 case PanelTypeChat:
716 bindings = append([]key.Binding{
717 key.NewBinding(
718 key.WithKeys("tab"),
719 key.WithHelp("tab", "focus editor"),
720 ),
721 }, bindings...)
722 bindings = append(bindings, p.chat.Bindings()...)
723 case PanelTypeEditor:
724 bindings = append([]key.Binding{
725 key.NewBinding(
726 key.WithKeys("tab"),
727 key.WithHelp("tab", "focus chat"),
728 ),
729 }, bindings...)
730 bindings = append(bindings, p.editor.Bindings()...)
731 case PanelTypeSplash:
732 bindings = append(bindings, p.splash.Bindings()...)
733 }
734
735 return bindings
736}
737
738func (p *chatPage) Help() help.KeyMap {
739 var shortList []key.Binding
740 var fullList [][]key.Binding
741 switch {
742 case p.isOnboarding && !p.splash.IsShowingAPIKey():
743 shortList = append(shortList,
744 // Choose model
745 key.NewBinding(
746 key.WithKeys("up", "down"),
747 key.WithHelp("↑/↓", "choose"),
748 ),
749 // Accept selection
750 key.NewBinding(
751 key.WithKeys("enter", "ctrl+y"),
752 key.WithHelp("enter", "accept"),
753 ),
754 // Quit
755 key.NewBinding(
756 key.WithKeys("ctrl+c"),
757 key.WithHelp("ctrl+c", "quit"),
758 ),
759 )
760 // keep them the same
761 for _, v := range shortList {
762 fullList = append(fullList, []key.Binding{v})
763 }
764 case p.isOnboarding && p.splash.IsShowingAPIKey():
765 if p.splash.IsAPIKeyValid() {
766 shortList = append(shortList,
767 key.NewBinding(
768 key.WithKeys("enter"),
769 key.WithHelp("enter", "continue"),
770 ),
771 )
772 } else {
773 shortList = append(shortList,
774 // Go back
775 key.NewBinding(
776 key.WithKeys("esc"),
777 key.WithHelp("esc", "back"),
778 ),
779 )
780 }
781 shortList = append(shortList,
782 // Quit
783 key.NewBinding(
784 key.WithKeys("ctrl+c"),
785 key.WithHelp("ctrl+c", "quit"),
786 ),
787 )
788 // keep them the same
789 for _, v := range shortList {
790 fullList = append(fullList, []key.Binding{v})
791 }
792 case p.isProjectInit:
793 shortList = append(shortList,
794 key.NewBinding(
795 key.WithKeys("ctrl+c"),
796 key.WithHelp("ctrl+c", "quit"),
797 ),
798 )
799 // keep them the same
800 for _, v := range shortList {
801 fullList = append(fullList, []key.Binding{v})
802 }
803 default:
804 if p.editor.IsCompletionsOpen() {
805 shortList = append(shortList,
806 key.NewBinding(
807 key.WithKeys("tab", "enter"),
808 key.WithHelp("tab/enter", "complete"),
809 ),
810 key.NewBinding(
811 key.WithKeys("esc"),
812 key.WithHelp("esc", "cancel"),
813 ),
814 key.NewBinding(
815 key.WithKeys("up", "down"),
816 key.WithHelp("↑/↓", "choose"),
817 ),
818 )
819 for _, v := range shortList {
820 fullList = append(fullList, []key.Binding{v})
821 }
822 return core.NewSimpleHelp(shortList, fullList)
823 }
824 if p.app.CoderAgent != nil && p.app.CoderAgent.IsBusy() {
825 cancelBinding := key.NewBinding(
826 key.WithKeys("esc"),
827 key.WithHelp("esc", "cancel"),
828 )
829 if p.isCanceling {
830 cancelBinding = key.NewBinding(
831 key.WithKeys("esc"),
832 key.WithHelp("esc", "press again to cancel"),
833 )
834 }
835 if p.app.CoderAgent.QueuedPrompts(p.session.ID) > 0 {
836 cancelBinding = key.NewBinding(
837 key.WithKeys("esc"),
838 key.WithHelp("esc", "clear queue"),
839 )
840 }
841 shortList = append(shortList, cancelBinding)
842 fullList = append(fullList,
843 []key.Binding{
844 cancelBinding,
845 },
846 )
847 }
848 globalBindings := []key.Binding{}
849 // we are in a session
850 if p.session.ID != "" {
851 tabKey := key.NewBinding(
852 key.WithKeys("tab"),
853 key.WithHelp("tab", "focus chat"),
854 )
855 if p.focusedPane == PanelTypeChat {
856 tabKey = key.NewBinding(
857 key.WithKeys("tab"),
858 key.WithHelp("tab", "focus editor"),
859 )
860 }
861 shortList = append(shortList, tabKey)
862 globalBindings = append(globalBindings, tabKey)
863 }
864 commandsBinding := key.NewBinding(
865 key.WithKeys("ctrl+p"),
866 key.WithHelp("ctrl+p", "commands"),
867 )
868 helpBinding := key.NewBinding(
869 key.WithKeys("ctrl+g"),
870 key.WithHelp("ctrl+g", "more"),
871 )
872 globalBindings = append(globalBindings, commandsBinding)
873 globalBindings = append(globalBindings,
874 key.NewBinding(
875 key.WithKeys("ctrl+s"),
876 key.WithHelp("ctrl+s", "sessions"),
877 ),
878 )
879 if p.session.ID != "" {
880 globalBindings = append(globalBindings,
881 key.NewBinding(
882 key.WithKeys("ctrl+n"),
883 key.WithHelp("ctrl+n", "new sessions"),
884 ))
885 }
886 shortList = append(shortList,
887 // Commands
888 commandsBinding,
889 )
890 fullList = append(fullList, globalBindings)
891
892 switch p.focusedPane {
893 case PanelTypeChat:
894 shortList = append(shortList,
895 key.NewBinding(
896 key.WithKeys("up", "down"),
897 key.WithHelp("↑↓", "scroll"),
898 ),
899 messages.CopyKey,
900 )
901 fullList = append(fullList,
902 []key.Binding{
903 key.NewBinding(
904 key.WithKeys("up", "down"),
905 key.WithHelp("↑↓", "scroll"),
906 ),
907 key.NewBinding(
908 key.WithKeys("shift+up", "shift+down"),
909 key.WithHelp("shift+↑↓", "next/prev item"),
910 ),
911 key.NewBinding(
912 key.WithKeys("pgup", "b"),
913 key.WithHelp("b/pgup", "page up"),
914 ),
915 key.NewBinding(
916 key.WithKeys("pgdown", " ", "f"),
917 key.WithHelp("f/pgdn", "page down"),
918 ),
919 },
920 []key.Binding{
921 key.NewBinding(
922 key.WithKeys("u"),
923 key.WithHelp("u", "half page up"),
924 ),
925 key.NewBinding(
926 key.WithKeys("d"),
927 key.WithHelp("d", "half page down"),
928 ),
929 key.NewBinding(
930 key.WithKeys("g", "home"),
931 key.WithHelp("g", "home"),
932 ),
933 key.NewBinding(
934 key.WithKeys("G", "end"),
935 key.WithHelp("G", "end"),
936 ),
937 },
938 []key.Binding{
939 messages.CopyKey,
940 messages.ClearSelectionKey,
941 },
942 )
943 case PanelTypeEditor:
944 newLineBinding := key.NewBinding(
945 key.WithKeys("shift+enter", "ctrl+j"),
946 // "ctrl+j" is a common keybinding for newline in many editors. If
947 // the terminal supports "shift+enter", we substitute the help text
948 // to reflect that.
949 key.WithHelp("ctrl+j", "newline"),
950 )
951 if p.keyboardEnhancements.SupportsKeyDisambiguation() {
952 newLineBinding.SetHelp("shift+enter", newLineBinding.Help().Desc)
953 }
954 shortList = append(shortList, newLineBinding)
955 fullList = append(fullList,
956 []key.Binding{
957 newLineBinding,
958 key.NewBinding(
959 key.WithKeys("ctrl+f"),
960 key.WithHelp("ctrl+f", "add image"),
961 ),
962 key.NewBinding(
963 key.WithKeys("/"),
964 key.WithHelp("/", "add file"),
965 ),
966 key.NewBinding(
967 key.WithKeys("ctrl+o"),
968 key.WithHelp("ctrl+o", "open editor"),
969 ),
970 })
971
972 if p.editor.HasAttachments() {
973 fullList = append(fullList, []key.Binding{
974 key.NewBinding(
975 key.WithKeys("ctrl+r"),
976 key.WithHelp("ctrl+r+{i}", "delete attachment at index i"),
977 ),
978 key.NewBinding(
979 key.WithKeys("ctrl+r", "r"),
980 key.WithHelp("ctrl+r+r", "delete all attachments"),
981 ),
982 key.NewBinding(
983 key.WithKeys("esc"),
984 key.WithHelp("esc", "cancel delete mode"),
985 ),
986 })
987 }
988 }
989 shortList = append(shortList,
990 // Quit
991 key.NewBinding(
992 key.WithKeys("ctrl+c"),
993 key.WithHelp("ctrl+c", "quit"),
994 ),
995 // Help
996 helpBinding,
997 )
998 fullList = append(fullList, []key.Binding{
999 key.NewBinding(
1000 key.WithKeys("ctrl+g"),
1001 key.WithHelp("ctrl+g", "less"),
1002 ),
1003 })
1004 }
1005
1006 return core.NewSimpleHelp(shortList, fullList)
1007}
1008
1009func (p *chatPage) IsChatFocused() bool {
1010 return p.focusedPane == PanelTypeChat
1011}
1012
1013// isMouseOverChat checks if the given mouse coordinates are within the chat area bounds.
1014// Returns true if the mouse is over the chat area, false otherwise.
1015func (p *chatPage) isMouseOverChat(x, y int) bool {
1016 // No session means no chat area
1017 if p.session.ID == "" {
1018 return false
1019 }
1020
1021 var chatX, chatY, chatWidth, chatHeight int
1022
1023 if p.compact {
1024 // In compact mode: chat area starts after header and spans full width
1025 chatX = 0
1026 chatY = HeaderHeight
1027 chatWidth = p.width
1028 chatHeight = p.height - EditorHeight - HeaderHeight
1029 } else {
1030 // In non-compact mode: chat area spans from left edge to sidebar
1031 chatX = 0
1032 chatY = 0
1033 chatWidth = p.width - SideBarWidth
1034 chatHeight = p.height - EditorHeight
1035 }
1036
1037 // Check if mouse coordinates are within chat bounds
1038 return x >= chatX && x < chatX+chatWidth && y >= chatY && y < chatY+chatHeight
1039}