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 || p.isProjectInit {
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 || p.isProjectInit {
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 // Skip attachment handling during onboarding/splash screen
383 if p.focusedPane == PanelTypeSplash || p.isOnboarding {
384 u, cmd := p.splash.Update(msg)
385 p.splash = u.(splash.Splash)
386 return p, cmd
387 }
388 agentCfg := config.Get().Agents[config.AgentCoder]
389 model := config.Get().GetModelByType(agentCfg.Model)
390 if model == nil {
391 return p, util.ReportWarn("No model configured yet")
392 }
393 if model.SupportsImages {
394 return p, util.CmdHandler(commands.OpenFilePickerMsg{})
395 } else {
396 return p, util.ReportWarn("File attachments are not supported by the current model: " + model.Name)
397 }
398 case key.Matches(msg, p.keyMap.Tab):
399 if p.session.ID == "" {
400 u, cmd := p.splash.Update(msg)
401 p.splash = u.(splash.Splash)
402 return p, cmd
403 }
404 p.changeFocus()
405 return p, nil
406 case key.Matches(msg, p.keyMap.Cancel):
407 if p.session.ID != "" && p.app.AgentCoordinator.IsBusy() {
408 return p, p.cancel()
409 }
410 case key.Matches(msg, p.keyMap.Details):
411 p.toggleDetails()
412 return p, nil
413 }
414
415 switch p.focusedPane {
416 case PanelTypeChat:
417 u, cmd := p.chat.Update(msg)
418 p.chat = u.(chat.MessageListCmp)
419 cmds = append(cmds, cmd)
420 case PanelTypeEditor:
421 u, cmd := p.editor.Update(msg)
422 p.editor = u.(editor.Editor)
423 cmds = append(cmds, cmd)
424 case PanelTypeSplash:
425 u, cmd := p.splash.Update(msg)
426 p.splash = u.(splash.Splash)
427 cmds = append(cmds, cmd)
428 }
429 case tea.PasteMsg:
430 switch p.focusedPane {
431 case PanelTypeEditor:
432 u, cmd := p.editor.Update(msg)
433 p.editor = u.(editor.Editor)
434 cmds = append(cmds, cmd)
435 return p, tea.Batch(cmds...)
436 case PanelTypeChat:
437 u, cmd := p.chat.Update(msg)
438 p.chat = u.(chat.MessageListCmp)
439 cmds = append(cmds, cmd)
440 return p, tea.Batch(cmds...)
441 case PanelTypeSplash:
442 u, cmd := p.splash.Update(msg)
443 p.splash = u.(splash.Splash)
444 cmds = append(cmds, cmd)
445 return p, tea.Batch(cmds...)
446 }
447 }
448 return p, tea.Batch(cmds...)
449}
450
451func (p *chatPage) Cursor() *tea.Cursor {
452 if p.header.ShowingDetails() {
453 return nil
454 }
455 switch p.focusedPane {
456 case PanelTypeEditor:
457 return p.editor.Cursor()
458 case PanelTypeSplash:
459 return p.splash.Cursor()
460 default:
461 return nil
462 }
463}
464
465func (p *chatPage) View() string {
466 var chatView string
467 t := styles.CurrentTheme()
468
469 if p.session.ID == "" {
470 splashView := p.splash.View()
471 // Full screen during onboarding or project initialization
472 if p.splashFullScreen {
473 chatView = splashView
474 } else {
475 // Show splash + editor for new message state
476 editorView := p.editor.View()
477 chatView = lipgloss.JoinVertical(
478 lipgloss.Left,
479 t.S().Base.Render(splashView),
480 editorView,
481 )
482 }
483 } else {
484 messagesView := p.chat.View()
485 editorView := p.editor.View()
486 if p.compact {
487 headerView := p.header.View()
488 chatView = lipgloss.JoinVertical(
489 lipgloss.Left,
490 headerView,
491 messagesView,
492 editorView,
493 )
494 } else {
495 sidebarView := p.sidebar.View()
496 messages := lipgloss.JoinHorizontal(
497 lipgloss.Left,
498 messagesView,
499 sidebarView,
500 )
501 chatView = lipgloss.JoinVertical(
502 lipgloss.Left,
503 messages,
504 p.editor.View(),
505 )
506 }
507 }
508
509 layers := []*lipgloss.Layer{
510 lipgloss.NewLayer(chatView).X(0).Y(0),
511 }
512
513 if p.showingDetails {
514 style := t.S().Base.
515 Width(p.detailsWidth).
516 Border(lipgloss.RoundedBorder()).
517 BorderForeground(t.BorderFocus)
518 version := t.S().Base.Foreground(t.Border).Width(p.detailsWidth - 4).AlignHorizontal(lipgloss.Right).Render(version.Version)
519 details := style.Render(
520 lipgloss.JoinVertical(
521 lipgloss.Left,
522 p.sidebar.View(),
523 version,
524 ),
525 )
526 layers = append(layers, lipgloss.NewLayer(details).X(1).Y(1))
527 }
528 canvas := lipgloss.NewCanvas(
529 layers...,
530 )
531 return canvas.Render()
532}
533
534func (p *chatPage) updateCompactConfig(compact bool) tea.Cmd {
535 return func() tea.Msg {
536 err := config.Get().SetCompactMode(compact)
537 if err != nil {
538 return util.InfoMsg{
539 Type: util.InfoTypeError,
540 Msg: "Failed to update compact mode configuration: " + err.Error(),
541 }
542 }
543 return nil
544 }
545}
546
547func (p *chatPage) toggleThinking() tea.Cmd {
548 return func() tea.Msg {
549 cfg := config.Get()
550 agentCfg := cfg.Agents[config.AgentCoder]
551 currentModel := cfg.Models[agentCfg.Model]
552
553 // Toggle the thinking mode
554 currentModel.Think = !currentModel.Think
555 if err := cfg.UpdatePreferredModel(agentCfg.Model, currentModel); err != nil {
556 return util.InfoMsg{
557 Type: util.InfoTypeError,
558 Msg: "Failed to update thinking mode: " + err.Error(),
559 }
560 }
561
562 // Update the agent with the new configuration
563 go p.app.UpdateAgentModel(context.TODO())
564
565 status := "disabled"
566 if currentModel.Think {
567 status = "enabled"
568 }
569 return util.InfoMsg{
570 Type: util.InfoTypeInfo,
571 Msg: "Thinking mode " + status,
572 }
573 }
574}
575
576func (p *chatPage) openReasoningDialog() tea.Cmd {
577 return func() tea.Msg {
578 cfg := config.Get()
579 agentCfg := cfg.Agents[config.AgentCoder]
580 model := cfg.GetModelByType(agentCfg.Model)
581 providerCfg := cfg.GetProviderForModel(agentCfg.Model)
582
583 if providerCfg != nil && model != nil && len(model.ReasoningLevels) > 0 {
584 // Return the OpenDialogMsg directly so it bubbles up to the main TUI
585 return dialogs.OpenDialogMsg{
586 Model: reasoning.NewReasoningDialog(),
587 }
588 }
589 return nil
590 }
591}
592
593func (p *chatPage) handleReasoningEffortSelected(effort string) tea.Cmd {
594 return func() tea.Msg {
595 cfg := config.Get()
596 agentCfg := cfg.Agents[config.AgentCoder]
597 currentModel := cfg.Models[agentCfg.Model]
598
599 // Update the model configuration
600 currentModel.ReasoningEffort = effort
601 if err := cfg.UpdatePreferredModel(agentCfg.Model, currentModel); err != nil {
602 return util.InfoMsg{
603 Type: util.InfoTypeError,
604 Msg: "Failed to update reasoning effort: " + err.Error(),
605 }
606 }
607
608 // Update the agent with the new configuration
609 if err := p.app.UpdateAgentModel(context.TODO()); err != nil {
610 return util.InfoMsg{
611 Type: util.InfoTypeError,
612 Msg: "Failed to update reasoning effort: " + err.Error(),
613 }
614 }
615
616 return util.InfoMsg{
617 Type: util.InfoTypeInfo,
618 Msg: "Reasoning effort set to " + effort,
619 }
620 }
621}
622
623func (p *chatPage) setCompactMode(compact bool) {
624 if p.compact == compact {
625 return
626 }
627 p.compact = compact
628 if compact {
629 p.sidebar.SetCompactMode(true)
630 } else {
631 p.setShowDetails(false)
632 }
633}
634
635func (p *chatPage) handleCompactMode(newWidth int, newHeight int) {
636 if p.forceCompact {
637 return
638 }
639 if (newWidth < CompactModeWidthBreakpoint || newHeight < CompactModeHeightBreakpoint) && !p.compact {
640 p.setCompactMode(true)
641 }
642 if (newWidth >= CompactModeWidthBreakpoint && newHeight >= CompactModeHeightBreakpoint) && p.compact {
643 p.setCompactMode(false)
644 }
645}
646
647func (p *chatPage) SetSize(width, height int) tea.Cmd {
648 p.handleCompactMode(width, height)
649 p.width = width
650 p.height = height
651 var cmds []tea.Cmd
652
653 if p.session.ID == "" {
654 if p.splashFullScreen {
655 cmds = append(cmds, p.splash.SetSize(width, height))
656 } else {
657 cmds = append(cmds, p.splash.SetSize(width, height-EditorHeight))
658 cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
659 cmds = append(cmds, p.editor.SetPosition(0, height-EditorHeight))
660 }
661 } else {
662 if p.compact {
663 cmds = append(cmds, p.chat.SetSize(width, height-EditorHeight-HeaderHeight))
664 p.detailsWidth = width - DetailsPositioning
665 cmds = append(cmds, p.sidebar.SetSize(p.detailsWidth-LeftRightBorders, p.detailsHeight-TopBottomBorders))
666 cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
667 cmds = append(cmds, p.header.SetWidth(width-BorderWidth))
668 } else {
669 cmds = append(cmds, p.chat.SetSize(width-SideBarWidth, height-EditorHeight))
670 cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
671 cmds = append(cmds, p.sidebar.SetSize(SideBarWidth, height-EditorHeight))
672 }
673 cmds = append(cmds, p.editor.SetPosition(0, height-EditorHeight))
674 }
675 return tea.Batch(cmds...)
676}
677
678func (p *chatPage) newSession() tea.Cmd {
679 if p.session.ID == "" {
680 return nil
681 }
682
683 p.session = session.Session{}
684 p.focusedPane = PanelTypeEditor
685 p.editor.Focus()
686 p.chat.Blur()
687 p.isCanceling = false
688 return tea.Batch(
689 util.CmdHandler(chat.SessionClearedMsg{}),
690 p.SetSize(p.width, p.height),
691 )
692}
693
694func (p *chatPage) setSession(session session.Session) tea.Cmd {
695 if p.session.ID == session.ID {
696 return nil
697 }
698
699 var cmds []tea.Cmd
700 p.session = session
701
702 cmds = append(cmds, p.SetSize(p.width, p.height))
703 cmds = append(cmds, p.chat.SetSession(session))
704 cmds = append(cmds, p.sidebar.SetSession(session))
705 cmds = append(cmds, p.header.SetSession(session))
706 cmds = append(cmds, p.editor.SetSession(session))
707
708 return tea.Sequence(cmds...)
709}
710
711func (p *chatPage) changeFocus() {
712 if p.session.ID == "" {
713 return
714 }
715 switch p.focusedPane {
716 case PanelTypeChat:
717 p.focusedPane = PanelTypeEditor
718 p.editor.Focus()
719 p.chat.Blur()
720 case PanelTypeEditor:
721 p.focusedPane = PanelTypeChat
722 p.chat.Focus()
723 p.editor.Blur()
724 }
725}
726
727func (p *chatPage) cancel() tea.Cmd {
728 if p.isCanceling {
729 p.isCanceling = false
730 if p.app.AgentCoordinator != nil {
731 p.app.AgentCoordinator.Cancel(p.session.ID)
732 }
733 return nil
734 }
735
736 if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.QueuedPrompts(p.session.ID) > 0 {
737 p.app.AgentCoordinator.ClearQueue(p.session.ID)
738 return nil
739 }
740 p.isCanceling = true
741 return cancelTimerCmd()
742}
743
744func (p *chatPage) setShowDetails(show bool) {
745 p.showingDetails = show
746 p.header.SetDetailsOpen(p.showingDetails)
747 if !p.compact {
748 p.sidebar.SetCompactMode(false)
749 }
750}
751
752func (p *chatPage) toggleDetails() {
753 if p.session.ID == "" || !p.compact {
754 return
755 }
756 p.setShowDetails(!p.showingDetails)
757}
758
759func (p *chatPage) sendMessage(text string, attachments []message.Attachment) tea.Cmd {
760 session := p.session
761 var cmds []tea.Cmd
762 if p.session.ID == "" {
763 newSession, err := p.app.Sessions.Create(context.Background(), "New Session")
764 if err != nil {
765 return util.ReportError(err)
766 }
767 session = newSession
768 cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(session)))
769 }
770 if p.app.AgentCoordinator == nil {
771 return util.ReportError(fmt.Errorf("coder agent is not initialized"))
772 }
773 cmds = append(cmds, p.chat.GoToBottom())
774 cmds = append(cmds, func() tea.Msg {
775 _, err := p.app.AgentCoordinator.Run(context.Background(), session.ID, text, attachments...)
776 if err != nil {
777 isCancelErr := errors.Is(err, context.Canceled)
778 isPermissionErr := errors.Is(err, permission.ErrorPermissionDenied)
779 if isCancelErr || isPermissionErr {
780 return nil
781 }
782 return util.InfoMsg{
783 Type: util.InfoTypeError,
784 Msg: err.Error(),
785 }
786 }
787 return nil
788 })
789 return tea.Batch(cmds...)
790}
791
792func (p *chatPage) Bindings() []key.Binding {
793 bindings := []key.Binding{
794 p.keyMap.NewSession,
795 p.keyMap.AddAttachment,
796 }
797 if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.IsBusy() {
798 cancelBinding := p.keyMap.Cancel
799 if p.isCanceling {
800 cancelBinding = key.NewBinding(
801 key.WithKeys("esc", "alt+esc"),
802 key.WithHelp("esc", "press again to cancel"),
803 )
804 }
805 bindings = append([]key.Binding{cancelBinding}, bindings...)
806 }
807
808 switch p.focusedPane {
809 case PanelTypeChat:
810 bindings = append([]key.Binding{
811 key.NewBinding(
812 key.WithKeys("tab"),
813 key.WithHelp("tab", "focus editor"),
814 ),
815 }, bindings...)
816 bindings = append(bindings, p.chat.Bindings()...)
817 case PanelTypeEditor:
818 bindings = append([]key.Binding{
819 key.NewBinding(
820 key.WithKeys("tab"),
821 key.WithHelp("tab", "focus chat"),
822 ),
823 }, bindings...)
824 bindings = append(bindings, p.editor.Bindings()...)
825 case PanelTypeSplash:
826 bindings = append(bindings, p.splash.Bindings()...)
827 }
828
829 return bindings
830}
831
832func (p *chatPage) Help() help.KeyMap {
833 var shortList []key.Binding
834 var fullList [][]key.Binding
835 switch {
836 case p.isOnboarding && p.splash.IsShowingClaudeAuthMethodChooser():
837 shortList = append(shortList,
838 // Choose auth method
839 key.NewBinding(
840 key.WithKeys("left", "right", "tab"),
841 key.WithHelp("←→/tab", "choose"),
842 ),
843 // Accept selection
844 key.NewBinding(
845 key.WithKeys("enter"),
846 key.WithHelp("enter", "accept"),
847 ),
848 // Go back
849 key.NewBinding(
850 key.WithKeys("esc", "alt+esc"),
851 key.WithHelp("esc", "back"),
852 ),
853 // Quit
854 key.NewBinding(
855 key.WithKeys("ctrl+c"),
856 key.WithHelp("ctrl+c", "quit"),
857 ),
858 )
859 // keep them the same
860 for _, v := range shortList {
861 fullList = append(fullList, []key.Binding{v})
862 }
863 case p.isOnboarding && p.splash.IsShowingClaudeOAuth2():
864 if p.splash.IsClaudeOAuthURLState() {
865 shortList = append(shortList,
866 key.NewBinding(
867 key.WithKeys("enter"),
868 key.WithHelp("enter", "open"),
869 ),
870 key.NewBinding(
871 key.WithKeys("c"),
872 key.WithHelp("c", "copy url"),
873 ),
874 )
875 } else if p.splash.IsClaudeOAuthComplete() {
876 shortList = append(shortList,
877 key.NewBinding(
878 key.WithKeys("enter"),
879 key.WithHelp("enter", "continue"),
880 ),
881 )
882 } else {
883 shortList = append(shortList,
884 key.NewBinding(
885 key.WithKeys("enter"),
886 key.WithHelp("enter", "submit"),
887 ),
888 )
889 }
890 shortList = append(shortList,
891 // Quit
892 key.NewBinding(
893 key.WithKeys("ctrl+c"),
894 key.WithHelp("ctrl+c", "quit"),
895 ),
896 )
897 // keep them the same
898 for _, v := range shortList {
899 fullList = append(fullList, []key.Binding{v})
900 }
901 case p.isOnboarding && !p.splash.IsShowingAPIKey():
902 shortList = append(shortList,
903 // Choose model
904 key.NewBinding(
905 key.WithKeys("up", "down"),
906 key.WithHelp("↑/↓", "choose"),
907 ),
908 // Accept selection
909 key.NewBinding(
910 key.WithKeys("enter", "ctrl+y"),
911 key.WithHelp("enter", "accept"),
912 ),
913 // Quit
914 key.NewBinding(
915 key.WithKeys("ctrl+c"),
916 key.WithHelp("ctrl+c", "quit"),
917 ),
918 )
919 // keep them the same
920 for _, v := range shortList {
921 fullList = append(fullList, []key.Binding{v})
922 }
923 case p.isOnboarding && p.splash.IsShowingAPIKey():
924 if p.splash.IsAPIKeyValid() {
925 shortList = append(shortList,
926 key.NewBinding(
927 key.WithKeys("enter"),
928 key.WithHelp("enter", "continue"),
929 ),
930 )
931 } else {
932 shortList = append(shortList,
933 // Go back
934 key.NewBinding(
935 key.WithKeys("esc", "alt+esc"),
936 key.WithHelp("esc", "back"),
937 ),
938 )
939 }
940 shortList = append(shortList,
941 // Quit
942 key.NewBinding(
943 key.WithKeys("ctrl+c"),
944 key.WithHelp("ctrl+c", "quit"),
945 ),
946 )
947 // keep them the same
948 for _, v := range shortList {
949 fullList = append(fullList, []key.Binding{v})
950 }
951 case p.isProjectInit:
952 shortList = append(shortList,
953 key.NewBinding(
954 key.WithKeys("ctrl+c"),
955 key.WithHelp("ctrl+c", "quit"),
956 ),
957 )
958 // keep them the same
959 for _, v := range shortList {
960 fullList = append(fullList, []key.Binding{v})
961 }
962 default:
963 if p.editor.IsCompletionsOpen() {
964 shortList = append(shortList,
965 key.NewBinding(
966 key.WithKeys("tab", "enter"),
967 key.WithHelp("tab/enter", "complete"),
968 ),
969 key.NewBinding(
970 key.WithKeys("esc", "alt+esc"),
971 key.WithHelp("esc", "cancel"),
972 ),
973 key.NewBinding(
974 key.WithKeys("up", "down"),
975 key.WithHelp("↑/↓", "choose"),
976 ),
977 )
978 for _, v := range shortList {
979 fullList = append(fullList, []key.Binding{v})
980 }
981 return core.NewSimpleHelp(shortList, fullList)
982 }
983 if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.IsBusy() {
984 cancelBinding := key.NewBinding(
985 key.WithKeys("esc", "alt+esc"),
986 key.WithHelp("esc", "cancel"),
987 )
988 if p.isCanceling {
989 cancelBinding = key.NewBinding(
990 key.WithKeys("esc", "alt+esc"),
991 key.WithHelp("esc", "press again to cancel"),
992 )
993 }
994 if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.QueuedPrompts(p.session.ID) > 0 {
995 cancelBinding = key.NewBinding(
996 key.WithKeys("esc", "alt+esc"),
997 key.WithHelp("esc", "clear queue"),
998 )
999 }
1000 shortList = append(shortList, cancelBinding)
1001 fullList = append(fullList,
1002 []key.Binding{
1003 cancelBinding,
1004 },
1005 )
1006 }
1007 globalBindings := []key.Binding{}
1008 // we are in a session
1009 if p.session.ID != "" {
1010 tabKey := key.NewBinding(
1011 key.WithKeys("tab"),
1012 key.WithHelp("tab", "focus chat"),
1013 )
1014 if p.focusedPane == PanelTypeChat {
1015 tabKey = key.NewBinding(
1016 key.WithKeys("tab"),
1017 key.WithHelp("tab", "focus editor"),
1018 )
1019 }
1020 shortList = append(shortList, tabKey)
1021 globalBindings = append(globalBindings, tabKey)
1022 }
1023 commandsBinding := key.NewBinding(
1024 key.WithKeys("ctrl+p"),
1025 key.WithHelp("ctrl+p", "commands"),
1026 )
1027 modelsBinding := key.NewBinding(
1028 key.WithKeys("ctrl+m", "ctrl+l"),
1029 key.WithHelp("ctrl+l", "models"),
1030 )
1031 if p.keyboardEnhancements.Flags > 0 {
1032 // non-zero flags mean we have at least key disambiguation
1033 modelsBinding.SetHelp("ctrl+m", "models")
1034 }
1035 helpBinding := key.NewBinding(
1036 key.WithKeys("ctrl+g"),
1037 key.WithHelp("ctrl+g", "more"),
1038 )
1039 globalBindings = append(globalBindings, commandsBinding, modelsBinding)
1040 globalBindings = append(globalBindings,
1041 key.NewBinding(
1042 key.WithKeys("ctrl+s"),
1043 key.WithHelp("ctrl+s", "sessions"),
1044 ),
1045 )
1046 if p.session.ID != "" {
1047 globalBindings = append(globalBindings,
1048 key.NewBinding(
1049 key.WithKeys("ctrl+n"),
1050 key.WithHelp("ctrl+n", "new sessions"),
1051 ))
1052 }
1053 shortList = append(shortList,
1054 // Commands
1055 commandsBinding,
1056 modelsBinding,
1057 )
1058 fullList = append(fullList, globalBindings)
1059
1060 switch p.focusedPane {
1061 case PanelTypeChat:
1062 shortList = append(shortList,
1063 key.NewBinding(
1064 key.WithKeys("up", "down"),
1065 key.WithHelp("↑↓", "scroll"),
1066 ),
1067 messages.CopyKey,
1068 )
1069 fullList = append(fullList,
1070 []key.Binding{
1071 key.NewBinding(
1072 key.WithKeys("up", "down"),
1073 key.WithHelp("↑↓", "scroll"),
1074 ),
1075 key.NewBinding(
1076 key.WithKeys("shift+up", "shift+down"),
1077 key.WithHelp("shift+↑↓", "next/prev item"),
1078 ),
1079 key.NewBinding(
1080 key.WithKeys("pgup", "b"),
1081 key.WithHelp("b/pgup", "page up"),
1082 ),
1083 key.NewBinding(
1084 key.WithKeys("pgdown", " ", "f"),
1085 key.WithHelp("f/pgdn", "page down"),
1086 ),
1087 },
1088 []key.Binding{
1089 key.NewBinding(
1090 key.WithKeys("u"),
1091 key.WithHelp("u", "half page up"),
1092 ),
1093 key.NewBinding(
1094 key.WithKeys("d"),
1095 key.WithHelp("d", "half page down"),
1096 ),
1097 key.NewBinding(
1098 key.WithKeys("g", "home"),
1099 key.WithHelp("g", "home"),
1100 ),
1101 key.NewBinding(
1102 key.WithKeys("G", "end"),
1103 key.WithHelp("G", "end"),
1104 ),
1105 },
1106 []key.Binding{
1107 messages.CopyKey,
1108 messages.ClearSelectionKey,
1109 },
1110 )
1111 case PanelTypeEditor:
1112 newLineBinding := key.NewBinding(
1113 key.WithKeys("shift+enter", "ctrl+j"),
1114 // "ctrl+j" is a common keybinding for newline in many editors. If
1115 // the terminal supports "shift+enter", we substitute the help text
1116 // to reflect that.
1117 key.WithHelp("ctrl+j", "newline"),
1118 )
1119 if p.keyboardEnhancements.Flags > 0 {
1120 // Non-zero flags mean we have at least key disambiguation.
1121 newLineBinding.SetHelp("shift+enter", newLineBinding.Help().Desc)
1122 }
1123 shortList = append(shortList, newLineBinding)
1124 fullList = append(fullList,
1125 []key.Binding{
1126 newLineBinding,
1127 key.NewBinding(
1128 key.WithKeys("ctrl+f"),
1129 key.WithHelp("ctrl+f", "add image"),
1130 ),
1131 key.NewBinding(
1132 key.WithKeys("@"),
1133 key.WithHelp("@", "mention file"),
1134 ),
1135 key.NewBinding(
1136 key.WithKeys("ctrl+o"),
1137 key.WithHelp("ctrl+o", "open editor"),
1138 ),
1139 })
1140
1141 if p.editor.HasAttachments() {
1142 fullList = append(fullList, []key.Binding{
1143 key.NewBinding(
1144 key.WithKeys("ctrl+r"),
1145 key.WithHelp("ctrl+r+{i}", "delete attachment at index i"),
1146 ),
1147 key.NewBinding(
1148 key.WithKeys("ctrl+r", "r"),
1149 key.WithHelp("ctrl+r+r", "delete all attachments"),
1150 ),
1151 key.NewBinding(
1152 key.WithKeys("esc", "alt+esc"),
1153 key.WithHelp("esc", "cancel delete mode"),
1154 ),
1155 })
1156 }
1157 }
1158 shortList = append(shortList,
1159 // Quit
1160 key.NewBinding(
1161 key.WithKeys("ctrl+c"),
1162 key.WithHelp("ctrl+c", "quit"),
1163 ),
1164 // Help
1165 helpBinding,
1166 )
1167 fullList = append(fullList, []key.Binding{
1168 key.NewBinding(
1169 key.WithKeys("ctrl+g"),
1170 key.WithHelp("ctrl+g", "less"),
1171 ),
1172 })
1173 }
1174
1175 return core.NewSimpleHelp(shortList, fullList)
1176}
1177
1178func (p *chatPage) IsChatFocused() bool {
1179 return p.focusedPane == PanelTypeChat
1180}
1181
1182// isMouseOverChat checks if the given mouse coordinates are within the chat area bounds.
1183// Returns true if the mouse is over the chat area, false otherwise.
1184func (p *chatPage) isMouseOverChat(x, y int) bool {
1185 // No session means no chat area
1186 if p.session.ID == "" {
1187 return false
1188 }
1189
1190 var chatX, chatY, chatWidth, chatHeight int
1191
1192 if p.compact {
1193 // In compact mode: chat area starts after header and spans full width
1194 chatX = 0
1195 chatY = HeaderHeight
1196 chatWidth = p.width
1197 chatHeight = p.height - EditorHeight - HeaderHeight
1198 } else {
1199 // In non-compact mode: chat area spans from left edge to sidebar
1200 chatX = 0
1201 chatY = 0
1202 chatWidth = p.width - SideBarWidth
1203 chatHeight = p.height - EditorHeight
1204 }
1205
1206 // Check if mouse coordinates are within chat bounds
1207 return x >= chatX && x < chatX+chatWidth && y >= chatY && y < chatY+chatHeight
1208}