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