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