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