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