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