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 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) (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 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 if err := cfg.UpdatePreferredModel(agentCfg.Model, currentModel); err != nil {
539 return util.InfoMsg{
540 Type: util.InfoTypeError,
541 Msg: "Failed to update thinking mode: " + err.Error(),
542 }
543 }
544
545 // Update the agent with the new configuration
546 go p.app.UpdateAgentModel(context.TODO())
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 modelsBinding := key.NewBinding(
946 key.WithKeys("ctrl+m", "ctrl+l"),
947 key.WithHelp("ctrl+l", "models"),
948 )
949 if p.keyboardEnhancements.SupportsKeyDisambiguation() {
950 modelsBinding.SetHelp("ctrl+m", "models")
951 }
952 helpBinding := key.NewBinding(
953 key.WithKeys("ctrl+g"),
954 key.WithHelp("ctrl+g", "more"),
955 )
956 globalBindings = append(globalBindings, commandsBinding, modelsBinding)
957 globalBindings = append(globalBindings,
958 key.NewBinding(
959 key.WithKeys("ctrl+s"),
960 key.WithHelp("ctrl+s", "sessions"),
961 ),
962 )
963 if p.session.ID != "" {
964 globalBindings = append(globalBindings,
965 key.NewBinding(
966 key.WithKeys("ctrl+n"),
967 key.WithHelp("ctrl+n", "new sessions"),
968 ))
969 }
970 shortList = append(shortList,
971 // Commands
972 commandsBinding,
973 modelsBinding,
974 )
975 fullList = append(fullList, globalBindings)
976
977 switch p.focusedPane {
978 case PanelTypeChat:
979 shortList = append(shortList,
980 key.NewBinding(
981 key.WithKeys("up", "down"),
982 key.WithHelp("↑↓", "scroll"),
983 ),
984 messages.CopyKey,
985 )
986 fullList = append(fullList,
987 []key.Binding{
988 key.NewBinding(
989 key.WithKeys("up", "down"),
990 key.WithHelp("↑↓", "scroll"),
991 ),
992 key.NewBinding(
993 key.WithKeys("shift+up", "shift+down"),
994 key.WithHelp("shift+↑↓", "next/prev item"),
995 ),
996 key.NewBinding(
997 key.WithKeys("pgup", "b"),
998 key.WithHelp("b/pgup", "page up"),
999 ),
1000 key.NewBinding(
1001 key.WithKeys("pgdown", " ", "f"),
1002 key.WithHelp("f/pgdn", "page down"),
1003 ),
1004 },
1005 []key.Binding{
1006 key.NewBinding(
1007 key.WithKeys("u"),
1008 key.WithHelp("u", "half page up"),
1009 ),
1010 key.NewBinding(
1011 key.WithKeys("d"),
1012 key.WithHelp("d", "half page down"),
1013 ),
1014 key.NewBinding(
1015 key.WithKeys("g", "home"),
1016 key.WithHelp("g", "home"),
1017 ),
1018 key.NewBinding(
1019 key.WithKeys("G", "end"),
1020 key.WithHelp("G", "end"),
1021 ),
1022 },
1023 []key.Binding{
1024 messages.CopyKey,
1025 messages.ClearSelectionKey,
1026 },
1027 )
1028 case PanelTypeEditor:
1029 newLineBinding := key.NewBinding(
1030 key.WithKeys("shift+enter", "ctrl+j"),
1031 // "ctrl+j" is a common keybinding for newline in many editors. If
1032 // the terminal supports "shift+enter", we substitute the help text
1033 // to reflect that.
1034 key.WithHelp("ctrl+j", "newline"),
1035 )
1036 if p.keyboardEnhancements.Flags > 0 {
1037 // Non-zero flags mean we have at least key disambiguation.
1038 newLineBinding.SetHelp("shift+enter", newLineBinding.Help().Desc)
1039 }
1040 shortList = append(shortList, newLineBinding)
1041 fullList = append(fullList,
1042 []key.Binding{
1043 newLineBinding,
1044 key.NewBinding(
1045 key.WithKeys("ctrl+f"),
1046 key.WithHelp("ctrl+f", "add image"),
1047 ),
1048 key.NewBinding(
1049 key.WithKeys("@"),
1050 key.WithHelp("@", "mention file"),
1051 ),
1052 key.NewBinding(
1053 key.WithKeys("ctrl+o"),
1054 key.WithHelp("ctrl+o", "open editor"),
1055 ),
1056 })
1057
1058 if p.editor.HasAttachments() {
1059 fullList = append(fullList, []key.Binding{
1060 key.NewBinding(
1061 key.WithKeys("ctrl+r"),
1062 key.WithHelp("ctrl+r+{i}", "delete attachment at index i"),
1063 ),
1064 key.NewBinding(
1065 key.WithKeys("ctrl+r", "r"),
1066 key.WithHelp("ctrl+r+r", "delete all attachments"),
1067 ),
1068 key.NewBinding(
1069 key.WithKeys("esc", "alt+esc"),
1070 key.WithHelp("esc", "cancel delete mode"),
1071 ),
1072 })
1073 }
1074 }
1075 shortList = append(shortList,
1076 // Quit
1077 key.NewBinding(
1078 key.WithKeys("ctrl+c"),
1079 key.WithHelp("ctrl+c", "quit"),
1080 ),
1081 // Help
1082 helpBinding,
1083 )
1084 fullList = append(fullList, []key.Binding{
1085 key.NewBinding(
1086 key.WithKeys("ctrl+g"),
1087 key.WithHelp("ctrl+g", "less"),
1088 ),
1089 })
1090 }
1091
1092 return core.NewSimpleHelp(shortList, fullList)
1093}
1094
1095func (p *chatPage) IsChatFocused() bool {
1096 return p.focusedPane == PanelTypeChat
1097}
1098
1099// isMouseOverChat checks if the given mouse coordinates are within the chat area bounds.
1100// Returns true if the mouse is over the chat area, false otherwise.
1101func (p *chatPage) isMouseOverChat(x, y int) bool {
1102 // No session means no chat area
1103 if p.session.ID == "" {
1104 return false
1105 }
1106
1107 var chatX, chatY, chatWidth, chatHeight int
1108
1109 if p.compact {
1110 // In compact mode: chat area starts after header and spans full width
1111 chatX = 0
1112 chatY = HeaderHeight
1113 chatWidth = p.width
1114 chatHeight = p.height - EditorHeight - HeaderHeight
1115 } else {
1116 // In non-compact mode: chat area spans from left edge to sidebar
1117 chatX = 0
1118 chatY = 0
1119 chatWidth = p.width - SideBarWidth
1120 chatHeight = p.height - EditorHeight
1121 }
1122
1123 // Check if mouse coordinates are within chat bounds
1124 return x >= chatX && x < chatX+chatWidth && y >= chatY && y < chatY+chatHeight
1125}