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