1package chat
2
3import (
4 "context"
5 "fmt"
6 "time"
7
8 "github.com/charmbracelet/bubbles/v2/help"
9 "github.com/charmbracelet/bubbles/v2/key"
10 "github.com/charmbracelet/bubbles/v2/spinner"
11 tea "github.com/charmbracelet/bubbletea/v2"
12 "github.com/charmbracelet/catwalk/pkg/catwalk"
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 &&
567 providerCfg.Type == catwalk.TypeOpenAI && model.HasReasoningEffort {
568 // Return the OpenDialogMsg directly so it bubbles up to the main TUI
569 return dialogs.OpenDialogMsg{
570 Model: reasoning.NewReasoningDialog(),
571 }
572 }
573 return nil
574 }
575}
576
577func (p *chatPage) handleReasoningEffortSelected(effort string) tea.Cmd {
578 return func() tea.Msg {
579 cfg := config.Get()
580 agentCfg := cfg.Agents[config.AgentCoder]
581 currentModel := cfg.Models[agentCfg.Model]
582
583 // Update the model configuration
584 currentModel.ReasoningEffort = effort
585 cfg.Models[agentCfg.Model] = currentModel
586
587 // Update the agent with the new configuration
588 if err := p.app.UpdateAgentModel(); err != nil {
589 return util.InfoMsg{
590 Type: util.InfoTypeError,
591 Msg: "Failed to update reasoning effort: " + err.Error(),
592 }
593 }
594
595 return util.InfoMsg{
596 Type: util.InfoTypeInfo,
597 Msg: "Reasoning effort set to " + effort,
598 }
599 }
600}
601
602func (p *chatPage) setCompactMode(compact bool) {
603 if p.compact == compact {
604 return
605 }
606 p.compact = compact
607 if compact {
608 p.sidebar.SetCompactMode(true)
609 } else {
610 p.setShowDetails(false)
611 }
612}
613
614func (p *chatPage) handleCompactMode(newWidth int, newHeight int) {
615 if p.forceCompact {
616 return
617 }
618 if (newWidth < CompactModeWidthBreakpoint || newHeight < CompactModeHeightBreakpoint) && !p.compact {
619 p.setCompactMode(true)
620 }
621 if (newWidth >= CompactModeWidthBreakpoint && newHeight >= CompactModeHeightBreakpoint) && p.compact {
622 p.setCompactMode(false)
623 }
624}
625
626func (p *chatPage) SetSize(width, height int) tea.Cmd {
627 p.handleCompactMode(width, height)
628 p.width = width
629 p.height = height
630 var cmds []tea.Cmd
631
632 if p.session.ID == "" {
633 if p.splashFullScreen {
634 cmds = append(cmds, p.splash.SetSize(width, height))
635 } else {
636 cmds = append(cmds, p.splash.SetSize(width, height-EditorHeight))
637 cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
638 cmds = append(cmds, p.editor.SetPosition(0, height-EditorHeight))
639 }
640 } else {
641 if p.compact {
642 cmds = append(cmds, p.chat.SetSize(width, height-EditorHeight-HeaderHeight))
643 p.detailsWidth = width - DetailsPositioning
644 cmds = append(cmds, p.sidebar.SetSize(p.detailsWidth-LeftRightBorders, p.detailsHeight-TopBottomBorders))
645 cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
646 cmds = append(cmds, p.header.SetWidth(width-BorderWidth))
647 } else {
648 cmds = append(cmds, p.chat.SetSize(width-SideBarWidth, height-EditorHeight))
649 cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
650 cmds = append(cmds, p.sidebar.SetSize(SideBarWidth, height-EditorHeight))
651 }
652 cmds = append(cmds, p.editor.SetPosition(0, height-EditorHeight))
653 }
654 return tea.Batch(cmds...)
655}
656
657func (p *chatPage) newSession() tea.Cmd {
658 if p.session.ID == "" {
659 return nil
660 }
661
662 p.session = session.Session{}
663 p.focusedPane = PanelTypeEditor
664 p.editor.Focus()
665 p.chat.Blur()
666 p.isCanceling = false
667 return tea.Batch(
668 util.CmdHandler(chat.SessionClearedMsg{}),
669 p.SetSize(p.width, p.height),
670 )
671}
672
673func (p *chatPage) setSession(session session.Session) tea.Cmd {
674 if p.session.ID == session.ID {
675 return nil
676 }
677
678 var cmds []tea.Cmd
679 p.session = session
680
681 cmds = append(cmds, p.SetSize(p.width, p.height))
682 cmds = append(cmds, p.chat.SetSession(session))
683 cmds = append(cmds, p.sidebar.SetSession(session))
684 cmds = append(cmds, p.header.SetSession(session))
685 cmds = append(cmds, p.editor.SetSession(session))
686
687 return tea.Sequence(cmds...)
688}
689
690func (p *chatPage) changeFocus() {
691 if p.session.ID == "" {
692 return
693 }
694 switch p.focusedPane {
695 case PanelTypeChat:
696 p.focusedPane = PanelTypeEditor
697 p.editor.Focus()
698 p.chat.Blur()
699 case PanelTypeEditor:
700 p.focusedPane = PanelTypeChat
701 p.chat.Focus()
702 p.editor.Blur()
703 }
704}
705
706func (p *chatPage) cancel() tea.Cmd {
707 if p.isCanceling {
708 p.isCanceling = false
709 if p.app.AgentCoordinator != nil {
710 p.app.AgentCoordinator.Cancel(p.session.ID)
711 }
712 return nil
713 }
714
715 if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.QueuedPrompts(p.session.ID) > 0 {
716 p.app.AgentCoordinator.ClearQueue(p.session.ID)
717 return nil
718 }
719 p.isCanceling = true
720 return cancelTimerCmd()
721}
722
723func (p *chatPage) setShowDetails(show bool) {
724 p.showingDetails = show
725 p.header.SetDetailsOpen(p.showingDetails)
726 if !p.compact {
727 p.sidebar.SetCompactMode(false)
728 }
729}
730
731func (p *chatPage) toggleDetails() {
732 if p.session.ID == "" || !p.compact {
733 return
734 }
735 p.setShowDetails(!p.showingDetails)
736}
737
738func (p *chatPage) sendMessage(text string, attachments []message.Attachment) tea.Cmd {
739 session := p.session
740 var cmds []tea.Cmd
741 if p.session.ID == "" {
742 newSession, err := p.app.Sessions.Create(context.Background(), "New Session")
743 if err != nil {
744 return util.ReportError(err)
745 }
746 session = newSession
747 cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(session)))
748 }
749 if p.app.AgentCoordinator == nil {
750 return util.ReportError(fmt.Errorf("coder agent is not initialized"))
751 }
752 cmds = append(cmds, p.chat.GoToBottom())
753 cmds = append(cmds, func() tea.Msg {
754 _, err := p.app.AgentCoordinator.Run(context.Background(), session.ID, text, attachments...)
755 if err != nil {
756 return util.InfoMsg{
757 Type: util.InfoTypeError,
758 Msg: err.Error(),
759 }
760 }
761 return nil
762 })
763 return tea.Batch(cmds...)
764}
765
766func (p *chatPage) Bindings() []key.Binding {
767 bindings := []key.Binding{
768 p.keyMap.NewSession,
769 p.keyMap.AddAttachment,
770 }
771 if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.IsBusy() {
772 cancelBinding := p.keyMap.Cancel
773 if p.isCanceling {
774 cancelBinding = key.NewBinding(
775 key.WithKeys("esc", "alt+esc"),
776 key.WithHelp("esc", "press again to cancel"),
777 )
778 }
779 bindings = append([]key.Binding{cancelBinding}, bindings...)
780 }
781
782 switch p.focusedPane {
783 case PanelTypeChat:
784 bindings = append([]key.Binding{
785 key.NewBinding(
786 key.WithKeys("tab"),
787 key.WithHelp("tab", "focus editor"),
788 ),
789 }, bindings...)
790 bindings = append(bindings, p.chat.Bindings()...)
791 case PanelTypeEditor:
792 bindings = append([]key.Binding{
793 key.NewBinding(
794 key.WithKeys("tab"),
795 key.WithHelp("tab", "focus chat"),
796 ),
797 }, bindings...)
798 bindings = append(bindings, p.editor.Bindings()...)
799 case PanelTypeSplash:
800 bindings = append(bindings, p.splash.Bindings()...)
801 }
802
803 return bindings
804}
805
806func (p *chatPage) Help() help.KeyMap {
807 var shortList []key.Binding
808 var fullList [][]key.Binding
809 switch {
810 case p.isOnboarding && !p.splash.IsShowingAPIKey():
811 shortList = append(shortList,
812 // Choose model
813 key.NewBinding(
814 key.WithKeys("up", "down"),
815 key.WithHelp("↑/↓", "choose"),
816 ),
817 // Accept selection
818 key.NewBinding(
819 key.WithKeys("enter", "ctrl+y"),
820 key.WithHelp("enter", "accept"),
821 ),
822 // Quit
823 key.NewBinding(
824 key.WithKeys("ctrl+c"),
825 key.WithHelp("ctrl+c", "quit"),
826 ),
827 )
828 // keep them the same
829 for _, v := range shortList {
830 fullList = append(fullList, []key.Binding{v})
831 }
832 case p.isOnboarding && p.splash.IsShowingAPIKey():
833 if p.splash.IsAPIKeyValid() {
834 shortList = append(shortList,
835 key.NewBinding(
836 key.WithKeys("enter"),
837 key.WithHelp("enter", "continue"),
838 ),
839 )
840 } else {
841 shortList = append(shortList,
842 // Go back
843 key.NewBinding(
844 key.WithKeys("esc", "alt+esc"),
845 key.WithHelp("esc", "back"),
846 ),
847 )
848 }
849 shortList = append(shortList,
850 // Quit
851 key.NewBinding(
852 key.WithKeys("ctrl+c"),
853 key.WithHelp("ctrl+c", "quit"),
854 ),
855 )
856 // keep them the same
857 for _, v := range shortList {
858 fullList = append(fullList, []key.Binding{v})
859 }
860 case p.isProjectInit:
861 shortList = append(shortList,
862 key.NewBinding(
863 key.WithKeys("ctrl+c"),
864 key.WithHelp("ctrl+c", "quit"),
865 ),
866 )
867 // keep them the same
868 for _, v := range shortList {
869 fullList = append(fullList, []key.Binding{v})
870 }
871 default:
872 if p.editor.IsCompletionsOpen() {
873 shortList = append(shortList,
874 key.NewBinding(
875 key.WithKeys("tab", "enter"),
876 key.WithHelp("tab/enter", "complete"),
877 ),
878 key.NewBinding(
879 key.WithKeys("esc", "alt+esc"),
880 key.WithHelp("esc", "cancel"),
881 ),
882 key.NewBinding(
883 key.WithKeys("up", "down"),
884 key.WithHelp("↑/↓", "choose"),
885 ),
886 )
887 for _, v := range shortList {
888 fullList = append(fullList, []key.Binding{v})
889 }
890 return core.NewSimpleHelp(shortList, fullList)
891 }
892 if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.IsBusy() {
893 cancelBinding := key.NewBinding(
894 key.WithKeys("esc", "alt+esc"),
895 key.WithHelp("esc", "cancel"),
896 )
897 if p.isCanceling {
898 cancelBinding = key.NewBinding(
899 key.WithKeys("esc", "alt+esc"),
900 key.WithHelp("esc", "press again to cancel"),
901 )
902 }
903 if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.QueuedPrompts(p.session.ID) > 0 {
904 cancelBinding = key.NewBinding(
905 key.WithKeys("esc", "alt+esc"),
906 key.WithHelp("esc", "clear queue"),
907 )
908 }
909 shortList = append(shortList, cancelBinding)
910 fullList = append(fullList,
911 []key.Binding{
912 cancelBinding,
913 },
914 )
915 }
916 globalBindings := []key.Binding{}
917 // we are in a session
918 if p.session.ID != "" {
919 tabKey := key.NewBinding(
920 key.WithKeys("tab"),
921 key.WithHelp("tab", "focus chat"),
922 )
923 if p.focusedPane == PanelTypeChat {
924 tabKey = key.NewBinding(
925 key.WithKeys("tab"),
926 key.WithHelp("tab", "focus editor"),
927 )
928 }
929 shortList = append(shortList, tabKey)
930 globalBindings = append(globalBindings, tabKey)
931 }
932 commandsBinding := key.NewBinding(
933 key.WithKeys("ctrl+p"),
934 key.WithHelp("ctrl+p", "commands"),
935 )
936 helpBinding := key.NewBinding(
937 key.WithKeys("ctrl+g"),
938 key.WithHelp("ctrl+g", "more"),
939 )
940 globalBindings = append(globalBindings, commandsBinding)
941 globalBindings = append(globalBindings,
942 key.NewBinding(
943 key.WithKeys("ctrl+s"),
944 key.WithHelp("ctrl+s", "sessions"),
945 ),
946 )
947 if p.session.ID != "" {
948 globalBindings = append(globalBindings,
949 key.NewBinding(
950 key.WithKeys("ctrl+n"),
951 key.WithHelp("ctrl+n", "new sessions"),
952 ))
953 }
954 shortList = append(shortList,
955 // Commands
956 commandsBinding,
957 )
958 fullList = append(fullList, globalBindings)
959
960 switch p.focusedPane {
961 case PanelTypeChat:
962 shortList = append(shortList,
963 key.NewBinding(
964 key.WithKeys("up", "down"),
965 key.WithHelp("↑↓", "scroll"),
966 ),
967 messages.CopyKey,
968 )
969 fullList = append(fullList,
970 []key.Binding{
971 key.NewBinding(
972 key.WithKeys("up", "down"),
973 key.WithHelp("↑↓", "scroll"),
974 ),
975 key.NewBinding(
976 key.WithKeys("shift+up", "shift+down"),
977 key.WithHelp("shift+↑↓", "next/prev item"),
978 ),
979 key.NewBinding(
980 key.WithKeys("pgup", "b"),
981 key.WithHelp("b/pgup", "page up"),
982 ),
983 key.NewBinding(
984 key.WithKeys("pgdown", " ", "f"),
985 key.WithHelp("f/pgdn", "page down"),
986 ),
987 },
988 []key.Binding{
989 key.NewBinding(
990 key.WithKeys("u"),
991 key.WithHelp("u", "half page up"),
992 ),
993 key.NewBinding(
994 key.WithKeys("d"),
995 key.WithHelp("d", "half page down"),
996 ),
997 key.NewBinding(
998 key.WithKeys("g", "home"),
999 key.WithHelp("g", "home"),
1000 ),
1001 key.NewBinding(
1002 key.WithKeys("G", "end"),
1003 key.WithHelp("G", "end"),
1004 ),
1005 },
1006 []key.Binding{
1007 messages.CopyKey,
1008 messages.ClearSelectionKey,
1009 },
1010 )
1011 case PanelTypeEditor:
1012 newLineBinding := key.NewBinding(
1013 key.WithKeys("shift+enter", "ctrl+j"),
1014 // "ctrl+j" is a common keybinding for newline in many editors. If
1015 // the terminal supports "shift+enter", we substitute the help text
1016 // to reflect that.
1017 key.WithHelp("ctrl+j", "newline"),
1018 )
1019 if p.keyboardEnhancements.SupportsKeyDisambiguation() {
1020 newLineBinding.SetHelp("shift+enter", newLineBinding.Help().Desc)
1021 }
1022 shortList = append(shortList, newLineBinding)
1023 fullList = append(fullList,
1024 []key.Binding{
1025 newLineBinding,
1026 key.NewBinding(
1027 key.WithKeys("ctrl+f"),
1028 key.WithHelp("ctrl+f", "add image"),
1029 ),
1030 key.NewBinding(
1031 key.WithKeys("/"),
1032 key.WithHelp("/", "add file"),
1033 ),
1034 key.NewBinding(
1035 key.WithKeys("ctrl+o"),
1036 key.WithHelp("ctrl+o", "open editor"),
1037 ),
1038 })
1039
1040 if p.editor.HasAttachments() {
1041 fullList = append(fullList, []key.Binding{
1042 key.NewBinding(
1043 key.WithKeys("ctrl+r"),
1044 key.WithHelp("ctrl+r+{i}", "delete attachment at index i"),
1045 ),
1046 key.NewBinding(
1047 key.WithKeys("ctrl+r", "r"),
1048 key.WithHelp("ctrl+r+r", "delete all attachments"),
1049 ),
1050 key.NewBinding(
1051 key.WithKeys("esc", "alt+esc"),
1052 key.WithHelp("esc", "cancel delete mode"),
1053 ),
1054 })
1055 }
1056 }
1057 shortList = append(shortList,
1058 // Quit
1059 key.NewBinding(
1060 key.WithKeys("ctrl+c"),
1061 key.WithHelp("ctrl+c", "quit"),
1062 ),
1063 // Help
1064 helpBinding,
1065 )
1066 fullList = append(fullList, []key.Binding{
1067 key.NewBinding(
1068 key.WithKeys("ctrl+g"),
1069 key.WithHelp("ctrl+g", "less"),
1070 ),
1071 })
1072 }
1073
1074 return core.NewSimpleHelp(shortList, fullList)
1075}
1076
1077func (p *chatPage) IsChatFocused() bool {
1078 return p.focusedPane == PanelTypeChat
1079}
1080
1081// isMouseOverChat checks if the given mouse coordinates are within the chat area bounds.
1082// Returns true if the mouse is over the chat area, false otherwise.
1083func (p *chatPage) isMouseOverChat(x, y int) bool {
1084 // No session means no chat area
1085 if p.session.ID == "" {
1086 return false
1087 }
1088
1089 var chatX, chatY, chatWidth, chatHeight int
1090
1091 if p.compact {
1092 // In compact mode: chat area starts after header and spans full width
1093 chatX = 0
1094 chatY = HeaderHeight
1095 chatWidth = p.width
1096 chatHeight = p.height - EditorHeight - HeaderHeight
1097 } else {
1098 // In non-compact mode: chat area spans from left edge to sidebar
1099 chatX = 0
1100 chatY = 0
1101 chatWidth = p.width - SideBarWidth
1102 chatHeight = p.height - EditorHeight
1103 }
1104
1105 // Check if mouse coordinates are within chat bounds
1106 return x >= chatX && x < chatX+chatWidth && y >= chatY && y < chatY+chatHeight
1107}