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