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