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