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