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